diff --git a/.gitignore b/.gitignore index b1974a108..94146e076 100644 --- a/.gitignore +++ b/.gitignore @@ -175,6 +175,8 @@ dump.rdb .worktrees/ .claude/ +.grunt-build-stamp + # Local TODO / audit notes (not committed) TODO-*.txt TODO-*.md diff --git a/CLAUDE.md b/CLAUDE.md index 8adf68597..516dac5e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,22 @@ management system built with Django. Python >=3.10,<3.15. - Public frontend (Foundation CSS): monochrome Foundation-Icons (``) - Django admin (`templates/admin/`): use emoji (no Foundation Icons) +- **Django template comments `{# ... #}` są jedno-liniowe — KAZDA LINIA + MUSI mieć własne otwarcie `{#` i zamknięcie `#}` na tej samej linii.** + Po `\n` w środku komentarza parser przestaje go widzieć i tekst wycieka + do wyrenderowanego HTML-u. Powtarzający się błąd. Reguła: + - ❌ ZABRONIONE wieloliniowe komentarze typu: + ```django + {# linia 1 + linia 2 #} + ``` + - ✅ ZAWSZE każda linia z osobnym `{# ... #}`: + ```django + {# linia 1 #} + {# linia 2 #} + ``` + - Alternatywa dla bloków: `{% comment %}...{% endcomment %}` (też OK, + ale per-line `{# #}` jest preferowane przez użytkownika). ## Python and Django Execution diff --git a/Makefile b/Makefile index 001e82d0f..fac15cb9e 100644 --- a/Makefile +++ b/Makefile @@ -119,13 +119,13 @@ clean-pycache: ## Usuń __pycache__, *.pyc oraz .eggs/.cache rm -rf .eggs .cache clean: clean-pycache ## Szersze czyszczenie: egg-info, logi, build, dist, staticroot/CACHE, .tox + rm -f .grunt-build-stamp find . -type d -name \*egg-info -print0 | xargs -0 rm -rf find . -name \*~ -print0 | xargs -0 rm -f find . -name \*.prof -print0 | xargs -0 rm -f rm -rf prof/ find . -name \*\\.log -print0 | xargs -0 rm -f - find . -name \*\\.log -print0 | xargs -0 rm -f - find . -name \#\* -print0 | xargs -0 rm -f + find . -name \#\* -not -path './node_modules/*' -print0 | xargs -0 rm -rf rm -rf build dist/*django_bpp*whl dist/*bpp_iplweb*whl *.log dist rm -rf src/django_bpp/staticroot/CACHE rm -rf .tox @@ -149,11 +149,13 @@ distclean: clean ## Pełne czyszczenie: + node_modules, staticroot, media, dist, grunt-build: ## Uruchom `grunt build` (SCSS → CSS, bundling JS) grunt build -# CSS output files (targets) -CSS_TARGETS := src/bpp/static/scss/app-blue.css src/bpp/static/scss/app-green.css src/bpp/static/scss/app-orange.css +# grunt build kompiluje WSZYSTKIE SCSS → CSS za jednym odpaleniem. +# Pattern rule $(CSS_TARGETS): $(SCSS_SOURCES) odpalałby grunt N razy +# (raz per out-of-date target). Zamiast tego: jeden stamp file zależy od +# wszystkich SCSS + node_modules; grunt dotyka stampu po zakończeniu. -# SCSS source files -SCSS_SOURCES := $(wildcard src/bpp/static/scss/*.scss) +SCSS_SOURCES := $(wildcard src/bpp/static/scss/*.scss) \ + $(wildcard src/*/static/*/scss/*.scss) # Node modules dependency NODE_MODULES := node_modules/.installed @@ -166,14 +168,16 @@ $(NODE_MODULES): package.json yarn.lock export PUPPETEER_SKIP_CHROME_DOWNLOAD=true PUPPETEER_SKIP_CHROME_HEADLESS_SHELL_DOWNLOAD=true && $(YARN_CMD) install --no-progress --emoji false -s touch $(NODE_MODULES) -$(CSS_TARGETS): $(SCSS_SOURCES) $(NODE_MODULES) +CSS_STAMP := .grunt-build-stamp + +$(CSS_STAMP): $(SCSS_SOURCES) $(NODE_MODULES) grunt build + @touch $(CSS_STAMP) $(MO_FILES): $(PO_FILES) - # cd src && django-admin compilemessages uv run python src/manage.py compilemessages --locale=pl --ignore=site-packages -assets: $(CSS_TARGETS) $(MO_FILES) ## Zbuduj frontend (CSS + .mo); uruchamia `yarn install` jeśli trzeba +assets: $(CSS_STAMP) $(MO_FILES) ## Zbuduj frontend (CSS + .mo); uruchamia `yarn install` jeśli trzeba yarn: $(NODE_MODULES) ## Zainstaluj zależności Node.js (yarn install) diff --git a/src/bpp/newsfragments/+deduplikator-autorow-general.feature.rst b/src/bpp/newsfragments/+deduplikator-autorow-general.feature.rst new file mode 100644 index 000000000..2da6d38b7 --- /dev/null +++ b/src/bpp/newsfragments/+deduplikator-autorow-general.feature.rst @@ -0,0 +1,6 @@ +Deduplikator autorów: nowy tryb "ogólny" znajdujący duplikaty wśród +autorów spoza listy pracowników instytucji w PBN. Jeden przycisk +"Skanuj duplikaty" uruchamia obie fazy (PBN + ogólna) sekwencyjnie. +Widok pozwala filtrować wyniki radio-button-em (PBN/Ogólny/Oba), +eksport XLSX zawiera kolumnę "Tryb". Anulowanie fazy ogólnej skutkuje +statusem "Częściowo zakończone" — wyniki PBN pozostają dostępne. diff --git a/src/bpp/newsfragments/+deduplikator-autorow-ui-overhaul.feature.rst b/src/bpp/newsfragments/+deduplikator-autorow-ui-overhaul.feature.rst new file mode 100644 index 000000000..cc19ee26b --- /dev/null +++ b/src/bpp/newsfragments/+deduplikator-autorow-ui-overhaul.feature.rst @@ -0,0 +1,28 @@ +Deduplikator autorów: gruntowna przebudowa UI. Tytuł i pozycje +menu uproszczone z "Deduplikator autorów PBN" na "Deduplikator +autorów" (bez znacznika BETA), wpis dodany dodatkowo do podmenu +"Operacje". Tryb skanowania (PBN/ogólny) prezentowany jest jako +kolorowy badge przy "Główny rekord autora", filtr "Pokaż wyniki" +zmieniony z radio-buttonów na poziomy button-group. + +Przyciski na karcie każdego potencjalnego duplikatu pogrupowane +w trzy logiczne sekcje: Podgląd (otwórz wyd. ciągłe/zwarte, +redagowanie, stronę główną, PBN), Decyzja ("Nie jest duplikatem +głównego autora", usuń autora bez publikacji), Scalanie (cztery +warianty scalania). Przyciski "Scal + ustaw dyscyplinę" oraz +"Scal + ustaw subdyscyplinę" są ukryte, gdy główny autor nie ma +żadnej dyscypliny. + +Powody podobieństwa renderowane są jako kolorowe chipy z ikonami +Foundation, z tonami match/info/weak/warn dobranymi do siły +przesłanki. Procent pewności jest sklampowany do zakresu 0–100% +(wcześniej widoczne były wartości typu 140% wynikające z surowego +score). + +Naprawione: oznaczenie autora jako nie-duplikat (przycisk +"Nie jest duplikatem głównego autora") wykonuje się teraz przez +AJAX z fadeOut karty, zamiast przeładowywać widok i przeskakiwać +do kolejnego głównego autora. Naprawiono też "Scal wszystkie", +który dla kandydatów z trybu ogólnego zwracał błąd 400 (JS +wysyłał ``main_scientist_id`` zamiast ``main_autor_id``); brakujące +parametry trafiają teraz dodatkowo do Rollbara. diff --git a/src/bpp/system.py b/src/bpp/system.py index 17be57ff6..210421b15 100644 --- a/src/bpp/system.py +++ b/src/bpp/system.py @@ -78,7 +78,7 @@ from bpp.models.struktura import Jednostka_Wydzial from bpp.models.system import Charakter_PBN from bpp.models.wydawca import Poziom_Wydawcy, Wydawca -from deduplikator_autorow.models import IgnoredAuthor, LogScalania, NotADuplicate +from deduplikator_autorow.models import IgnoredScientist, LogScalania, NotADuplicate from dynamic_columns.models import ModelAdmin, ModelAdminColumn from ewaluacja_common.models import Rodzaj_Autora from ewaluacja_liczba_n.models import IloscUdzialowDlaAutoraZaRok, LiczbaNDlaUczelni @@ -189,7 +189,7 @@ RozbieznosciZrodelView, NotADuplicate, LogScalania, - IgnoredAuthor, + IgnoredScientist, ], "indeks autorów": [Autor, Autor_Jednostka], "administracja": [ diff --git a/src/bpp/tests/test_autocomplete/test_autocomplete_authors.py b/src/bpp/tests/test_autocomplete/test_autocomplete_authors.py index b2c5cc003..8deefa593 100644 --- a/src/bpp/tests/test_autocomplete/test_autocomplete_authors.py +++ b/src/bpp/tests/test_autocomplete/test_autocomplete_authors.py @@ -20,7 +20,6 @@ ) - def test_dyscyplina_naukowa_przypisanie_autocomplete( app, autor_jan_kowalski, dyscyplina1, dyscyplina2, rok ): @@ -75,7 +74,6 @@ def test_dyscyplina_naukowa_przypisanie_autocomplete( assert res.json["results"][0]["text"] == "memetyka stosowana" - def test_dyscyplina_naukowa_przypisanie_autocomplete_brak_autora( app, ): @@ -90,7 +88,6 @@ def test_dyscyplina_naukowa_przypisanie_autocomplete_brak_autora( assert res.json["results"][0]["text"] == "Podaj autora" - def test_dyscyplina_naukowa_przypisanie_autocomplete_brak_drugiej( app, autor_jan_kowalski, dyscyplina1, dyscyplina2, rok ): @@ -133,6 +130,30 @@ def autocomplete(s): assert Autor.objects.first().imiona == "Baz Quux" +@pytest.mark.django_db +def test_AutorAutocomplete_create_object_creates_log_entry(rf, admin_user, db): + from django.contrib.admin.models import ADDITION, LogEntry + from django.contrib.contenttypes.models import ContentType + + autor_count_before = Autor.objects.count() + + ac = AutorAutocomplete() + ac.request = rf.post("/", data={"text": "Kowalski Jan"}) + ac.request.user = admin_user + + obj = ac.create_object("Kowalski Jan") + + assert obj.pk != -1 + assert Autor.objects.count() == autor_count_before + 1 + + ct = ContentType.objects.get_for_model(Autor) + log = LogEntry.objects.get( + content_type=ct, object_id=str(obj.pk), action_flag=ADDITION + ) + assert log.user == admin_user + assert "autocomplete" in log.change_message + + @pytest.mark.django_db def test_Status_KorektyAutocomplete(statusy_korekt): """Test status korekty autocomplete filtering.""" diff --git a/src/bpp/views/autocomplete/authors.py b/src/bpp/views/autocomplete/authors.py index 1cea8c001..0373b49a6 100644 --- a/src/bpp/views/autocomplete/authors.py +++ b/src/bpp/views/autocomplete/authors.py @@ -94,10 +94,27 @@ class AutorAutocomplete(GroupRequiredMixin, AutorAutocompleteBase): def create_object(self, text): try: - return Autor.objects.create_from_string(text) + obj = Autor.objects.create_from_string(text) except ValueError: return self.err + from django.contrib.admin.models import ADDITION, LogEntry + from django.contrib.contenttypes.models import ContentType + + try: + LogEntry.objects.create( + user_id=self.request.user.pk, + content_type_id=ContentType.objects.get_for_model(Autor).pk, + object_id=str(obj.pk), + object_repr=str(obj)[:200], + action_flag=ADDITION, + change_message="Utworzono z formularza autocomplete", + ) + except (AttributeError, TypeError): + pass + + return obj + class PublicAutorAutocomplete(AutorAutocompleteBase): """Public autocomplete for authors (no create, no PBN/MNISW markers).""" diff --git a/src/deduplikator_autorow/admin.py b/src/deduplikator_autorow/admin.py index 8f4fe5e8d..407f2e8a2 100644 --- a/src/deduplikator_autorow/admin.py +++ b/src/deduplikator_autorow/admin.py @@ -10,6 +10,7 @@ DuplicateCandidate, DuplicateScanRun, IgnoredAuthor, + IgnoredScientist, LogScalania, NotADuplicate, ) @@ -76,8 +77,8 @@ def get_author_last_name(self, obj): get_author_last_name.admin_order_field = "scientist_pk" -@admin.register(IgnoredAuthor) -class IgnoredAuthorAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): +@admin.register(IgnoredScientist) +class IgnoredScientistAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): list_display = [ "get_scientist_display", "get_autor_display", @@ -133,6 +134,42 @@ def save_model(self, request, obj, form, change): super().save_model(request, obj, form, change) +@admin.register(IgnoredAuthor) +class IgnoredAuthorAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): + list_display = [ + "get_autor_display", + "reason", + "created_by", + "created_on", + ] + + list_filter = ["created_on", "created_by"] + + search_fields = [ + "autor__nazwisko", + "autor__imiona", + "reason", + "created_by__username", + ] + + readonly_fields = ["created_on"] + date_hierarchy = "created_on" + ordering = ["-created_on"] + + def get_autor_display(self, obj): + if obj.autor: + url = reverse("admin:bpp_autor_change", args=[obj.autor.pk]) + return mark_safe(f'{obj.autor}') + return "-" + + get_autor_display.short_description = "Autor (BPP)" + + def save_model(self, request, obj, form, change): + if not change: + obj.created_by = request.user + super().save_model(request, obj, form, change) + + @admin.register(LogScalania) class LogScalaniaAdmin(DynamicAdminFilterMixin, admin.ModelAdmin): list_display = [ diff --git a/src/deduplikator_autorow/migrations/0009_rename_ignoredauthor_ignoredscientist.py b/src/deduplikator_autorow/migrations/0009_rename_ignoredauthor_ignoredscientist.py new file mode 100644 index 000000000..cff55c56e --- /dev/null +++ b/src/deduplikator_autorow/migrations/0009_rename_ignoredauthor_ignoredscientist.py @@ -0,0 +1,23 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("deduplikator_autorow", "0008_add_priority_field"), + ] + + operations = [ + migrations.RenameModel( + old_name="IgnoredAuthor", + new_name="IgnoredScientist", + ), + migrations.AlterModelOptions( + name="ignoredscientist", + options={ + "ordering": ["-created_on"], + "verbose_name": "Ignorowany Scientist (PBN)", + "verbose_name_plural": "Ignorowani Scientist (PBN)", + }, + ), + ] diff --git a/src/deduplikator_autorow/migrations/0010_add_ignored_author.py b/src/deduplikator_autorow/migrations/0010_add_ignored_author.py new file mode 100644 index 000000000..b9f55e6dc --- /dev/null +++ b/src/deduplikator_autorow/migrations/0010_add_ignored_author.py @@ -0,0 +1,142 @@ +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +def rename_leftover_ignoredauthor_indexes(apps, schema_editor): + """Rename indexes that PostgreSQL kept after RenameModel in 0009. + + Migration 0009 renamed the IgnoredAuthor model to IgnoredScientist, which + in PostgreSQL renames the table but keeps existing index names. Those + `deduplikator_autorow_ignoredauthor_*` index names would collide with + auto-generated names for the new IgnoredAuthor model created here. + + We rename them to match the new (IgnoredScientist) table to avoid the + collision and keep names consistent with the actual table. SQL is + idempotent (uses IF EXISTS) so it works against fresh DBs too. + """ + renames = [ + ( + "deduplikator_autorow_ignoredauthor_autor_id_5e237500", + "deduplikator_autorow_ignoredsci_autor_id_5e237500", + ), + ( + "deduplikator_autorow_ignoredauthor_created_by_id_3d0a197e", + "deduplikator_autorow_ignoredsci_created_by_id_3d0a197e", + ), + ( + "deduplikator_autorow_ignoredauthor_scientist_id_ae6083d3_like", + "deduplikator_autorow_ignoredsci_scientist_id_ae6083d3_like", + ), + ( + "deduplikator_autorow_ignoredauthor_pkey", + "deduplikator_autorow_ignoredscientist_pkey", + ), + ( + "deduplikator_autorow_ignoredauthor_scientist_id_key", + "deduplikator_autorow_ignoredscientist_scientist_id_key", + ), + ] + with schema_editor.connection.cursor() as cursor: + for old_name, new_name in renames: + cursor.execute( + f'ALTER INDEX IF EXISTS "{old_name}" RENAME TO "{new_name}"' + ) + + +def reverse_rename_leftover_ignoredauthor_indexes(apps, schema_editor): + renames = [ + ( + "deduplikator_autorow_ignoredsci_autor_id_5e237500", + "deduplikator_autorow_ignoredauthor_autor_id_5e237500", + ), + ( + "deduplikator_autorow_ignoredsci_created_by_id_3d0a197e", + "deduplikator_autorow_ignoredauthor_created_by_id_3d0a197e", + ), + ( + "deduplikator_autorow_ignoredsci_scientist_id_ae6083d3_like", + "deduplikator_autorow_ignoredauthor_scientist_id_ae6083d3_like", + ), + ( + "deduplikator_autorow_ignoredscientist_pkey", + "deduplikator_autorow_ignoredauthor_pkey", + ), + ( + "deduplikator_autorow_ignoredscientist_scientist_id_key", + "deduplikator_autorow_ignoredauthor_scientist_id_key", + ), + ] + with schema_editor.connection.cursor() as cursor: + for old_name, new_name in renames: + cursor.execute( + f'ALTER INDEX IF EXISTS "{old_name}" RENAME TO "{new_name}"' + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("bpp", "0413_bppuser_autor_onetoone"), + ("deduplikator_autorow", "0009_rename_ignoredauthor_ignoredscientist"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RunPython( + rename_leftover_ignoredauthor_indexes, + reverse_rename_leftover_ignoredauthor_indexes, + ), + migrations.CreateModel( + name="IgnoredAuthor", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "reason", + models.CharField( + blank=True, + max_length=500, + verbose_name="Powód ignorowania", + ), + ), + ( + "created_on", + models.DateTimeField( + default=django.utils.timezone.now, + verbose_name="Data utworzenia", + ), + ), + ( + "autor", + models.OneToOneField( + help_text="Autor BPP do ignorowania w deduplikacji ogólnej", + on_delete=django.db.models.deletion.CASCADE, + to="bpp.autor", + verbose_name="Autor (BPP)", + ), + ), + ( + "created_by", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + verbose_name="Utworzył", + ), + ), + ], + options={ + "verbose_name": "Ignorowany autor (BPP)", + "verbose_name_plural": "Ignorowani autorzy (BPP)", + "ordering": ["-created_on"], + }, + ), + ] diff --git a/src/deduplikator_autorow/migrations/0011_scan_mode_phase_partial.py b/src/deduplikator_autorow/migrations/0011_scan_mode_phase_partial.py new file mode 100644 index 000000000..9e56f826e --- /dev/null +++ b/src/deduplikator_autorow/migrations/0011_scan_mode_phase_partial.py @@ -0,0 +1,71 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("deduplikator_autorow", "0010_add_ignored_author"), + ] + + operations = [ + migrations.AlterField( + model_name="duplicatescanrun", + name="status", + field=models.CharField( + choices=[ + ("pending", "Oczekuje"), + ("running", "W trakcie"), + ("completed", "Zakończone"), + ( + "partial_completed", + "Częściowo zakończone (faza PBN OK, general anulowana)", + ), + ("cancelled", "Anulowane"), + ("failed", "Błąd"), + ], + db_index=True, + default="pending", + max_length=20, + verbose_name="Status", + ), + ), + migrations.AddField( + model_name="duplicatescanrun", + name="phase", + field=models.CharField( + blank=True, + choices=[("pbn", "Faza PBN"), ("general", "Faza ogólna")], + max_length=20, + verbose_name="Aktualna faza", + ), + ), + migrations.AddField( + model_name="duplicatecandidate", + name="scan_mode", + field=models.CharField( + choices=[("pbn", "PBN"), ("general", "Ogólny")], + db_index=True, + default="pbn", + max_length=20, + verbose_name="Tryb skanowania", + ), + ), + migrations.RemoveConstraint( + model_name="duplicatecandidate", + name="unique_scan_main_duplicate", + ), + migrations.AddIndex( + model_name="duplicatecandidate", + index=models.Index( + fields=["scan_run", "scan_mode", "status"], + name="deduplikato_scan_ru_78ad22_idx", + ), + ), + migrations.AddConstraint( + model_name="duplicatecandidate", + constraint=models.UniqueConstraint( + fields=("scan_run", "scan_mode", "main_autor", "duplicate_autor"), + name="unique_scan_mode_main_duplicate", + ), + ), + ] diff --git a/src/deduplikator_autorow/models.py b/src/deduplikator_autorow/models.py index 9def5b9ea..33c299126 100644 --- a/src/deduplikator_autorow/models.py +++ b/src/deduplikator_autorow/models.py @@ -30,8 +30,8 @@ def __str__(self): return f"Autor {self.autor} (not duplicate) - {self.created_by}" -class IgnoredAuthor(models.Model): - """Authors that should be completely ignored in the deduplication process""" +class IgnoredScientist(models.Model): + """Scientists from PBN that should be completely ignored in deduplication""" scientist = models.OneToOneField( "pbn_api.Scientist", @@ -66,8 +66,8 @@ class IgnoredAuthor(models.Model): ) class Meta: - verbose_name = "Ignorowany autor" - verbose_name_plural = "Ignorowani autorzy" + verbose_name = "Ignorowany Scientist (PBN)" + verbose_name_plural = "Ignorowani Scientist (PBN)" ordering = ["-created_on"] def __str__(self): @@ -76,6 +76,39 @@ def __str__(self): return f"Ignorowany: Scientist #{self.scientist.pk}" +class IgnoredAuthor(models.Model): + """BPP authors (without PBN-Scientist link) that should be ignored in deduplication.""" + + autor = models.OneToOneField( + "bpp.Autor", + on_delete=models.CASCADE, + db_index=True, + verbose_name="Autor (BPP)", + help_text="Autor BPP do ignorowania w deduplikacji ogólnej", + ) + + reason = models.CharField( + max_length=500, + blank=True, + verbose_name="Powód ignorowania", + ) + + created_on = models.DateTimeField("Data utworzenia", default=timezone.now) + created_by = models.ForeignKey( + BppUser, + on_delete=models.CASCADE, + verbose_name="Utworzył", + ) + + class Meta: + verbose_name = "Ignorowany autor (BPP)" + verbose_name_plural = "Ignorowani autorzy (BPP)" + ordering = ["-created_on"] + + def __str__(self): + return f"Ignorowany autor: {self.autor}" + + class LogScalania(models.Model): """Log of author merge operations with detailed tracking""" @@ -226,6 +259,10 @@ class Status(models.TextChoices): PENDING = "pending", "Oczekuje" RUNNING = "running", "W trakcie" COMPLETED = "completed", "Zakończone" + PARTIAL_COMPLETED = ( + "partial_completed", + "Częściowo zakończone (faza PBN OK, general anulowana)", + ) CANCELLED = "cancelled", "Anulowane" FAILED = "failed", "Błąd" @@ -274,6 +311,13 @@ class Status(models.TextChoices): blank=True, ) + phase = models.CharField( + "Aktualna faza", + max_length=20, + blank=True, + choices=[("pbn", "Faza PBN"), ("general", "Faza ogólna")], + ) + class Meta: verbose_name = "Skanowanie duplikatów" verbose_name_plural = "Skanowania duplikatów" @@ -352,6 +396,14 @@ class Status(models.TextChoices): help_text="Priorytet wyświetlania: 100=prace 2022-2025 z dyscyplinami, 50=prace 2022-2025, 0=inne", ) + scan_mode = models.CharField( + "Tryb skanowania", + max_length=20, + choices=[("pbn", "PBN"), ("general", "Ogólny")], + default="pbn", + db_index=True, + ) + # Status tracking status = models.CharField( "Status", @@ -402,11 +454,12 @@ class Meta: models.Index(fields=["scan_run", "status"]), models.Index(fields=["main_autor", "status"]), models.Index(fields=["priority", "confidence_score"]), + models.Index(fields=["scan_run", "scan_mode", "status"]), ] constraints = [ models.UniqueConstraint( - fields=["scan_run", "main_autor", "duplicate_autor"], - name="unique_scan_main_duplicate", + fields=["scan_run", "scan_mode", "main_autor", "duplicate_autor"], + name="unique_scan_mode_main_duplicate", ), ] diff --git a/src/deduplikator_autorow/static/deduplikator_autorow/scss/deduplikator_autorow.scss b/src/deduplikator_autorow/static/deduplikator_autorow/scss/deduplikator_autorow.scss index 45f7d7f94..4d3ca06e1 100644 --- a/src/deduplikator_autorow/static/deduplikator_autorow/scss/deduplikator_autorow.scss +++ b/src/deduplikator_autorow/static/deduplikator_autorow/scss/deduplikator_autorow.scss @@ -1,6 +1,17 @@ // Deduplikator Autorow - Styles // BEM convention: .deduplikator-autorow__element--modifier +// Foundation .label ma domyślnie kwadratowe rogi - w obrębie deduplikatora +// chcemy jednolitą "pigułkową" estetykę (zgodną z chipami powodów +// podobieństwa). Wrapper .deduplikator-autorow-page ogranicza override +// tylko do tej strony, żeby nie wpływać globalnie na inne widoki BPP. +.deduplikator-autorow-page .label { + border-radius: 999px; + padding: 3px 12px; + font-weight: 600; + letter-spacing: 0.02em; +} + // ============================================================================= // SIDEBAR ACCORDION // ============================================================================= @@ -252,6 +263,16 @@ color: #666; } +.deduplikator-autorow__search-btn-flat-right { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.deduplikator-autorow__search-btn-flat-right + .input-group-button .button { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + // ============================================================================= // DISCIPLINE TABLE // ============================================================================= @@ -306,25 +327,21 @@ .deduplikator-autorow__publication-list { max-height: 300px; overflow-y: auto; - border: 1px solid #e1e1e1; - padding: 10px; + padding: 0; } .deduplikator-autorow__publication-list--short { max-height: 250px; - background-color: #f9f9f9; } .deduplikator-autorow__publication-item { - margin-bottom: 8px; - padding: 5px; - border-left: 3px solid #1779ba; + margin-bottom: 4px; + padding: 0; } .deduplikator-autorow__publication-item--duplicate { - margin-bottom: 6px; - padding: 3px; - border-left: 2px solid #8a8a8a; + margin-bottom: 2px; + padding: 0; } .deduplikator-autorow__publication-link { @@ -352,6 +369,28 @@ .deduplikator-autorow__duplicate-card { margin-bottom: 20px; + // Niektóre nadrzędne layouty BPP wymuszają text-align: center w obrębie + // .callout (zaobserwowane w warningowych callout-ach panelu duplikatów — + // imiona/nazwiska autorów wyświetlały się wycentrowane). Wymuszamy + // domyślne wyrównanie do lewej dla całej karty. + text-align: left; +} + +// Stan "wyłączone" dla przycisków "Scal wszystkie" gdy w grupie jest kandydat +// poniżej progu pewności. Trzymamy je klikalne (do wyświetlenia komunikatu) +// dlatego nie używamy [disabled] - tylko aria-disabled + klasa wizualna. +.deduplikator-autorow__merge-all-btn--disabled, +.button.deduplikator-autorow__merge-all-btn--disabled { + opacity: 0.55; + cursor: not-allowed; + background-color: #b5b5b5 !important; + color: #fff !important; + + &:hover, + &:focus { + background-color: #b5b5b5 !important; + box-shadow: none; + } } .deduplikator-autorow__duplicate-header { @@ -406,6 +445,10 @@ .deduplikator-autorow__duplicates-header { margin-bottom: 15px; + + .grid-x + .grid-x { + margin-top: 0.5em; + } } .deduplikator-autorow__duplicates-title { @@ -481,3 +524,235 @@ .deduplikator-autorow__confidence-low { color: red; } + +// Mode badges, partial-completed banner, mode filter, scan phase +.deduplikator-autorow { + &__main-record-title { + display: flex; + align-items: center; + gap: 0.6em; + flex-wrap: wrap; + } + + &__badge { + display: inline-flex; + align-items: center; + gap: 0.35em; + padding: 4px 10px 4px 9px; + border-radius: 999px; + font-size: 0.7em; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #fff; + line-height: 1; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + vertical-align: middle; + + .fi-link, + .fi-magnifying-glass { + font-size: 1em; + line-height: 1; + } + + &--pbn { + background: linear-gradient(180deg, #42a5f5 0%, #1976d2 100%); + border: 1px solid #1565c0; + } + + &--general { + background: linear-gradient(180deg, #ffb74d 0%, #f57c00 100%); + border: 1px solid #ef6c00; + } + } + + &__partial-banner { + margin: 1em 0; + } + + // Top bar — przyciski trybu po lewej, wyszukiwarka po prawej. + &__top-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1em; + margin: 1em 0; + flex-wrap: nowrap; + } + + &__top-search { + flex: 0 1 auto; + min-width: 200px; + margin: 0; + } + + &__top-search-group { + margin: 0; + } + + &__top-search-info { + display: block; + margin-top: 0.4em; + color: #555; + } + + &__confidence-filter { + flex: 0 0 auto; + margin: 0; + display: flex; + align-items: center; + } + + &__confidence-buttons { + margin: 0; + + .button { + margin: 0; + display: inline-flex; + align-items: center; + gap: 0.4em; + } + } + + // Mode filter (Pokaż wyniki: PBN/Ogólny/Oba) — Foundation button-group based + &__mode-filter { + flex: 0 0 auto; + margin: 0; + display: flex; + align-items: center; + gap: 0.75em; + flex-wrap: nowrap; + } + + &__mode-filter-label { + font-weight: 600; + color: #4a4a4a; + } + + &__mode-buttons { + margin: 0; + + .button { + margin: 0; + display: inline-flex; + align-items: center; + gap: 0.4em; + } + } + + &__mode-count { + display: inline-block; + margin-left: 0.3em; + padding: 1px 7px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.18); + color: inherit; + font-size: 0.8em; + font-weight: 700; + line-height: 1.4; + + .hollow & { + background: rgba(0, 0, 0, 0.08); + } + } + + &__scan-phase { + margin-top: 0.5em; + font-style: italic; + } + + // Action group sections within each duplicate card (Podgląd / Decyzja / Scalanie) + &__actions { + display: flex; + flex-direction: column; + gap: 14px; + } + + &__action-group { + background: rgba(0, 0, 0, 0.03); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 6px; + padding: 10px 12px; + } + + &__action-group-title { + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #555; + margin: 0 0 8px 0; + display: flex; + align-items: center; + gap: 0.4em; + + .fi-eye, + .fi-checkbox, + .fi-arrows-compress { + color: #888; + } + } + + // Opisy bibliograficzne renderują / wokół tytułów — to jest OK, + // tytuł ma być boldem. Problem: Foundation daje .callout a:not(.close-button) + // { font-weight: bolder }, więc cały tekst w wewnątrz .callout jest + // bold. Resetujemy font-weight na w obrębie itemów publikacji z + // wyższą specyficznością niż .callout a:not(.close-button), żeby wygrać + // kaskadę. Zostawiamy / z ich domyślnym bold, żeby tytuł nadal + // był wytłuszczony. + .callout &__publication-item a, + .callout &__publication-item--duplicate a { + font-weight: normal; + } + + // Reason chips — small pills with icon + text + &__reasons-chips { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 0; + margin: 0; + } + + &__reason-chip { + display: inline-flex; + align-items: center; + gap: 0.35em; + padding: 3px 9px; + border-radius: 999px; + font-size: 0.78rem; + line-height: 1.5; + border: 1px solid transparent; + white-space: nowrap; + max-width: 100%; + + .deduplikator-autorow__reason-chip-text { + overflow: hidden; + text-overflow: ellipsis; + } + + &--match { + background: #e6f4ea; + border-color: #b6dec0; + color: #1b5e20; + } + + &--info { + background: #e7f0fc; + border-color: #b9d4f0; + color: #0d3a73; + } + + &--weak { + background: #f0f0f0; + border-color: #d8d8d8; + color: #555; + } + + &--warn { + background: #fff4e0; + border-color: #f5d49a; + color: #8a4b00; + } + } +} diff --git a/src/deduplikator_autorow/tasks.py b/src/deduplikator_autorow/tasks.py index 101027480..3027491d5 100644 --- a/src/deduplikator_autorow/tasks.py +++ b/src/deduplikator_autorow/tasks.py @@ -56,6 +56,33 @@ def _get_user_by_id(user_id): return None +def _calculate_priority_from_meta(meta_entry: dict) -> int: + """Computes priority from meta dict (no SQL). + + Mirrors :func:`calculate_author_priority` but uses cached fields + from the meta dict produced by ``build_autor_meta``. Avoids + per-candidate SQL on the hot path of ``_run_general_phase``. + + Priority values: + 100 - has 2022-2025 publications WITH disciplines + 50 - has 2022-2025 publications (any) + 0 - no recent publications + + TODO: ``calculate_author_priority`` checks disciplines specifically + in 2022-2025 (``Autor_Dyscyplina.objects.filter(rok__gte=2022, + rok__lte=2025)``). The meta-cache only stores ``ma_dyscypline`` + (any year), so this is an approximation. Acceptable for v1 since + priority is a sort hint, not a correctness invariant. To achieve + exact parity, store year-filtered discipline data in meta. + """ + recent_lata = {rok for rok in meta_entry["lata_publikacji"] if 2022 <= rok <= 2025} + if not recent_lata: + return 0 + if meta_entry["ma_dyscypline"]: + return 100 + return 50 + + def calculate_author_priority(autor): """ Calculate priority based on publication dates and disciplines. @@ -217,124 +244,266 @@ def _process_author_duplicates(osoba_z_instytucji, scan_run, min_confidence): return candidates -@shared_task(bind=True, name="deduplikator_autorow.scan_for_duplicates") -def scan_for_duplicates(self, user_id=None, min_confidence=MIN_CONFIDENCE_TO_STORE): - """ - Background task to scan all authors for potential duplicates. - - This task: - 1. Creates a DuplicateScanRun record - 2. Deletes all existing DuplicateCandidate records (replace mode) - 3. Iterates through all OsobaZInstytucji - 4. For each, calls szukaj_kopii() to find candidates - 5. For each candidate, calls analiza_duplikatow() and stores in DuplicateCandidate - 6. Updates progress periodically - 7. Marks run as completed - - Args: - user_id: Optional ID of the user who triggered the scan - min_confidence: Minimum confidence score to store a candidate (default: 50) +def _run_general_phase(scan_run, min_confidence=MIN_CONFIDENCE_TO_STORE): + """Faza 2 skanu — duplikaty general (no SQL on hot path). - Returns: - dict: Result with status, scan_run_id, and statistics + Algorytm: + 1. build_autor_meta + build_buckets — pre-load wszystkich autorów. + 2. Read IgnoredAuthor / NotADuplicate exclusions. + 3. generate_pairs — pary score >= min_confidence. + 4. find_clusters — connected components. + 5. Cluster-skip jeśli ktokolwiek w klastrze ma OsobaZInstytucji. + 6. Pick main przez hierarchię B; emit pary (main, dup) jako + DuplicateCandidate(scan_mode='general'). + 7. Sprawdza scan_run.status == CANCELLED między batchami. """ - from pbn_api.models import OsobaZInstytucji - - from .models import DuplicateCandidate, DuplicateScanRun, IgnoredAuthor - - logger.info("Starting duplicate scan task...") - - user = _get_user_by_id(user_id) + from .models import ( + DuplicateCandidate, + DuplicateScanRun, + IgnoredAuthor, + NotADuplicate, + ) + from .utils.analysis_meta import analiza_pary_meta + from .utils.cluster import find_clusters + from .utils.main_selection import pick_main_pk + from .utils.meta import build_autor_meta, build_buckets + from .utils.search_general import generate_pairs + + logger.info("General phase: building meta cache...") + meta = build_autor_meta() + buckets = build_buckets(meta) + logger.info("General phase: %d autorów, %d bucketów", len(meta), len(buckets)) + + ignored_pks = set(IgnoredAuthor.objects.values_list("autor_id", flat=True)) + notadup_pks = set(NotADuplicate.objects.values_list("autor_id", flat=True)) + + pairs_data: dict[tuple[int, int], tuple[int, list[str]]] = {} + for pk_a, pk_b, score, reasons in generate_pairs( + buckets, meta, ignored_pks, notadup_pks, min_confidence + ): + pairs_data[(pk_a, pk_b)] = (score, reasons) + logger.info("General phase: znaleziono %d par", len(pairs_data)) + + clusters = find_clusters(list(pairs_data.keys())) + logger.info("General phase: %d klastrów wstępnych", len(clusters)) + + skipped_count = 0 + candidates_to_create: list[DuplicateCandidate] = [] + for cluster in clusters: + if any(meta[pk]["ma_osoba_z_instytucji"] for pk in cluster): + skipped_count += 1 + continue + main_pk = pick_main_pk(cluster, meta) + for dup_pk in cluster - {main_pk}: + key = (min(main_pk, dup_pk), max(main_pk, dup_pk)) + if key in pairs_data: + score, reasons = pairs_data[key] + else: + score, reasons = analiza_pary_meta(meta[main_pk], meta[dup_pk]) + main_obj = meta[main_pk]["obj"] + dup_obj = meta[dup_pk]["obj"] + candidates_to_create.append( + DuplicateCandidate( + scan_run=scan_run, + main_autor=main_obj, + duplicate_autor=dup_obj, + confidence_score=score, + confidence_percent=normalize_confidence(score), + reasons=reasons, + priority=_calculate_priority_from_meta(meta[dup_pk]), + main_autor_name=str(main_obj), + duplicate_autor_name=str(dup_obj), + main_publications_count=meta[main_pk]["publikacje_count"], + duplicate_publications_count=meta[dup_pk]["publikacje_count"], + scan_mode="general", + ) + ) + if len(candidates_to_create) >= 1000: + with transaction.atomic(): + DuplicateCandidate.objects.bulk_create( + candidates_to_create, ignore_conflicts=True + ) + candidates_to_create = [] + scan_run.refresh_from_db() + if scan_run.status == DuplicateScanRun.Status.CANCELLED: + logger.info("General phase cancelled mid-batch") + return + + if candidates_to_create: + with transaction.atomic(): + DuplicateCandidate.objects.bulk_create( + candidates_to_create, ignore_conflicts=True + ) - scan_run = DuplicateScanRun.objects.create( - status=DuplicateScanRun.Status.RUNNING, - created_by=user, - celery_task_id=self.request.id or "", + logger.info( + "General phase: %d klastrów pominiętych (z OsobaZInstytucji)", + skipped_count, ) - try: - deleted_count = DuplicateCandidate.objects.all().delete()[0] - logger.info(f"Deleted {deleted_count} existing candidates") - ignored_scientist_ids = set( - IgnoredAuthor.objects.values_list("scientist_id", flat=True) - ) +def _run_pbn_phase(scan_run, min_confidence=MIN_CONFIDENCE_TO_STORE): + """Faza 1 skanu — duplikaty PBN (OsobaZInstytucji). - osoby_query = OsobaZInstytucji.objects.select_related("personId").all() - if ignored_scientist_ids: - osoby_query = osoby_query.exclude(personId__pk__in=ignored_scientist_ids) + Iteruje przez wszystkie OsobaZInstytucji (z wyjątkiem IgnoredScientist), + dla każdej szuka kopii (`szukaj_kopii`), analizuje (`analiza_duplikatow`) + i tworzy DuplicateCandidate. Polluje `scan_run.status` między autorami — + jeśli zewnętrzny `cancel_scan` ustawił CANCELLED, kończy wcześnie + (status pozostaje CANCELLED — caller decyduje o finalizacji). - total_count = osoby_query.count() - scan_run.total_authors_to_scan = total_count - scan_run.save(update_fields=["total_authors_to_scan"]) + Aktualizuje pola `total_authors_to_scan`, `authors_scanned` i + `duplicates_found` na `scan_run` w trakcie pracy. + """ + from pbn_api.models import OsobaZInstytucji - logger.info(f"Scanning {total_count} authors for duplicates...") + from .models import DuplicateCandidate, DuplicateScanRun, IgnoredScientist - authors_scanned = 0 - duplicates_found = 0 - candidates_to_create = [] + ignored_scientist_ids = set( + IgnoredScientist.objects.values_list("scientist_id", flat=True) + ) - for osoba_z_instytucji in osoby_query.iterator(): - scan_run.refresh_from_db() - if scan_run.status == DuplicateScanRun.Status.CANCELLED: - logger.info("Scan cancelled by user") - return { - "status": "cancelled", - "scan_run_id": scan_run.pk, - "authors_scanned": authors_scanned, - "duplicates_found": duplicates_found, - } + osoby_query = OsobaZInstytucji.objects.select_related("personId").all() + if ignored_scientist_ids: + osoby_query = osoby_query.exclude(personId__pk__in=ignored_scientist_ids) - authors_scanned += 1 + total_count = osoby_query.count() + scan_run.total_authors_to_scan = total_count + scan_run.save(update_fields=["total_authors_to_scan"]) - new_candidates = _process_author_duplicates( - osoba_z_instytucji, scan_run, min_confidence - ) - candidates_to_create.extend(new_candidates) - duplicates_found += len(new_candidates) + logger.info(f"PBN phase: scanning {total_count} authors...") - if len(candidates_to_create) >= 1000: + authors_scanned = 0 + duplicates_found = 0 + candidates_to_create = [] + + for osoba_z_instytucji in osoby_query.iterator(): + scan_run.refresh_from_db() + if scan_run.status == DuplicateScanRun.Status.CANCELLED: + logger.info("PBN phase cancelled by user") + if candidates_to_create: with transaction.atomic(): DuplicateCandidate.objects.bulk_create( candidates_to_create, ignore_conflicts=True ) - candidates_to_create = [] + scan_run.authors_scanned = authors_scanned + scan_run.duplicates_found = duplicates_found + scan_run.save(update_fields=["authors_scanned", "duplicates_found"]) + return - if authors_scanned % PROGRESS_UPDATE_INTERVAL == 0: - scan_run.authors_scanned = authors_scanned - scan_run.duplicates_found = duplicates_found - scan_run.save(update_fields=["authors_scanned", "duplicates_found"]) - logger.info( - f"Progress: {authors_scanned}/{total_count} authors, " - f"{duplicates_found} duplicates found" - ) + authors_scanned += 1 + + new_candidates = _process_author_duplicates( + osoba_z_instytucji, scan_run, min_confidence + ) + candidates_to_create.extend(new_candidates) + duplicates_found += len(new_candidates) - if candidates_to_create: + if len(candidates_to_create) >= 1000: with transaction.atomic(): DuplicateCandidate.objects.bulk_create( candidates_to_create, ignore_conflicts=True ) + candidates_to_create = [] + + if authors_scanned % PROGRESS_UPDATE_INTERVAL == 0: + scan_run.authors_scanned = authors_scanned + scan_run.duplicates_found = duplicates_found + scan_run.save(update_fields=["authors_scanned", "duplicates_found"]) + logger.info( + f"PBN progress: {authors_scanned}/{total_count} authors, " + f"{duplicates_found} duplicates found" + ) + + if candidates_to_create: + with transaction.atomic(): + DuplicateCandidate.objects.bulk_create( + candidates_to_create, ignore_conflicts=True + ) + + scan_run.authors_scanned = authors_scanned + scan_run.duplicates_found = duplicates_found + scan_run.save(update_fields=["authors_scanned", "duplicates_found"]) + + logger.info( + f"PBN phase done: {authors_scanned} authors scanned, " + f"{duplicates_found} duplicates found" + ) + + +@shared_task(bind=True, name="deduplikator_autorow.scan_for_duplicates") +def scan_for_duplicates(self, user_id=None, min_confidence=MIN_CONFIDENCE_TO_STORE): + """Combined task: faza PBN + faza general w jednym przebiegu. + + Statusy końcowe: + - COMPLETED: obie fazy ukończone. + - PARTIAL_COMPLETED: faza PBN OK, faza general anulowana → wyniki PBN + dostępne. + - CANCELLED: faza PBN anulowana → brak wyników. + - FAILED: nieobsłużony wyjątek. + """ + from .models import DuplicateCandidate, DuplicateScanRun + + logger.info("Starting duplicate scan task (combined PBN + general)...") + + user = _get_user_by_id(user_id) + scan_run = DuplicateScanRun.objects.create( + status=DuplicateScanRun.Status.RUNNING, + created_by=user, + celery_task_id=self.request.id or "", + ) + + try: + # Replace mode: clear all previous candidates + deleted_count = DuplicateCandidate.objects.all().delete()[0] + logger.info(f"Deleted {deleted_count} existing candidates") + + # FAZA 1: PBN + scan_run.phase = "pbn" + scan_run.save(update_fields=["phase"]) + _run_pbn_phase(scan_run, min_confidence) + scan_run.refresh_from_db() + if scan_run.status == DuplicateScanRun.Status.CANCELLED: + scan_run.finished_at = timezone.now() + scan_run.save(update_fields=["finished_at"]) + logger.info("Scan cancelled in PBN phase") + return { + "status": "cancelled", + "scan_run_id": scan_run.pk, + } + + # FAZA 2: general + scan_run.phase = "general" + scan_run.save(update_fields=["phase"]) + _run_general_phase(scan_run, min_confidence) + scan_run.refresh_from_db() + if scan_run.status == DuplicateScanRun.Status.CANCELLED: + scan_run.status = DuplicateScanRun.Status.PARTIAL_COMPLETED + scan_run.finished_at = timezone.now() + scan_run.save(update_fields=["status", "finished_at"]) + logger.info("Scan cancelled in general phase → PARTIAL_COMPLETED") + return { + "status": "partial_completed", + "scan_run_id": scan_run.pk, + } + total_cands = DuplicateCandidate.objects.filter(scan_run=scan_run).count() scan_run.status = DuplicateScanRun.Status.COMPLETED scan_run.finished_at = timezone.now() - scan_run.authors_scanned = authors_scanned - scan_run.duplicates_found = duplicates_found + scan_run.duplicates_found = total_cands scan_run.save() logger.info( - f"Scan completed: {authors_scanned} authors scanned, " - f"{duplicates_found} duplicates found" + f"Scan completed: {scan_run.authors_scanned} authors scanned, " + f"{total_cands} duplicates found" ) return { "status": "success", "scan_run_id": scan_run.pk, - "authors_scanned": authors_scanned, - "duplicates_found": duplicates_found, + "duplicates_found": total_cands, } except Exception as e: - logger.error(f"Error during duplicate scan: {str(e)}", exc_info=True) + logger.exception("Error during duplicate scan") scan_run.status = DuplicateScanRun.Status.FAILED scan_run.finished_at = timezone.now() scan_run.error_message = str(e) diff --git a/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html b/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html index 0f9e904e5..1ac032b10 100644 --- a/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html +++ b/src/deduplikator_autorow/templates/deduplikator_autorow/duplicate_authors.html @@ -1,20 +1,21 @@ {% extends "base.html" %} {% load static %} -{% block extra_css %} +{% block extrahead %} +{{ block.super }} {% endblock %} -{% block title %}Deduplikator Autorów PBN{% endblock %} +{% block title %}Deduplikator autorów{% endblock %} {% block breadcrumbs %}
  • Strona główna
  • -
  • Deduplikator autorów PBN
  • +
  • Deduplikator autorów
  • {% endblock %} {% block content %} -
    +
    @@ -46,6 +47,11 @@

    ETA: obliczanie...

    + {% if running_scan.phase %} +
    + Faza: {{ running_scan.get_phase_display|default:running_scan.phase }} +
    + {% endif %}
    {% csrf_token %} - {% if search_lastname %} - - Wyczyść - -
    - Szukano: "{{ search_lastname }}" - {% if search_results_count is not None %} -
    Znaleziono: {{ search_results_count }} autor{% if search_results_count != 1 %}ów{% endif %} - {% endif %} -
    - {% endif %} -
    -
    - -
  • @@ -264,43 +239,35 @@
  • - +
  • 0 %}aria-expanded="true"{% endif %}> - Obejrz nie-duplikaty + Nie-duplikaty {% if not_duplicate_count > 0 %}({{ not_duplicate_count }}){% else %}{% endif %}
    -

    Przejrzyj autorów oznaczonych jako nie będących duplikatami.

    - - Otwórz listę - -
    -
  • - - - {% if not_duplicate_count > 0 %} -
  • - - Zresetuj nie-duplikaty ({{ not_duplicate_count }}) - -
    -

    Wyczyść wszystkie oznaczenia nie-duplikatów.

    -

    - Obecnie: {{ not_duplicate_count }} oznaczonych -

    -
    - {% csrf_token %} - -
    + {% if not_duplicate_count > 0 %} +

    Autorzy oznaczeni jako nie będący duplikatami.

    +

    + Obecnie: {{ not_duplicate_count }} oznaczonych +

    + + Otwórz listę + +
    + {% csrf_token %} + +
    + {% else %} +

    Brak autorów oznaczonych jako nie-duplikaty.

    + {% endif %}
  • - {% endif %}
  • @@ -313,12 +280,12 @@

    Obecnie: {{ ignored_authors_count }} ignorowanych

    - Zobacz listę -
    + {% csrf_token %} +
  • + {% if q_search %} +
    + + + +
    + {% endif %} +
    + {% if q_search and search_results_count is not None %} + + Wynik dla "{{ q_search }}": + {{ search_results_count }} autor{% if search_results_count != 1 %}ów{% endif %} + + {% endif %} + + + {% endif %} + {% endwith %} + {% include "includes/pbn_freshness_warning.html" with custom_message="Analiza duplikatów opiera się na lokalnej kopii danych autorów z PBN. Wyniki mogą być nieaktualne." %}
    -

    Deduplikator Autorów PBN BETA

    +

    Deduplikator autorów

    {% if not search_lastname and scientist %} @@ -397,7 +455,7 @@

    Deduplikator Autorów PBN Poprzedni autor {% endif %} -
    + {% csrf_token %} @@ -419,6 +477,17 @@

    Deduplikator Autorów PBN Następny autor

    + {% elif search_lastname and glowny_autor %} +
    + {% if search_has_prev %} + Poprzedni + {% endif %} + {% if search_has_next %} + Następny + {% endif %} +
    {% endif %}
    @@ -452,9 +521,23 @@

    Skanowanie w toku...

    ETA: obliczanie...

    + {% if running_scan.phase %} +
    + Faza: {{ running_scan.get_phase_display|default:running_scan.phase }} +
    + {% endif %}

    Strona odświeży się automatycznie po zakończeniu skanowania.

    {% elif not glowny_autor %} + {% if search_lastname %} +
    +

    Nie znaleziono takich autorów

    +

    Nie znaleziono kandydatów dla nazwiska „{{ search_lastname }}".

    + + Wyczyść wyszukiwanie + +
    + {% else %}

    Gratulacje!

    Wszystkie duplikaty zostały już przetworzone.

    @@ -467,10 +550,26 @@

    Gratulacje!

    + {% endif %} {% else %} {% if glowny_autor %}
    -

    Główny rekord autora

    +

    + Główny rekord autora + {% if first_candidate %} + {% if first_candidate.scan_mode == "pbn" %} + + PBN + + {% else %} + + ogólny + + {% endif %} + {% endif %} +

    Imię i nazwisko:
    @@ -552,62 +651,116 @@

    Dyscypliny głównego autora (2022-2025):

    {% if glowne_publikacje %} -

    Publikacje głównego autora:{% if glowne_publikacje_year_range %} +

    {% if glowne_publikacje_count == 1 %}Publikacja{% else %}Publikacje{% endif %} głównego autora:{% if glowne_publikacje_year_range %} ({{ glowne_publikacje_year_range }}){% endif %}

    {% for publikacja in glowne_publikacje %}
    - + - {{ publikacja.tytul_oryginalny }} + {{ publikacja.opis_bibliograficzny_cache|truncatewords_html:30|safe }} - {% if publikacja.rok %} - ({{ publikacja.rok }}){% endif %} -
    - {{ publikacja.opis_bibliograficzny_cache|truncatewords:20 }} +
    {% endfor %}
    {% endif %}
    -
    -
    -

    Możliwe duplikaty ({{ duplikaty_z_publikacjami|length }})

    -
    - {% if duplikaty_z_publikacjami|length > 0 %} -
    -
    - - - Scalanie... 0/0 duplikatów - +
    + {# Wiersz 1: nagłówek + wskaźnik postępu + przyciski scalania. #} +
    +
    +

    + Możliwe duplikaty + {% if confidence_band != 'all' and duplikaty_z_publikacjami|length != candidates_total_for_main %} + ({{ duplikaty_z_publikacjami|length }} z {{ candidates_total_for_main }}) + {% else %} + ({{ duplikaty_z_publikacjami|length }}) + {% endif %} +

    +
    + {% if duplikaty_z_publikacjami|length > 0 %} + {# Wskaźnik postępu - ukryty domyślnie, JS pokazuje go w trakcie scalania. #} + +
    +
    + + +
    +
    + {% endif %}
    -
    -
    - - + + {# Wiersz 2: filtr pewności per-autor — Wszyscy / Pewniaki / Słabe. #} + {# Liczniki dotyczą TYLKO kandydatów aktualnego głównego #} + {# autora; nie skanuje całej bazy. #} + {# Ukrywamy cały rząd gdy obie kategorie mają kandydatów #} + {# — ale gdy tylko jedna kategoria > 0, nie ma czego filtrować. #} + {% if candidates_high_for_main > 0 and candidates_low_for_main > 0 %} + {% endif %}
    {% for duplikat_data in duplikaty_z_publikacjami %} -
    +
    @@ -654,105 +807,154 @@

    Możliwe duplikaty ({{ duplik
    Powody podobieństwa:
    -
      +
      {% for powod in duplikat_data.analiza.powody_podobienstwa %} -
    • {{ powod }}
    • + + + {{ powod.text }} + {% empty %} -
    • Brak szczegółowych powodów
    • + + + Brak szczegółowych powodów + {% endfor %} -
    +

    -
    -
    - - - - - - Pokaż wyd. ciągłe - - - Pokaż wyd. zwarte - - {% if duplikat_data.candidate_id %} -
    - {% csrf_token %} - - -
    - {% else %} -
    - {% csrf_token %} - - -
    - {% endif %} - {% if duplikat_data.publikacje_count == 0 %} + {% endif %} + {% if duplikat_data.publikacje_count == 0 %} - {% endif %} + {% endif %} +
    +
    + + {# 3. SCALANIE — co zrobić, jeśli to duplikat #} +
    +
    + Scalanie +
    +
    + {% if glowny_autor_dyscypliny %} + + + {% endif %} + + +
    @@ -802,23 +1004,19 @@
    Dyscypliny duplikatu (2022-2025)
    {% if duplikat_data.publikacje %} -
    Publikacje duplikatu: +
    {% if duplikat_data.publikacje_count == 1 %}Publikacja{% else %}Publikacje{% endif %} duplikatu: {% if duplikat_data.publikacje_year_range %} ({{ duplikat_data.publikacje_year_range }}){% endif %}
    {% for publikacja in duplikat_data.publikacje %}
    - + - {{ publikacja.tytul_oryginalny }} + {{ publikacja.opis_bibliograficzny_cache|truncatewords_html:25|safe }} - {% if publikacja.rok %} - ({{ publikacja.rok }}){% endif %} -
    - - {{ publikacja.opis_bibliograficzny_cache|truncatewords:15 }} +
    {% endfor %}
    @@ -921,8 +1119,25 @@
    Publikacje duplikatu: document.addEventListener('DOMContentLoaded', function() { adjustSidebarPosition(); relocateMessages(); // Move messages to respect sidebar layout + scrollToHashTarget(); // Po kliknięciu filtra pewności scroll-do-mozliwe-duplikaty }); + // Po nawigacji z fragmentem URL (np. ?confidence=high#mozliwe-duplikaty) + // przeglądarka domyślnie skoczy do elementu, ale sticky header BPP go + // schowa. Używamy oficjalnego helpera bpp.scrollToVisible, jak wymagane + // w CLAUDE.md. + function scrollToHashTarget() { + var hash = window.location.hash; + if (!hash || hash.length < 2) return; + var el = document.getElementById(hash.substring(1)); + if (!el) return; + if (window.bpp && typeof window.bpp.scrollToVisible === 'function') { + window.bpp.scrollToVisible(el); + } else { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + // Adjust on window resize and scroll window.addEventListener('resize', adjustSidebarPosition); window.addEventListener('scroll', adjustSidebarPosition); @@ -951,10 +1166,10 @@
    Publikacje duplikatu: button.querySelector('.spinner').style.display = 'inline'; } - // Build request data + // Build request data — use Autor PKs directly (template provides Autor PKs). var requestData = { - 'main_scientist_id': mainAuthorId, - 'duplicate_scientist_id': duplicateAuthorId + 'main_autor_id': mainAuthorId, + 'duplicate_autor_id': duplicateAuthorId }; if (candidateId) { requestData['candidate_id'] = candidateId; @@ -1139,6 +1354,196 @@
    Publikacje duplikatu: e.preventDefault(); }); + // Autocomplete dla pola "Szukaj autora po nazwisku" - debounce 200ms, + // używa endpointu lastname_suggestions zwracającego top-10 nazwisk + // z pending DuplicateCandidate. Przy < 2 znakach lista jest czyszczona. + (function setupLastnameAutocomplete() { + var input = document.getElementById('search_lastname'); + var datalist = document.getElementById('lastname-suggestions'); + if (!input || !datalist) return; + + var debounceTimer = null; + var lastQuery = ''; + + function setOptions(names) { + while (datalist.firstChild) datalist.removeChild(datalist.firstChild); + names.forEach(function(name) { + var opt = document.createElement('option'); + opt.value = name; + datalist.appendChild(opt); + }); + } + + input.addEventListener('input', function() { + var q = input.value.trim(); + if (q.length < 2) { + setOptions([]); + lastQuery = ''; + return; + } + if (q === lastQuery) return; + lastQuery = q; + + clearTimeout(debounceTimer); + debounceTimer = setTimeout(function() { + fetch('{% url "deduplikator_autorow:lastname_suggestions" %}?q=' + encodeURIComponent(q), { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data && Array.isArray(data.results)) { + setOptions(data.results); + } + }) + .catch(function(err) { + console.error('Autocomplete failed:', err); + }); + }, 200); + }); + })(); + + // Po usunięciu/scaleniu karty duplikatu — przelicz, czy "Scal wszystkie" + // mogą wrócić do stanu aktywnego (wszyscy pozostali kandydaci ≥ 50%). + // Działa przeciwnie też: jeśli ktoś usunie ostatni "dobry" wpis i zostały + // tylko niskoprocentowe, przyciski wracają w stan disabled. + var MIN_PEWNOSC_THRESHOLD = {{ MIN_PEWNOSC_DO_WYSWIETLENIA|default:50 }}; + function refreshMergeAllAvailability() { + var cards = document.querySelectorAll('[id^="duplicate-card-"]'); + if (cards.length === 0) return; + + var minPewnosc = 100; + var lowConfidence = []; + cards.forEach(function(card) { + var p = parseInt(card.dataset.pewnosc, 10); + if (isNaN(p)) return; + if (p < minPewnosc) minPewnosc = p; + if (p < MIN_PEWNOSC_THRESHOLD) { + lowConfidence.push((card.dataset.authorName || 'autor') + ' (' + p + '%)'); + } + }); + + var allowMergeAll = lowConfidence.length === 0; + var buttons = document.querySelectorAll('[data-merge-all]'); + var group = document.querySelector('[data-low-confidence-names]'); + + buttons.forEach(function(btn) { + if (allowMergeAll) { + btn.classList.remove('deduplikator-autorow__merge-all-btn--disabled'); + btn.removeAttribute('aria-disabled'); + } else { + btn.classList.add('deduplikator-autorow__merge-all-btn--disabled'); + btn.setAttribute('aria-disabled', 'true'); + } + }); + + if (group) { + if (allowMergeAll) { + group.removeAttribute('data-low-confidence-names'); + } else { + group.setAttribute('data-low-confidence-names', lowConfidence.join('||')); + } + } else if (!allowMergeAll && buttons.length > 0) { + // Brak wrappera (przyciski były od początku aktywne) — dorzuć dataset + // do najbliższego button-group, żeby alert mógł odczytać nazwiska. + var bg = buttons[0].closest('.button-group'); + if (bg) bg.setAttribute('data-low-confidence-names', lowConfidence.join('||')); + } + } + + // Event delegation for "Nie są duplikatami" - AJAX with fadeOut + document.addEventListener('click', function(e) { + var target = e.target.closest('[data-mark-not-duplicate]'); + if (!target) return; + e.preventDefault(); + + var kind = target.dataset.markNotDuplicate; + var duplicateAuthorId = target.dataset.duplicateAuthor; + var card = document.getElementById('duplicate-card-' + duplicateAuthorId); + + var url, payload; + if (kind === 'candidate') { + url = '{% url "deduplikator_autorow:mark_candidate_not_duplicate" %}'; + payload = { candidate_id: target.dataset.candidateId }; + } else { + url = '{% url "deduplikator_autorow:mark_non_duplicate" %}'; + payload = { scientist_pk: target.dataset.scientistPk }; + } + + target.disabled = true; + + $.ajax({ + url: url, + type: 'POST', + dataType: 'json', + headers: { + 'X-CSRFToken': '{{ csrf_token }}', + 'X-Requested-With': 'XMLHttpRequest' + }, + data: payload, + success: function(response) { + if (response && response.success) { + if (bppNotifications && bppNotifications.addMessage && response.message) { + bppNotifications.addMessage({ + cssClass: 'success', + text: response.message, + sound: false + }); + } + {# Aktualizuj licznik nie-duplikatów w sidebarze #} + var ndBadge = document.getElementById('not-duplicate-count'); + var ndText = document.getElementById('not-duplicate-count-text'); + if (ndBadge) { + var ndCount = parseInt(ndBadge.dataset.count || '0') + 1; + ndBadge.dataset.count = ndCount; + ndBadge.textContent = ndCount; + } + if (ndText) { + ndText.textContent = (parseInt(ndText.textContent) || 0) + 1; + } + if (card) { + $(card).fadeOut(400, function() { + $(this).remove(); + var countElement = document.querySelector('.deduplikator-autorow__duplicates-title'); + if (countElement) { + var match = countElement.textContent.match(/\((\d+)\)/); + if (match) { + var newCount = parseInt(match[1]) - 1; + countElement.textContent = 'Możliwe duplikaty (' + newCount + ')'; + if (newCount === 0) { + setTimeout(function() { window.location.reload(); }, 500); + return; + } + } + } + refreshMergeAllAvailability(); + }); + } + } else { + target.disabled = false; + var msg = (response && response.message) || 'Operacja nie powiodła się.'; + if (bppNotifications && bppNotifications.addMessage) { + bppNotifications.addMessage({ cssClass: 'alert', text: msg, sound: true }); + } else { + alert(msg); + } + } + }, + error: function(xhr) { + target.disabled = false; + var msg = 'Błąd serwera podczas oznaczania jako nie-duplikat.'; + try { + var resp = xhr.responseJSON || JSON.parse(xhr.responseText); + if (resp && resp.message) msg = resp.message; + } catch (err) { /* ignore parse */ } + if (bppNotifications && bppNotifications.addMessage) { + bppNotifications.addMessage({ cssClass: 'alert', text: msg, sound: true }); + } else { + alert(msg); + } + } + }); + }); + // Event delegation for delete author (data-delete-author) document.addEventListener('click', function(e) { var target = e.target.closest('[data-delete-author]'); @@ -1174,10 +1579,34 @@
    Publikacje duplikatu: document.addEventListener('click', function(e) { var target = e.target.closest('[data-merge-all]'); if (!target) return; + e.preventDefault(); + + // Wyszarzony przycisk (są kandydaci z pewnością < 50%) -> komunikat, + // zamiast uruchamiać scalanie zbiorcze. + if (target.getAttribute('aria-disabled') === 'true') { + var group = target.closest('[data-low-confidence-names]'); + var namesAttr = group ? group.dataset.lowConfidenceNames : ''; + var names = namesAttr ? namesAttr.split('||') : []; + var listHtml = names.length + ? '\n\nAutorzy poniżej progu 50%:\n • ' + names.join('\n • ') + : ''; + alert( + 'Scalanie zbiorcze jest wyłączone dla tego głównego autora, ' + + 'ponieważ co najmniej jeden potencjalny duplikat ma pewność ' + + 'poniżej 50%.' + listHtml + '\n\n' + + 'Co możesz zrobić, żeby przyciski wróciły:\n' + + ' • Dodaj niepewnych autorów do "ignorowanych" (przycisk ' + + '"Ignoruj autora" w nagłówku),\n' + + ' • lub scal/odrzuć ich ręcznie (przyciski w sekcji ' + + '"Decyzja" / "Scalanie" przy każdej karcie).\n\n' + + 'Scalanie zbiorcze jest dostępne tylko, gdy WSZYSCY kandydaci ' + + 'mają pewność ≥ 50%.' + ); + return; + } var skipPbn = target.dataset.skipPbn === 'true'; handleMergeAll(skipPbn); - e.preventDefault(); }); function handleMergeAll(skipPbn) { @@ -1212,6 +1641,11 @@
    Publikacje duplikatu: } // Show progress, disable buttons + var progressCell = document.getElementById('merge-all-progress-cell'); + if (progressCell) { + progressCell.removeAttribute('hidden'); + progressCell.style.display = ''; + } document.getElementById('merge-all-progress').style.display = 'block'; document.getElementById('merge-all-btn').disabled = true; document.getElementById('merge-all-no-pbn-btn').disabled = true; @@ -1247,8 +1681,8 @@
    Publikacje duplikatu: type: 'GET', dataType: 'json', data: { - 'main_scientist_id': mainAuthorId, - 'duplicate_scientist_id': duplicateId, + 'main_autor_id': mainAuthorId, + 'duplicate_autor_id': duplicateId, 'skip_pbn': skipPbn }, complete: function(xhr, status) { @@ -1277,6 +1711,11 @@
    Publikacje duplikatu: function handleMergeAllError(response, duplicateId) { // Hide progress + var progressCell = document.getElementById('merge-all-progress-cell'); + if (progressCell) { + progressCell.setAttribute('hidden', ''); + progressCell.style.display = 'none'; + } document.getElementById('merge-all-progress').style.display = 'none'; // Re-enable main buttons diff --git a/src/deduplikator_autorow/tests/test_analysis_meta.py b/src/deduplikator_autorow/tests/test_analysis_meta.py new file mode 100644 index 000000000..e4273250c --- /dev/null +++ b/src/deduplikator_autorow/tests/test_analysis_meta.py @@ -0,0 +1,241 @@ +"""Testy analiza_pary_meta — scoring par autorów na bazie meta.""" + +from deduplikator_autorow.utils.analysis_meta import analiza_pary_meta + + +def _meta( + nazwisko="kowalski", + imiona=("jan",), + orcid=None, + pbn_uid=False, + tytul=False, + pubs=0, + max_rok=0, + lata=None, +): + return { + "nazwisko_norm": nazwisko, + "nazwisko_parts": nazwisko.split("-"), + "imiona_norm": list(imiona), + "orcid_value": orcid, + "ma_orcid": bool(orcid), + "ma_pbn_uid": pbn_uid, + "ma_tytul": tytul, + "tytul_id": 1 if tytul else None, + "publikacje_count": pubs, + "max_rok": max_rok, + "lata_publikacji": set(lata or []), + } + + +def test_identyczne_orcid_dodaje_50(): + a = _meta(orcid="0000-0001-2345-6789") + b = _meta(orcid="0000-0001-2345-6789") + score, reasons = analiza_pary_meta(a, b) + assert score >= 50 + assert any("ORCID" in r for r in reasons) + + +def test_rozne_orcid_odejmuje_50(): + # Identyczne imiona (żeby hard-rejection nie zadziałał i ORCID-mismatch + # mógł być widoczny jako dominujący sygnał). + a = _meta( + nazwisko="kowalski", + imiona=("jan",), + orcid="0000-0001-1111-1111", + ) + b = _meta( + nazwisko="nowak", + imiona=("jan",), + orcid="0000-0002-2222-2222", + ) + score, reasons = analiza_pary_meta(a, b) + # +30 wspólne imię, -50 różny ORCID, +10 mało publikacji = -10 raw, + # ale plus inne drobne. Sprawdzamy że ORCID-mismatch wypłynął jako + # negatywny element (nie samo +30 dominuje). + assert any("różny ORCID" in r for r in reasons) + # Sumarycznie score powinien być wyraźnie obniżony przez ORCID + assert score < 30 + + +def test_identyczne_nazwisko_dodaje_40(): + a = _meta(nazwisko="kowalski") + b = _meta(nazwisko="kowalski") + score, reasons = analiza_pary_meta(a, b) + assert score >= 40 + assert any("nazwisko" in r.lower() for r in reasons) + + +def test_wspolne_lata_publikacji_dodaje_20(): + a = _meta(lata=[2020, 2021, 2022]) + b = _meta(lata=[2021, 2022]) + score, reasons = analiza_pary_meta(a, b) + assert any("wspólne lata" in r.lower() for r in reasons) + + +def test_score_to_int(): + a = _meta() + b = _meta() + score, _ = analiza_pary_meta(a, b) + assert isinstance(score, int) + + +def test_swap_imienia_z_nazwiskiem_dodaje_50(): + """Pełna zamiana imię ↔ nazwisko: A 'kowalski jan', B 'jan kowalski'.""" + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="jan", imiona=("kowalski",)) + score, reasons = analiza_pary_meta(a, b) + assert any("zamian" in r.lower() for r in reasons) + + +# --- Penalty za różne imiona (rozłączne, brak overlap-u w żadnym wymiarze) ---- + + +def test_rozne_imiona_bez_zadnego_overlap_odejmuje_punkty(): + """'Jan' vs 'Stefan': brak common, brak similar (3-prefix), brak inicjału. + + Realny case użytkownika: 'Jan Kowalski' vs 'Stefan + Kowalski-Nowak' — system dawał ~49% mimo zupełnie innych imion. + Penalty ma zniwelować inne przesłanki tak, żeby raw score spadał poniżej + progu MIN_CONFIDENCE_TO_STORE. + """ + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="kowalski-nowak", imiona=("stefan",)) + score, reasons = analiza_pary_meta(a, b) + assert any("różne imiona" in r.lower() for r in reasons), ( + f"Brak powodu 'różne imiona' w {reasons}" + ) + # Bez penalty: +30 (zawieranie nazwiska) + drobne plusy ≈ 30-50 raw. + # Z penalty -40: powinno spaść poniżej progu 50 wymaganego do zapisu. + assert score < 50, ( + f"Score {score} >= 50 mimo zupełnie różnych imion (powody: {reasons})" + ) + + +def test_jedno_wspolne_imie_nie_powoduje_penalty(): + """'Jan Maria' vs 'Maria Kasia' mają wspólne 'maria' — bez penalty.""" + a = _meta(imiona=("jan", "maria")) + b = _meta(imiona=("maria", "kasia")) + _, reasons = analiza_pary_meta(a, b) + assert not any("różne imiona" in r.lower() for r in reasons) + + +def test_podobne_imie_nie_powoduje_penalty(): + """'Jan' vs 'Janusz' — startsWith(3) wystarcza, brak penalty.""" + a = _meta(imiona=("jan",)) + b = _meta(imiona=("janusz",)) + _, reasons = analiza_pary_meta(a, b) + assert not any("różne imiona" in r.lower() for r in reasons) + + +def test_pasujacy_inicjal_nie_powoduje_penalty(): + """Wspólny inicjał (J vs J) traktowany jako sygnał - bez penalty.""" + # _common_initials w meta bierze pierwszy znak imienia. "jan" i "jakub" + # mają wspólny inicjał "j" — i jednocześnie startsWith(3) NIE pasuje + # ('jan' vs 'jakub' — różne 3 prefiksy 'jan' vs 'jak'). Penalty nie powinien + # pojawić się tylko z powodu wspólnego inicjału. + a = _meta(imiona=("jan",)) + b = _meta(imiona=("jakub",)) + _, reasons = analiza_pary_meta(a, b) + assert not any("różne imiona" in r.lower() for r in reasons) + + +def test_brak_imion_po_jednej_stronie_nie_aktywuje_penalty(): + """Hard-rejection wymaga, by OBIE strony miały imiona — w przeciwnym razie + nie ma o czym mówić, że są 'różne'.""" + a = _meta(imiona=("jan",)) + b = _meta(imiona=()) + _, reasons = analiza_pary_meta(a, b) + assert not any("różne imiona" in r.lower() for r in reasons) + + +# --- Hard rejection: rozłączne imiona = NIE jest duplikatem (regardless of all) ---- + + +def test_zupelnie_rozne_imiona_jest_hard_rejected(): + """Jan vs Agnieszka — różne imiona, brak swap → score mocno ujemny, + żeby pair na pewno NIE przeszedł progu zapisu.""" + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="kowalski", imiona=("agnieszka",)) + score, reasons = analiza_pary_meta(a, b) + assert score < 0, f"Score {score} - pair powinna być twardo odrzucona" + assert score <= -1000, f"Score {score} - powinien być sentinel ≤ -1000" + assert any("odrzucono" in r.lower() for r in reasons) + + +def test_hard_rejection_wygrywa_z_identycznym_orcid(): + """Nawet identyczny ORCID (+50) nie ratuje pary z totalnie różnymi imionami. + + Jeżeli ORCID jest taki sam ale imiona zupełnie różne, system nadal + odrzuca - to bardziej prawdopodobnie błąd w ORCID/imionach niż realny + duplikat (bo imiona człowieka raczej nie zmieniają się tak drastycznie). + """ + a = _meta(nazwisko="kowalski", imiona=("jan",), orcid="0000-0001-1111-1111") + b = _meta( + nazwisko="kowalski-nowak", imiona=("stefan",), orcid="0000-0001-1111-1111" + ) + score, reasons = analiza_pary_meta(a, b) + assert score <= -1000 + assert any("odrzucono" in r.lower() for r in reasons) + + +def test_hard_rejection_nie_blokuje_swap(): + """Klasyczny swap 'Jan Kowalski' ↔ 'Kowalski Jan' nie jest hard-rejected, + mimo że wartości imion się nie pokrywają z imionami drugiego.""" + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="jan", imiona=("kowalski",)) + score, reasons = analiza_pary_meta(a, b) + assert score > 0 + assert any("zamian" in r.lower() for r in reasons) + + +# --- Inicjały: kiedy MOŻE być duplikatem, kiedy NIE MOŻE ---------------------- + + +def test_jan_kowalski_vs_j_kropka_kowalski_moze_byc_duplikatem(): + """'Jan Kowalski' vs 'J. Kowalski' — to samo nazwisko, inicjał J się + zgadza → kandydat MOŻE być duplikatem (nie hard-reject).""" + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="kowalski", imiona=("j.",)) + score, reasons = analiza_pary_meta(a, b) + assert score > 0, ( + f"Para 'Jan' vs 'J.' powinna być akceptowalna, score={score}, reasons={reasons}" + ) + assert not any("odrzucono" in r.lower() for r in reasons) + + +def test_jan_kowalski_vs_a_kropka_kowalski_NIE_moze_byc_duplikatem(): + """'Jan Kowalski' vs 'A. Kowalski' — to samo nazwisko, ale inicjał A != J + → hard-reject (różne osoby).""" + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="kowalski", imiona=("a.",)) + score, reasons = analiza_pary_meta(a, b) + assert score <= -1000, ( + f"Inicjał A != J — para powinna być twardo odrzucona, score={score}" + ) + assert any("odrzucono" in r.lower() for r in reasons) + + +def test_jan_kowalski_vs_kowalski_j_swap_z_inicjalem_moze_byc(): + """'Jan Kowalski' vs 'Kowalski J.' — swap z inicjałem (database + swap: imiona='Kowalski', nazwisko='J.'). Inicjał J pasuje do 'Jan' → + MOŻE być duplikatem.""" + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="j.", imiona=("kowalski",)) + score, reasons = analiza_pary_meta(a, b) + assert score > 0, ( + f"Swap z pasującym inicjałem powinien przejść, score={score}, reasons={reasons}" + ) + assert not any("odrzucono" in r.lower() for r in reasons) + + +def test_jan_kowalski_vs_kowalski_a_swap_z_innym_inicjalem_NIE_moze_byc(): + """'Jan Kowalski' vs 'Kowalski A.' — swap-like layout, ale 'A.' nie + pasuje do 'Jan' (różne inicjały) → hard-reject.""" + a = _meta(nazwisko="kowalski", imiona=("jan",)) + b = _meta(nazwisko="a.", imiona=("kowalski",)) + score, reasons = analiza_pary_meta(a, b) + assert score <= -1000, ( + f"Swap-shape z różnym inicjałem powinien być odrzucony, score={score}" + ) + assert any("odrzucono" in r.lower() for r in reasons) diff --git a/src/deduplikator_autorow/tests/test_cluster.py b/src/deduplikator_autorow/tests/test_cluster.py new file mode 100644 index 000000000..42b214e35 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_cluster.py @@ -0,0 +1,39 @@ +"""Testy union-find (connected components).""" + +from deduplikator_autorow.utils.cluster import find_clusters + + +def test_two_disjoint_pairs(): + pairs = [(1, 2), (3, 4)] + clusters = sorted(find_clusters(pairs), key=min) + assert clusters == [{1, 2}, {3, 4}] + + +def test_transitive_cluster(): + """A~B and B~C → cluster {A, B, C}.""" + pairs = [(1, 2), (2, 3)] + clusters = list(find_clusters(pairs)) + assert clusters == [{1, 2, 3}] + + +def test_single_pair(): + pairs = [(7, 8)] + clusters = list(find_clusters(pairs)) + assert clusters == [{7, 8}] + + +def test_no_pairs(): + assert list(find_clusters([])) == [] + + +def test_isolated_nodes_with_pairs(): + """Tylko węzły mające połączenia trafiają do klastrów.""" + pairs = [(1, 2), (5, 6), (2, 3)] + clusters = sorted(find_clusters(pairs), key=min) + assert clusters == [{1, 2, 3}, {5, 6}] + + +def test_duplicate_pairs_are_idempotent(): + pairs = [(1, 2), (1, 2), (2, 1)] + clusters = list(find_clusters(pairs)) + assert clusters == [{1, 2}] diff --git a/src/deduplikator_autorow/tests/test_combined_scan.py b/src/deduplikator_autorow/tests/test_combined_scan.py new file mode 100644 index 000000000..b748a0101 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_combined_scan.py @@ -0,0 +1,104 @@ +"""Testy combined task scan_for_duplicates (PBN + general).""" + +from unittest import mock + +import pytest +from model_bakery import baker + +from deduplikator_autorow.models import DuplicateCandidate, DuplicateScanRun +from deduplikator_autorow.tasks import scan_for_duplicates + + +@pytest.mark.django_db +def test_combined_scan_runs_both_phases_status_completed(): + """Sukces obu faz → status COMPLETED.""" + result = scan_for_duplicates.apply().result + assert result["status"] == "success" + scan = DuplicateScanRun.objects.get(pk=result["scan_run_id"]) + assert scan.status == DuplicateScanRun.Status.COMPLETED + + +@pytest.mark.django_db +def test_combined_scan_general_finds_duplicates(): + """Faza general dodaje DuplicateCandidate(scan_mode='general').""" + baker.make("bpp.Autor", nazwisko="Hawkins", imiona="Lee") + baker.make("bpp.Autor", nazwisko="Hawkins", imiona="Lee") + result = scan_for_duplicates.apply().result + scan = DuplicateScanRun.objects.get(pk=result["scan_run_id"]) + assert ( + DuplicateCandidate.objects.filter(scan_run=scan, scan_mode="general").count() + >= 1 + ) + + +@pytest.mark.django_db +def test_cancel_during_general_phase_leaves_partial_completed(): + """Anulowanie w fazie 2 (general) → PARTIAL_COMPLETED.""" + baker.make("bpp.Autor", nazwisko="Igor", imiona="Test") + baker.make("bpp.Autor", nazwisko="Igor", imiona="Test") + + def fake_general(scan_run, *args, **kwargs): + scan_run.status = DuplicateScanRun.Status.CANCELLED + scan_run.save(update_fields=["status"]) + + with mock.patch( + "deduplikator_autorow.tasks._run_general_phase", + side_effect=fake_general, + ): + result = scan_for_duplicates.apply().result + + scan = DuplicateScanRun.objects.get(pk=result["scan_run_id"]) + assert scan.status == DuplicateScanRun.Status.PARTIAL_COMPLETED + assert result["status"] == "partial_completed" + + +@pytest.mark.django_db +def test_cancel_during_pbn_phase_leaves_cancelled(): + """Anulowanie w fazie 1 (PBN) → CANCELLED, faza 2 nie startuje.""" + + def fake_pbn(scan_run, *args, **kwargs): + scan_run.status = DuplicateScanRun.Status.CANCELLED + scan_run.save(update_fields=["status"]) + + with ( + mock.patch( + "deduplikator_autorow.tasks._run_pbn_phase", + side_effect=fake_pbn, + ), + mock.patch("deduplikator_autorow.tasks._run_general_phase") as general_mock, + ): + result = scan_for_duplicates.apply().result + general_mock.assert_not_called() + + scan = DuplicateScanRun.objects.get(pk=result["scan_run_id"]) + assert scan.status == DuplicateScanRun.Status.CANCELLED + assert result["status"] == "cancelled" + + +@pytest.mark.django_db +def test_phase_field_set_during_run(): + """Pole `phase` ustawione na 'pbn' przy fazie 1, 'general' przy fazie 2.""" + phases_seen = [] + + from deduplikator_autorow import tasks as deduptasks + + original_pbn = deduptasks._run_pbn_phase + original_general = deduptasks._run_general_phase + + def spy_pbn(scan_run, *a, **kw): + scan_run.refresh_from_db() + phases_seen.append(("pbn", scan_run.phase)) + return original_pbn(scan_run, *a, **kw) + + def spy_general(scan_run, *a, **kw): + scan_run.refresh_from_db() + phases_seen.append(("general", scan_run.phase)) + return original_general(scan_run, *a, **kw) + + with ( + mock.patch.object(deduptasks, "_run_pbn_phase", spy_pbn), + mock.patch.object(deduptasks, "_run_general_phase", spy_general), + ): + scan_for_duplicates.apply() + + assert phases_seen == [("pbn", "pbn"), ("general", "general")] diff --git a/src/deduplikator_autorow/tests/test_general_phase.py b/src/deduplikator_autorow/tests/test_general_phase.py new file mode 100644 index 000000000..586e6ace5 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_general_phase.py @@ -0,0 +1,147 @@ +"""Testy fazy general w skanowaniu duplikatów.""" + +import pytest +from model_bakery import baker + +from deduplikator_autorow.models import ( + DuplicateCandidate, + DuplicateScanRun, + IgnoredAuthor, + NotADuplicate, +) +from deduplikator_autorow.tasks import _run_general_phase + + +@pytest.mark.django_db +def test_general_finds_simple_pair(): + """Dwóch autorów o tym samym nazwisku/imieniu, żaden bez OsobaZInstytucji.""" + baker.make("bpp.Autor", nazwisko="Kowalski", imiona="Jan") + baker.make("bpp.Autor", nazwisko="Kowalski", imiona="Jan") + scan = DuplicateScanRun.objects.create() + _run_general_phase(scan, min_confidence=50) + cands = DuplicateCandidate.objects.filter(scan_run=scan, scan_mode="general") + assert cands.count() == 1 + + +@pytest.mark.django_db +def test_general_skips_cluster_with_osoba_instytucji(): + """Klaster {A, B, C} gdzie B ma OsobaZInstytucji → klaster pominięty.""" + baker.make("bpp.Autor", nazwisko="Nowak", imiona="Anna") + b = baker.make("bpp.Autor", nazwisko="Nowak", imiona="Anna") + baker.make("bpp.Autor", nazwisko="Nowak", imiona="Anna") + scientist = baker.make("pbn_api.Scientist") + b.pbn_uid = scientist + b.save() + baker.make("pbn_api.OsobaZInstytucji", personId=scientist) + + scan = DuplicateScanRun.objects.create() + _run_general_phase(scan, min_confidence=50) + cands = DuplicateCandidate.objects.filter(scan_run=scan, scan_mode="general") + assert cands.count() == 0 + + +@pytest.mark.django_db +def test_general_main_chosen_by_orcid(): + """Z dwóch autorów ORCID-owany wygrywa jako main.""" + a = baker.make("bpp.Autor", nazwisko="Adams", imiona="Eve", orcid=None) + b = baker.make( + "bpp.Autor", + nazwisko="Adams", + imiona="Eve", + orcid="0000-0001-2345-6789", + ) + scan = DuplicateScanRun.objects.create() + _run_general_phase(scan, min_confidence=50) + cand = DuplicateCandidate.objects.get(scan_run=scan, scan_mode="general") + assert cand.main_autor_id == b.pk + assert cand.duplicate_autor_id == a.pk + + +@pytest.mark.django_db +def test_general_pk_tiebreaker(): + """Wszystko równe → niższy pk wygrywa jako main.""" + a = baker.make("bpp.Autor", nazwisko="Black", imiona="Carl") + b = baker.make("bpp.Autor", nazwisko="Black", imiona="Carl") + lower_pk = min(a.pk, b.pk) + higher_pk = max(a.pk, b.pk) + + scan = DuplicateScanRun.objects.create() + _run_general_phase(scan, min_confidence=50) + cand = DuplicateCandidate.objects.get(scan_run=scan, scan_mode="general") + assert cand.main_autor_id == lower_pk + assert cand.duplicate_autor_id == higher_pk + + +@pytest.mark.django_db +def test_general_respects_ignored_author(): + a = baker.make("bpp.Autor", nazwisko="Yellow", imiona="Sun") + baker.make("bpp.Autor", nazwisko="Yellow", imiona="Sun") + user = baker.make("bpp.BppUser") + IgnoredAuthor.objects.create(autor=a, created_by=user) + + scan = DuplicateScanRun.objects.create() + _run_general_phase(scan, min_confidence=50) + assert DuplicateCandidate.objects.filter(scan_run=scan).count() == 0 + + +@pytest.mark.django_db +def test_general_respects_not_a_duplicate(): + a = baker.make("bpp.Autor", nazwisko="Green", imiona="Mike") + baker.make("bpp.Autor", nazwisko="Green", imiona="Mike") + user = baker.make("bpp.BppUser") + NotADuplicate.objects.create(autor=a, created_by=user) + + scan = DuplicateScanRun.objects.create() + _run_general_phase(scan, min_confidence=50) + assert DuplicateCandidate.objects.filter(scan_run=scan).count() == 0 + + +@pytest.mark.django_db +def test_general_phase_no_sql_per_candidate(): + """_run_general_phase nie robi SQL per candidate (meta-cache).""" + from django.db import connection + from django.test.utils import CaptureQueriesContext + + # 5 par z dwoma autorami każda → 5 candidates + for nazwisko in ["Aaa", "Bbb", "Ccc", "Ddd", "Eee"]: + baker.make("bpp.Autor", nazwisko=nazwisko, imiona="Jan") + baker.make("bpp.Autor", nazwisko=nazwisko, imiona="Jan") + + scan = DuplicateScanRun.objects.create() + with CaptureQueriesContext(connection) as ctx: + _run_general_phase(scan, min_confidence=50) + n5 = len(ctx.captured_queries) + + # Drugi run z 10 par + for nazwisko in ["Fff", "Ggg", "Hhh", "Iii", "Jjj"]: + baker.make("bpp.Autor", nazwisko=nazwisko, imiona="Jan") + baker.make("bpp.Autor", nazwisko=nazwisko, imiona="Jan") + + scan2 = DuplicateScanRun.objects.create() + with CaptureQueriesContext(connection) as ctx: + _run_general_phase(scan2, min_confidence=50) + n10 = len(ctx.captured_queries) + + # Liczba zapytań nie powinna rosnąć liniowo z liczbą candidates. + # Bulk_create może tworzyć 1-2 dodatkowych SAVEPOINT/INSERT, ale + # nie 5+ per candidate. + diff = n10 - n5 + assert diff <= 5, ( + f"Per-candidate SQL detected: 5 candidates → {n5} queries, " + f"10 candidates → {n10} queries (diff={diff})" + ) + + +@pytest.mark.django_db +def test_general_transitive_cluster(): + """Trzech 'Linker Jan' tworzy klaster {A,B,C} → 2 pary z jednym main.""" + a = baker.make("bpp.Autor", nazwisko="Linker", imiona="Jan") + b = baker.make("bpp.Autor", nazwisko="Linker", imiona="Jan") + c = baker.make("bpp.Autor", nazwisko="Linker", imiona="Jan") + scan = DuplicateScanRun.objects.create() + _run_general_phase(scan, min_confidence=50) + cands = DuplicateCandidate.objects.filter(scan_run=scan, scan_mode="general") + assert cands.count() == 2 + main_pks = {c.main_autor_id for c in cands} + assert len(main_pks) == 1 + assert main_pks == {min(a.pk, b.pk, c.pk)} diff --git a/src/deduplikator_autorow/tests/test_ignore_views.py b/src/deduplikator_autorow/tests/test_ignore_views.py new file mode 100644 index 000000000..dde4bf9f9 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_ignore_views.py @@ -0,0 +1,62 @@ +import pytest +from django.contrib.auth.models import Group +from django.urls import reverse +from model_bakery import baker + +from bpp.const import GR_WPROWADZANIE_DANYCH +from deduplikator_autorow.models import IgnoredAuthor, IgnoredScientist + + +@pytest.fixture +def auth_client(client, db): + user = baker.make("bpp.BppUser", is_active=True) + user.set_password("xx") + user.save() + grp, _ = Group.objects.get_or_create(name=GR_WPROWADZANIE_DANYCH) + user.groups.add(grp) + client.force_login(user) + return client + + +@pytest.mark.django_db +def test_ignore_scientist_endpoint(auth_client): + sci = baker.make("pbn_api.Scientist") + response = auth_client.post( + reverse("deduplikator_autorow:ignore_scientist"), + {"scientist_id": sci.pk, "reason": "test"}, + ) + assert response.status_code == 302 + assert IgnoredScientist.objects.filter(scientist=sci).exists() + + +@pytest.mark.django_db +def test_ignore_autor_endpoint(auth_client): + autor = baker.make("bpp.Autor") + response = auth_client.post( + reverse("deduplikator_autorow:ignore_autor"), + {"autor_id": autor.pk, "reason": "test"}, + ) + assert response.status_code == 302 + assert IgnoredAuthor.objects.filter(autor=autor).exists() + + +@pytest.mark.django_db +def test_reset_ignored_autorzy_endpoint(auth_client): + autor = baker.make("bpp.Autor") + user = baker.make("bpp.BppUser") + IgnoredAuthor.objects.create(autor=autor, created_by=user) + response = auth_client.post(reverse("deduplikator_autorow:reset_ignored_autorzy")) + assert response.status_code == 302 + assert IgnoredAuthor.objects.count() == 0 + + +@pytest.mark.django_db +def test_reset_ignored_scientists_endpoint(auth_client): + sci = baker.make("pbn_api.Scientist") + user = baker.make("bpp.BppUser") + IgnoredScientist.objects.create(scientist=sci, created_by=user) + response = auth_client.post( + reverse("deduplikator_autorow:reset_ignored_scientists") + ) + assert response.status_code == 302 + assert IgnoredScientist.objects.count() == 0 diff --git a/src/deduplikator_autorow/tests/test_main_selection.py b/src/deduplikator_autorow/tests/test_main_selection.py new file mode 100644 index 000000000..6dbeea8d2 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_main_selection.py @@ -0,0 +1,75 @@ +"""Testy hierarchii wyboru głównego rekordu (hierarchia B).""" + +from deduplikator_autorow.utils.main_selection import pick_main_pk + + +def _meta(**kwargs): + """Helper — minimalny wpis meta.""" + base = { + "ma_orcid": False, + "ma_pbn_uid": False, + "ma_tytul": False, + "ma_dyscypline": False, + "publikacje_count": 0, + "max_rok": 0, + } + base.update(kwargs) + return base + + +def test_orcid_wins_over_everything(): + metas = { + 1: _meta(ma_orcid=False, publikacje_count=100, max_rok=2025), + 2: _meta(ma_orcid=True, publikacje_count=1, max_rok=2000), + } + cluster = {1, 2} + assert pick_main_pk(cluster, metas) == 2 + + +def test_pbn_uid_wins_when_orcid_tied(): + metas = { + 1: _meta(ma_orcid=True, ma_pbn_uid=False), + 2: _meta(ma_orcid=True, ma_pbn_uid=True), + } + assert pick_main_pk({1, 2}, metas) == 2 + + +def test_tytul_wins_when_above_tied(): + metas = { + 1: _meta(ma_orcid=True, ma_pbn_uid=True, ma_tytul=False), + 2: _meta(ma_orcid=True, ma_pbn_uid=True, ma_tytul=True), + } + assert pick_main_pk({1, 2}, metas) == 2 + + +def test_dyscyplina_wins_when_above_tied(): + metas = { + 1: _meta(ma_orcid=True, ma_pbn_uid=True, ma_tytul=True, ma_dyscypline=False), + 2: _meta(ma_orcid=True, ma_pbn_uid=True, ma_tytul=True, ma_dyscypline=True), + } + assert pick_main_pk({1, 2}, metas) == 2 + + +def test_publikacje_count_wins_when_above_tied(): + metas = { + 1: _meta(publikacje_count=5), + 2: _meta(publikacje_count=10), + } + assert pick_main_pk({1, 2}, metas) == 2 + + +def test_max_rok_wins_when_publikacje_tied(): + metas = { + 1: _meta(publikacje_count=5, max_rok=2020), + 2: _meta(publikacje_count=5, max_rok=2025), + } + assert pick_main_pk({1, 2}, metas) == 2 + + +def test_pk_lowest_wins_when_all_tied(): + metas = { + 77: _meta(), + 12: _meta(), + 99: _meta(), + } + assert pick_main_pk({77, 12, 99}, metas) == 12 diff --git a/src/deduplikator_autorow/tests/test_merge_all_refresh.py b/src/deduplikator_autorow/tests/test_merge_all_refresh.py new file mode 100644 index 000000000..d10f9ff86 --- /dev/null +++ b/src/deduplikator_autorow/tests/test_merge_all_refresh.py @@ -0,0 +1,194 @@ +"""Testy stanu "Scal wszystkie" — gating po pewności + dane potrzebne JS-owi +do odświeżenia stanu po AJAX-owym usunięciu karty (refreshMergeAllAvailability). + +Testy są server-side: sprawdzają dane, które view eksportuje do template-a, +a template do DOM-u. Zapewniają, że klient ma wszystko czego potrzebuje, żeby +prawidłowo przeliczyć stan przycisków bez przeładowania strony. + +Testowy E2E (kliknięcie "Nie jest duplikatem" + sprawdzenie odblokowania) +żyje w `test_merge_all_refresh_e2e.py` (Playwright). +""" + +import pytest +from django.contrib.auth.models import Group +from django.urls import reverse +from django.utils import timezone +from model_bakery import baker + +from bpp.const import GR_WPROWADZANIE_DANYCH +from deduplikator_autorow.models import DuplicateCandidate, DuplicateScanRun +from deduplikator_autorow.views import MIN_PEWNOSC_DO_WYSWIETLENIA + + +@pytest.fixture +def auth_client(client, db): + user = baker.make("bpp.BppUser", is_active=True) + user.set_password("xx") + user.save() + grp, _ = Group.objects.get_or_create(name=GR_WPROWADZANIE_DANYCH) + user.groups.add(grp) + client.force_login(user) + return client + + +def _create_candidate(scan, main, dup, confidence_percent, mode="pbn"): + """confidence_percent in 0..1 - musi przejść przez display=round(*100).""" + return DuplicateCandidate.objects.create( + scan_run=scan, + main_autor=main, + duplicate_autor=dup, + confidence_score=int(confidence_percent * 100), # nieistotne dla display + confidence_percent=confidence_percent, + main_autor_name=str(main), + duplicate_autor_name=str(dup), + scan_mode=mode, + ) + + +@pytest.fixture +def scan_with_mixed_confidence(db): + """Scan: 1 main author + 2 duplikaty (jeden 80%, jeden 30%).""" + scan = DuplicateScanRun.objects.create( + status=DuplicateScanRun.Status.COMPLETED, + finished_at=timezone.now(), + ) + main = baker.make("bpp.Autor", nazwisko="Kowalski", imiona="Jan") + high = baker.make("bpp.Autor", nazwisko="Kowalski", imiona="Jan") + low = baker.make("bpp.Autor", nazwisko="Kowal", imiona="Janusz") + high_cand = _create_candidate(scan, main, high, 0.80) + low_cand = _create_candidate(scan, main, low, 0.30) + return scan, main, high, low, high_cand, low_cand + + +@pytest.fixture +def scan_only_high_confidence(db): + """Scan: 1 main + 2 duplikaty - oba ≥ 50%.""" + scan = DuplicateScanRun.objects.create( + status=DuplicateScanRun.Status.COMPLETED, + finished_at=timezone.now(), + ) + main = baker.make("bpp.Autor", nazwisko="Nowak", imiona="Anna") + a = baker.make("bpp.Autor", nazwisko="Nowak", imiona="Anna") + b = baker.make("bpp.Autor", nazwisko="Nowak", imiona="Ania") + _create_candidate(scan, main, a, 0.85) + _create_candidate(scan, main, b, 0.65) + return scan, main + + +def test_view_exposes_pewnosc_threshold_to_template( + auth_client, scan_only_high_confidence +): + """JS musi znać próg, żeby przeliczyć po fadeOut. Testujemy że jest w DOM-ie.""" + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + assert response.status_code == 200 + content = response.content.decode() + # Wartość MIN_PEWNOSC_DO_WYSWIETLENIA (50) powinna być wstawiona do JS-a + # jako var MIN_PEWNOSC_THRESHOLD = 50; + assert f"MIN_PEWNOSC_THRESHOLD = {MIN_PEWNOSC_DO_WYSWIETLENIA}" in content + + +def test_card_has_data_pewnosc_attribute(auth_client, scan_with_mixed_confidence): + """Każda karta musi mieć data-pewnosc - JS po fadeOut iteruje po nich.""" + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + content = response.content.decode() + # Wysokopewny: 0.80 * 100 = 80 + assert 'data-pewnosc="80"' in content + # Niskopewny: 0.30 * 100 = 30 + assert 'data-pewnosc="30"' in content + + +def test_card_has_data_author_name_attribute(auth_client, scan_with_mixed_confidence): + """Każda karta ma data-author-name dla aktualizowanej listy w alercie.""" + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + content = response.content.decode() + # Sprawdzamy że atrybut jest, z dowolnym sensownym tekstem reprezentującym autora + assert "data-author-name=" in content + + +def test_merge_all_disabled_when_low_confidence_present( + auth_client, scan_with_mixed_confidence +): + """Z 30% kandydatem przyciski 'Scal wszystkie' są wyszarzone.""" + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + content = response.content.decode() + assert 'aria-disabled="true"' in content + assert "deduplikator-autorow__merge-all-btn--disabled" in content + + +def test_low_confidence_names_in_data_attribute( + auth_client, scan_with_mixed_confidence +): + """data-low-confidence-names przekazuje listę nazwisk do alertu JS.""" + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + content = response.content.decode() + assert "data-low-confidence-names=" in content + # Zawiera autora 30% (Kowal Janusz) + assert "30%" in content + + +def test_merge_all_enabled_when_all_high_confidence( + auth_client, scan_only_high_confidence +): + """Wszyscy kandydaci ≥ 50% - przyciski aktywne, brak klasy disabled.""" + response = auth_client.get(reverse("deduplikator_autorow:duplicate_authors")) + content = response.content.decode() + # Przyciski merge-all renderują się + assert 'data-merge-all="true"' in content + # Atrybut HTML aria-disabled="true" w button-tagach NIE występuje obok + # przycisków merge-all. Klasa __merge-all-btn--disabled występuje też jako + # string w JS-ie (dla manipulacji classList), więc asercję robimy + # znajdując każdy
    @@ -178,7 +177,7 @@
  • wysylka oswiadczen do PBN
  • kolejka eksportu do PBN
  • importer autorów PBN
  • -
  • deduplikator autorów PBN
  • +
  • deduplikator autorów
  • - + + -