diff --git a/docs/superpowers/specs/2026-06-18-profil-autora-i-podstrona-design.md b/docs/superpowers/specs/2026-06-18-profil-autora-i-podstrona-design.md new file mode 100644 index 000000000..284a67803 --- /dev/null +++ b/docs/superpowers/specs/2026-06-18-profil-autora-i-podstrona-design.md @@ -0,0 +1,309 @@ +# Profil autora (edytowalny) + przebudowa podstrony autora — projekt + +Data: 2026-06-18 +Status: zatwierdzony do napisania planu implementacji + +## 1. Cel + +Dwie powiązane zmiany: + +1. **Przebudowa publicznej podstrony autora** (`/autor//`, `/autor//`) — + z dotychczasowej strony „metadane + formularz wyszukiwania" na stronę + złożoną z **konfigurowalnych sekcji treści** (kolejność + widoczność + + limity), renderowanych nad spójnym, stałym nagłówkiem tożsamości. +2. **Edytowalny profil autora** — autor (zalogowany użytkownik powiązany z + rekordem `Autor`) może wgrać zdjęcie i biogram oraz ułożyć własną stronę: + wybrać które sekcje, w jakiej kolejności i w jakiej liczbie pozycji mają się + wyświetlać. + +Dostarczane **fazowo**: + +- **Faza 1 (PR1):** model danych + render publicznej strony + edycja w Django + adminie. Wszyscy autorzy bez konfiguracji dostają układ domyślny. +- **Faza 2 (PR2):** self-service edytor w „Mój profil" (drag-drop, uploady, + live preview, picker wyróżnionych prac) + eksport RIS. + +## 2. Stan obecny (ustalenia ze zwiadu po kodzie) + +- **Publiczna strona:** `AutorView(DetailView)` — `src/bpp/views/browse.py:145`, + szablon `src/bpp/templates/browse/autor.html`, URL-e `src/bpp/urls.py:262,288` + (oba `bpp:browse_autor`). Dziś renderuje metadane + formularz „Wyszukaj + publikacje autora" (POST → `bpp:browse_build_search` → multiseek) + kod do + embedowania. Nie listuje publikacji inline. +- **„Mój profil":** `ProfilUzytkownikaView(LoginRequiredMixin, TemplateView)` — + `src/bpp/views/profile.py:5`, URL `profil/` (`bpp:profil-uzytkownika`), + szablon `src/bpp/templates/bpp/profil_uzytkownika.html`. **Wyłącznie + read-only** — brak jakiegokolwiek formularza edycji. +- **Powiązanie User↔Autor:** `BppUser.autor` OneToOneField (`related_name="user"`) + — `src/bpp/models/profile.py:56-64`. Auto-dopasowanie po e-mailu/nazwisku: + `BppUser.sprobuj_dopasowac_autora()` (`profile.py:80-115`). +- **Model `Autor`:** `src/bpp/models/autor.py:81`. Ma `opis` + (`HTMLField`/TinyMCE, `autor.py:129`) + `pokazuj_opis` — pokazywany w + nagłówku jako „Opis". **Brak** jakiegokolwiek pola na zdjęcie. Pillow + zainstalowany; wzorzec ImageField: `Uczelnia.logo_www` + (`src/bpp/models/uczelnia.py:96`, `upload_to="logo"`). +- **Dane publikacji:** zdenormalizowany cache `Rekord` + (`src/bpp/models/cache/rekord.py`), `RekordManager.prace_autora(autor)` + (`rekord.py:45`) → `filter(autorzy__autor=autor).distinct()`. Pola: `rok`, + `charakter_formalny`, `zrodlo` (`rekord.py:217`, FK `bpp.Zrodlo`), + `ostatnio_zmieniony` (`rekord.py:231`), punktacja z `ModelPunktowanyBaza` + (`punkty_kbn`, `impact_factor`, ... — `src/bpp/models/abstract/scoring.py`, + wszystkie indeksowane). +- **Punkty autora:** `Cache_Punktacja_Autora` (`src/bpp/models/cache/punktacja.py:51`, + `managed=True`), pola `autor`, `rekord_id` (TupleField), `pkdaut`, `slot`. +- **Charakter pracy:** `Charakter_Formalny` (MPTT) — `charakter_ogolny` + (`art`/`roz`/`ksi`/`xxx`, `src/bpp/const.py:32-35`) rozróżnia + artykuł/rozdział/książkę/inne; modele źródłowe `Wydawnictwo_Ciagle` + (artykuły) i `Wydawnictwo_Zwarte` (zwarte). +- **Dyscypliny:** `Autorzy.dyscyplina_naukowa` (`src/bpp/models/cache/autorzy.py:22,52`, + FK `bpp.Dyscyplina_Naukowa`). +- **Współautorzy:** `AuthorConnection` (`src/powiazania_autorow/models.py:6`) — + `primary_author`, `secondary_author`, `shared_publications_count`, + `ordering=["-shared_publications_count"]`. Stored undirected (para + uporządkowana po id), więc filtrować po obu stronach. Prekomputowany + okresowo (Celery). **To dokładnie ten sam obiekt, który zasila wizualny + browser „powiązania autorów 2D/3D"** (URL-e `/autor//powiazania/`, + `/powiazania/3d/`, JSON-y — `powiazania_autorow.views`, gejtowane przez + `czy_pokazywac_siec_powiazan` w `browse.py:150-163`). +- **Raport autora:** `nowe_raporty`, slug `raport-autorow`, pk-linkowalny + (`/nowe_raporty/raport-autorow////`), + `nowe_raporty:raport_form` / `:raport_generuj` (`src/nowe_raporty/urls.py`). + Widoczność: `DefinicjaRaportu.widoczny_dla(request)` + (`src/nowe_raporty/models.py:115-128`); domyślnie publiczny, admin-konfigurowalny. +- **Sanityzacja / Markdown:** `nh3` użyty w `bpp.util.text.safe_html()` + (`src/bpp/util/text.py:168`) — idiomatyczny sanitizer repo. Pakiet `markdown` + 3.10.2 zainstalowany (importowalny), bez istniejącego pipeline'u renderu. +- **Eksport cytowań:** BibTeX istnieje i jest reużywalny dla listy prac + (`src/bpp/export/bibtex.py`, `export_to_bibtex()` `bibtex.py:507`, metody + `.to_bibtex()` na modelach) — operuje na konkretnych obiektach + `Wydawnictwo_*`/`Patent`/`Praca_*` (z `Rekord` przez `.original`). + **RIS nie istnieje — net-new.** +- **Wyróżnione/wybrane publikacje:** koncept **nie istnieje** + (`Autorzy.przypieta` dotyczy przypinania dyscypliny do PBN, nie wyróżniania + prac). **Net-new model.** + +## 3. Decyzje projektowe (zatwierdzone) + +| Zagadnienie | Decyzja | +|---|---| +| Biogram | Nowe pola `biogram` + `biogram_format` (md/html). `opis` nietknięty. | +| Kto edytuje (Faza 2) | Każdy zalogowany z ustawionym `user.autor`. | +| Dostawa | Fazowo: Faza 1 render + admin, Faza 2 self-service. | +| Bloki metadanych | Zostają stałym nagłówkiem; edytor steruje tylko sekcjami treści. | +| Ranking „best" | Osobno `najlepsze_pk` (`-punkty_kbn`) i `najlepsze_if` (`-impact_factor`). | +| Limit list | 10/20/30/50, domyślnie 10, per sekcja listowa. | +| Zdjęcie | Awatar w nagłówku, kwadrat 400×400 (center-crop), zapis **WebP** q≈85, ≤5 MB. | +| Statystyki | Liczba prac wg charakteru formalnego (szczegółowo). | +| Link do raportu | 3 linki: bieżący rok, ostatnie 4 lata, szczegółowy formularz; widoczność wg `widoczny_dla`. | +| Układ domyślny | „biogram-najpierw". | +| Domyślne sugestie | ON: `wykres_lata` + `wspolautorzy`; reszta sugestii OFF. | +| `opis` vs `biogram` | `opis` zostaje w nagłówku; biogram to osobna sekcja. | +| Współautorzy | `AuthorConnection` (prekomputowany) + CTA do browsera 2D/3D. | + +## 4. Model danych (Faza 1) + +Nowa migracja w `src/bpp/migrations/` (NIE edytować istniejących). + +### 4.1. Nowe pola na `Autor` (`src/bpp/models/autor.py`) + +- `zdjecie = models.ImageField(upload_to="autor_zdjecia", null=True, blank=True)` + — przetwarzane przy zapisie do kwadratu 400×400 WebP (patrz 7.3). +- `biogram = models.TextField(blank=True, default="")` — surowe źródło. +- `biogram_format = models.CharField(max_length=4, choices=[("md","Markdown"), + ("html","HTML")], default="md")`. +- `uklad_profilu = models.JSONField(null=True, blank=True, default=None)` — + `null` = układ domyślny; inaczej lista pozycji (patrz 6). + +### 4.2. Nowy model `WybranaPublikacjaAutora` + +Wyróżnione prace (ręcznie wybierane). Plik: `src/bpp/models/wybrana_publikacja.py` +(zarejestrowany w `src/bpp/models/__init__.py`). + +``` +autor = FK(Autor, related_name="wybrane_publikacje", on_delete=CASCADE) +content_type = FK(ContentType, on_delete=CASCADE) +object_id = PositiveIntegerField +publikacja = GenericForeignKey("content_type", "object_id") +kolejnosc = PositiveIntegerField(default=0) +class Meta: ordering = ["kolejnosc"]; unique_together = [(autor, content_type, object_id)] +``` + +Rozwiązywane do `Rekord` przez `(content_type_id, object_id)` lub do `.original`. +W Fazie 1 wypełniane przez admina (inline); w Fazie 2 przez picker self-service. + +## 5. Rejestr sekcji (w kodzie) + +Plik: `src/bpp/profil/sekcje.py`. Katalog typów sekcji żyje w kodzie; per-autor +JSON trzyma tylko kolejność/widoczność/limit. Dodanie sekcji = zmiana kodu, bez +migracji danych. + +Każdy wpis rejestru: `klucz`, `nazwa`, `obowiazkowa: bool`, `ma_limit: bool`, +`domyslnie_widoczna: bool`, `template` (partial), funkcja +`pobierz_kontekst(autor, limit, request) -> dict|None` (zwraca `None`/pusty → +sekcja auto-ukryta). + +| klucz | nazwa | obow. | limit | dom. ON | źródło | +|---|---|---|---|---|---| +| `biogram` | Biogram | nie | – | tak | `autor.biogram_html` | +| `wyszukiwarka` | Wyszukiwarka prac | **tak** | – | tak (wymuszone) | obecny POST→multiseek | +| `najlepsze_pk` | Najlepsze prace (punkty MNiSW) | nie | tak | tak | `prace_autora` `-punkty_kbn` | +| `najlepsze_if` | Najlepsze prace (Impact Factor) | nie | tak | tak | `prace_autora` `-impact_factor` | +| `najnowsze_artykuly` | Najnowsze artykuły | nie | tak | tak | wyd. ciągłe `-rok` | +| `najnowsze_zwarte` | Najnowsze książki / rozdziały | nie | tak | tak | wyd. zwarte `-rok` | +| `ostatnio_edytowane` | Ostatnio edytowane | nie | tak | tak | `-ostatnio_zmieniony` | +| `wybrane_publikacje` | Wybrane publikacje | nie | – | nie | `WybranaPublikacjaAutora` | +| `statystyki_charakter` | Statystyki wg charakteru | nie | – | tak | `Count` po `charakter_formalny` | +| `wykres_lata` | Publikacje w latach | nie | – | **tak** | `autor.prace_w_latach` | +| `punkty_lata` | Punkty / sloty w latach | nie | – | nie | `Cache_Punktacja_Autora` | +| `dyscypliny` | Udział dyscyplin | nie | – | nie | `Autorzy.dyscyplina_naukowa` | +| `zrodla` | Najczęstsze źródła / czasopisma | nie | tak | nie | `Rekord.zrodlo` | +| `wspolautorzy` | Najczęstsi współautorzy | nie | tak | **tak** | `AuthorConnection` + CTA 2D/3D | +| `eksport` | Eksport listy publikacji | nie | – | nie | `export_to_bibtex` (BibTeX; RIS w Fazie 2) | + +Wszystkie zapytania listowe: `select_related("charakter_formalny","zrodlo", +"wydawca")`, twardy limit, świadome unikanie N+1. + +## 6. Konfiguracja układu (`uklad_profilu`) i jej rozwiązywanie + +### 6.1. Schemat JSON + +Lista pozycji w kolejności wyświetlania: + +```json +[ + {"klucz": "biogram", "widoczna": true, "limit": null}, + {"klucz": "wyszukiwarka", "widoczna": true, "limit": null}, + {"klucz": "najlepsze_pk", "widoczna": true, "limit": 10}, + ... +] +``` + +`limit ∈ {10,20,30,50}` tylko dla sekcji `ma_limit=True`; w przeciwnym razie +`null`. + +### 6.2. Walidacja (`waliduj_uklad`) + +- Klucze spoza rejestru → odrzucone. +- `limit` poza `{10,20,30,50}` dla sekcji listowej → korekta do 10. +- Sekcja `obowiazkowa` → `widoczna` wymuszone na `True`. + +### 6.3. Rozwiązywanie (`rozwiaz_uklad(autor) -> list[SekcjaUkladu]`) + +1. Start od kanonicznego porządku domyślnego (kolejność z tabeli w §5, + z `domyslnie_widoczna`). +2. Jeśli `autor.uklad_profilu` niepuste: nadpisz kolejność/widoczność/limit dla + znanych kluczy. +3. Sekcje z rejestru nieobecne w zapisanym configu → dołączone w pozycji + kanonicznej z domyślami (forward-compat: nowo dodana sekcja pojawia się + automatycznie). +4. Wymuś `widoczna=True` dla obowiązkowych. +5. Zwróć tylko `widoczna=True`; przy renderze dodatkowo odpadają sekcje, których + `pobierz_kontekst` zwróci pusto (auto-ukrywanie). + +**Układ domyślny** (biogram-najpierw, z domyślnymi sugestiami ON): +`biogram → wyszukiwarka → najlepsze_pk → najlepsze_if → najnowsze_artykuly → +najnowsze_zwarte → ostatnio_edytowane → statystyki_charakter → wykres_lata → +wspolautorzy`. Pozostałe (`wybrane_publikacje`, `punkty_lata`, `dyscypliny`, +`zrodla`, `eksport`) istnieją w rejestrze, domyślnie OFF. + +## 7. Render publicznej strony (Faza 1) + +### 7.1. `AutorView` (`src/bpp/views/browse.py`) + +`get_context_data`: +- `sekcje = rozwiaz_uklad(self.object)` z policzonym kontekstem każdej widocznej + sekcji (leniwie), z auto-ukrywaniem pustych. +- `raport_links` — jeśli `DefinicjaRaportu.objects.filter(slug="raport-autorow")` + istnieje i `.widoczny_dla(request)`: + - bieżący rok: `…/raport-autorow////` + - ostatnie 4 lata: `…/raport-autorow////` + - szczegółowy: `nowe_raporty:raport_form` (slug `raport-autorow`). + `rok = timezone.now().year`. Brak `DefinicjaRaportu` → brak linków (guard). + +### 7.2. Szablony + +- `src/bpp/templates/browse/autor.html` — refaktor: stały nagłówek + (zdjęcie-awatar + nazwisko + jednostka + ORCID/PBN/metryki/stopnie/cytowania — + jak dziś, **`opis` zostaje**) + blok linków raportu + pętla po `sekcje` + renderująca `{% include sekcja.template %}`. +- `src/bpp/templates/browse/autor_sekcje/.html` — partial na sekcję. +- Ikony: frontend publiczny → Foundation-Icons (``). +- `wspolautorzy`: lista top-N (linki do podstron + `shared_publications_count`) + + CTA „Zobacz pełną sieć powiązań" → `bpp:browse_autor_powiazania` / + `…_3d` (gdy `ma_powiazania`). + +### 7.3. Przetwarzanie zdjęcia + +`src/bpp/util/obrazy.py`: `przetworz_zdjecie_autora(plik) -> ContentFile`: +- walidacja ≤5 MB i typu obrazu (na poziomie formularza), +- Pillow: `ImageOps.exif_transpose`, center-crop do kwadratu, resize 400×400, + zapis WebP q≈85. +Wołane z save'a admina (Faza 1) i formularza self-service (Faza 2). Reużywalna +funkcja, jeden punkt prawdy. + +### 7.4. Render biogramu + +`Autor.biogram_html` (`cached_property`): `md` → +`markdown.markdown(biogram)` → `safe_html(...)`; `html` → `safe_html(biogram)`. +Jeden punkt sanityzacji (nh3, `bpp.util.text.safe_html`). + +## 8. Admin (Faza 1) + +`src/bpp/admin/` (admin `Autor`): +- Pola `zdjecie` (z podglądem miniatury), `biogram` + `biogram_format`. +- Edytor układu: formularz listujący sekcje z checkboxem widoczności, polem + kolejności i selectem limitu; zapis do `uklad_profilu` (ta sama logika + walidacji/serializacji reużyta w Fazie 2). +- Inline `WybranaPublikacjaAutora` z autocomplete prac. +- Admin używa emoji (bez Foundation Icons) zgodnie z konwencją repo. + +## 9. Faza 2 (osobny PR) + +- `src/bpp/views/profil_edycja.py` — widok edycji w „Mój profil", gate: + `LoginRequiredMixin` + wymóg `request.user.autor`. +- Edytor: drag-drop kolejności (sprawdzić istniejący JS sortowania w repo przed + dodaniem zależności), przełączniki widoczności, selecty limitów, upload + zdjęcia z podglądem, edytor biogramu z przełącznikiem MD/HTML + **live + preview** (render serwerowy AJAX-em przez ten sam pipeline), picker + wyróżnionych prac (autocomplete add/remove/reorder). +- Eksport zbiorczy: `/autor//eksport.bib` (BibTeX, reużycie + `export_to_bibtex`) i `/autor//eksport.ris` (**RIS net-new**). Świadomy + limit/stream dla autorów z dużą liczbą prac. +- „Mój profil" (`profil_uzytkownika.html`) zyskuje link „Edytuj swoją stronę". + +## 10. Testy (pytest + `model_bakery.baker`, bez unittest) + +- Model: przetwarzanie zdjęcia (rozmiar/crop/format WebP, korekta EXIF), + walidacja ≤5 MB; sanityzacja biogramu (XSS usunięty, MD wyrenderowany, + niedozwolone tagi wycięte); walidacja i rozwiązywanie układu (domyślny vs + override; forward-compat nowej sekcji; wymuszenie obowiązkowej). +- Widok: kolejność sekcji zgodna z configiem; wyszukiwarka zawsze obecna; + auto-ukrywanie pustych sekcji; gating linków raportu (anon bez / uprawniony z); + CTA współautorów tylko gdy `ma_powiazania`. +- Faza 2: gate edycji (obcy autor / brak `user.autor` → odmowa), zapis układu, + eksport BibTeX/RIS. + +## 11. Migracje i baseline + +- Nowa migracja: pola na `Autor` + model `WybranaPublikacjaAutora`. +- Po migracjach (raz, przy scalaniu): `make baseline-update` — odświeżenie + `baseline-sql/baseline.sql` + `baseline.meta.json` (commit obu). Nie odświeżać + w równoległych branchach. + +## 12. Dostawa (worktree + PR) + +Praca w worktree jako siostrzany katalog (zgodnie z CLAUDE.md), **nie** w `bpp/`: + +``` +git worktree add ~/Programowanie/bpp-profil-autora -b feature/profil-autora +``` + +Zmiany trafiają do PR-a (osobny PR na Fazę 1 i Fazę 2). + +## 13. Świadomie poza zakresem (YAGNI) + +- RIS i eksport zbiorczy — dopiero Faza 2. +- Self-service picker / drag-drop / live preview — Faza 2. +- Pole „zainteresowania badawcze" — odrzucone w brainstormingu. +- Własna nowa wizualizacja współautorów — reużywamy istniejący browser 2D/3D. +- Migracja `opis` → `biogram` — `opis` zostaje niezależny. diff --git a/docs/superpowers/specs/2026-06-19-profil-autora-rewizja-2col-HANDOFF.md b/docs/superpowers/specs/2026-06-19-profil-autora-rewizja-2col-HANDOFF.md new file mode 100644 index 000000000..6fe400197 --- /dev/null +++ b/docs/superpowers/specs/2026-06-19-profil-autora-rewizja-2col-HANDOFF.md @@ -0,0 +1,200 @@ +# Profil autora — REWIZJA (2 kolumny, układ per-Uczelnia) + HANDOFF + +Data: 2026-06-19 +Dotyczy: kontynuacji prac nad PR #385 (branch `feature/profil-autora`). +Spec bazowy: `docs/superpowers/specs/2026-06-18-profil-autora-i-podstrona-design.md`. + +> Ten dokument jest samowystarczalny — świeża sesja Claude'a ma z niego wznowić +> bez dostępu do poprzedniej rozmowy. + +## 0. Stan obecny (co JUŻ jest na branchu) + +- Worktree: `~/Programowanie/bpp-profil-autora`, branch `feature/profil-autora`, + PR **#385** → `dev`. **CI w pełni zielone** (lint, build test-runner, + 12 shardów Tests, vitest, baseline freshness, CodeQL). +- Remote push: SSH nie działa (brak klucza); pushuj przez HTTPS z gh: + `git push https://github.com/iplweb/bpp.git feature/profil-autora:feature/profil-autora` + (po `gh auth setup-git`). gh zalogowany jako `mpasternak` (token HTTPS). +- Testy: `PYTEST_TESTCONTAINERS_REUSE=1 uv run pytest src/bpp/tests/test_profil/`. + UWAGA: reused testcontainer bywa STALE (błąd `bpp_uczelnia.site_id NOT NULL` + przy fixture `uczelnia`). Gdy to wyskoczy → `make clean-testcontainers` i + odpal bez reuse (świeży kontener jest OK). +- Faza 1 dostarczyła (działa, otestowane 35+ testów w `src/bpp/tests/test_profil/`): + - Model `Autor`: `zdjecie` (ImageField), `biogram` + `biogram_format` (md/html), + `uklad_profilu` (JSONField — UWAGA: do PRZENIESIENIA na Uczelnię, patrz §2.1), + `cached_property biogram_html`. Model `WybranaPublikacjaAutora` (GenericFK). + Migracja `0444`. + - `bpp/util/biogram.py` `renderuj_biogram` + `bpp/util/text.py` + `safe_biogram_html` (nh3, bogatszy zestaw tagów, usuwa script/style, + rel=nofollow noopener). + - `bpp/util/obrazy.py` `przetworz_zdjecie_autora` (EXIF→crop→WebP 400×400). + - `bpp/profil_autora.py` — rejestr sekcji (`KATALOG_SEKCJI`, `TypSekcji`, + `KLUCZ_*`, `waliduj_uklad`, `rozwiaz_uklad`, `domyslny_uklad`). + - `bpp/profil_autora_dane.py` — buildery danych sekcji (`przygotuj_sekcje`). + - `bpp/views/browse.py` `AutorView` — render sekcji + `_raport_links`. + - `bpp/templates/browse/autor.html` + `browse/autor_sekcje/*.html`. + - `bpp/admin/autor.py` — fieldset profilu, `clean_zdjecie`, inline + `WybranaPublikacjaAutoraInline`. + - `bpp/static/scss/_autor-bem.scss` — style sekcji. + +## 1. Czego chce użytkownik (rewizja — wiadomość 2026-06-19) + +1. **Klik w całą pozycję pracy** (najlepsze/najnowsze/ostatnio edytowane) ma + prowadzić do szczegółów — NIE link `[szczegóły]` na końcu. +2. **Opisy bibliograficzne bywają dramatycznie długie** (dużo autorów) — trzeba + je rozsądnie skracać. +3. **Wykres „Publikacje w latach"**: dla >10 lat robi się za szeroki → powyżej + 10 lat wersja **liniowa**, do 10 lat **słupkowa**. +4. **Statystyki wg charakteru**: klik w charakter formalny ma **budować + wyszukiwanie** w formularzu (dany autor + ten charakter formalny). +5. **Układ 2-kolumnowy** strony autora: + - LEWA: klasyka — (zdjęcie+biogram na górze), aktualna jednostka, + historia zatrudnienia, wyszukiwarka prac, linki do raportów (+ pozostałe + bloki tożsamości: identyfikatory, metryki, stopnie, cytowania). + - PRAWA (od góry, domyślnie): Statystyki wg charakteru → wykres prac w latach + → (wykres PK) → (wykres IF, **tylko jeśli IF ≠ 0**) → współautorzy → + długi ogon „najlepsze prace" → najnowsze artykuły → najnowsze książki → + ostatnio edytowane. +6. **Edytor kafelkowy = admin-only, układ GLOBALNY per-Uczelnia** (system bywa + multi-uczelniany). Autor self-service edytuje TYLKO biogram + zdjęcie. +7. **Historia zatrudnienia** w jednostkach — sekcja w lewej kolumnie pod + aktualną jednostką (dane z `Autor_Jednostka`). + +## 2. Decyzje (zatwierdzone 2026-06-19) + +| # | Decyzja | +|---|---| +| Układ — zakres | **Globalny per-Uczelnia**. Render bierze `Uczelnia.objects.get_for_request(request)` i czyta jego układ. `Autor.uklad_profilu` — usunąć (override per-autor NIEpotrzebny). | +| „Najnowsze" listy | Zostają w prawej kolumnie, w długim ogonie pod „najlepszymi". | +| Zdjęcie/biogram | Na górze LEWEJ kolumny (wizytówka). | +| Historia zatrudnienia | TAK, sekcja w lewej kolumnie pod „aktualna jednostka". | +| Skracanie opisów | CSS line-clamp (~3 linie) + cała pozycja klikalna do szczegółów. | +| Próg wykresu | >10 lat → liniowy (SVG), ≤10 lat → słupkowy. | +| Klik w charakter | POST do `bpp:browse_build_search` z `autor` + charakter formalny. | + +## 3. Plan implementacji (konkretnie) + +### 3.1. Przeniesienie układu na Uczelnię (multi-uczelnia) + +- **Model**: dodać `Uczelnia.uklad_profilu_autora = JSONField(null=True, blank=True, + default=None)` (schemat jak dotychczasowy `uklad_profilu`: lista + `{"klucz","widoczna","limit"}` — ale tylko sekcje PRAWEJ kolumny, patrz §3.2). +- **Migracja 0445** (NIE edytować 0444 — reguła CLAUDE.md): `AddField` na + Uczelni + `RemoveField(Autor, "uklad_profilu")` (pole nieshipowane, więc + usunięcie czyste; zero danych produkcyjnych). +- **`rozwiaz_uklad`**: zmienić sygnaturę z `(autor)` na `(uczelnia)` — czyta + `uczelnia.uklad_profilu_autora` (lub `None`→default). Zaktualizować testy + `test_uklad.py` (stub `SimpleNamespace(uklad_profilu_autora=...)`). +- **`przygotuj_sekcje(autor, uczelnia, request)`** — układ z uczelni, dane z autora. +- **`AutorView`**: ma już `uczelnia = Uczelnia.objects.get_for_request(...)`; + przekazać do `przygotuj_sekcje`. +- **Admin**: usunąć `uklad_profilu` z `AutorForm`/fieldsetu Autora; dodać edytor + układu w adminie **Uczelni** (`UczelniaAdmin`). MVP: JSON w textarea + help. + (Kafelkowy drag-drop można dołożyć później — patrz §4.) + +### 3.2. Rejestr sekcji — tylko PRAWA kolumna + +Lewa kolumna jest STAŁA w szablonie (klasyka). Rejestr (`KATALOG_SEKCJI`) +obsługuje wyłącznie kafelki PRAWEJ kolumny. Usuń z rejestru: `wyszukiwarka`, +`biogram`, `eksport` (wyszukiwarka+biogram → lewa stała; eksport → Faza 2). +Zostają (domyślny porządek prawej kolumny): + +1. `statystyki_charakter` (ON) +2. `wykres_lata` (ON) — liczba prac/rok +3. `wykres_pk_lata` (ON) — suma `punkty_kbn`/rok ← NOWA +4. `wykres_if_lata` (ON, auto-hide gdy suma IF = 0) — suma `impact_factor`/rok ← NOWA +5. `wspolautorzy` (ON) +6. `najlepsze_pk` (ON) +7. `najlepsze_if` (ON) +8. `najnowsze_artykuly` (ON) +9. `najnowsze_zwarte` (ON) +10. `ostatnio_edytowane` (ON) +11. `dyscypliny` (OFF), `zrodla` (OFF), `punkty_lata` (OFF), `wybrane_publikacje` (OFF) + +Usuń `obowiazkowa` z `TypSekcji` (była tylko dla wyszukiwarki). Buildery +`_biogram`, `_wyszukiwarka`, `_eksport` z `profil_autora_dane.py` — usunąć. + +### 3.3. Szablon 2-kolumnowy (`browse/autor.html`) + +- Foundation grid (NIE nadpisywać klas grid w SCSS — zmiana w HTML): + `grid-x grid-margin-x` → `cell large-4` (lewa) + `cell large-8` (prawa); + na małych ekranach stackuje się automatycznie. +- Nagłówek (breadcrumb + H1 + funkcja + przyciski staff) — full-width nad gridem. +- LEWA `cell large-4` (kolejność): zdjęcie (awatar) → biogram → aktualna + jednostka → **historia zatrudnienia** → identyfikatory → metryki → stopnie → + cytowania → wyszukiwarka prac (`autor_sekcje/wyszukiwarka.html`) → linki + raportu → (embed-kod). Wszystko STAŁE w szablonie. +- PRAWA `cell large-8`: pętla `{% for s in sekcje_profilu %}{% include s.template ... %}{% endfor %}`. + +### 3.4. Listy prac — klik w całość + skracanie + +`browse/autor_sekcje/_lista_prac.html`: +- Usuń link `[szczegóły]`. Każda pozycja `
  • ` klikalna w całość → `data-href` + = `praca.get_absolute_url`; mały, delegowany JS: klik w `.autor-page__praca` + nawiguje do `data-href`, CHYBA że kliknięto wewnętrzny `` (np. DOI). Nie + zagnieżdżaj `` w `` (opis_bibliograficzny_cache zawiera własne linki). +- Skracanie: kontener opisu z CSS line-clamp (~3 linie, overflow hidden, + `text-overflow: ellipsis` / `-webkit-line-clamp`). Cała pozycja i tak klikalna + do pełnych szczegółów. + +### 3.5. Wykresy (liniowy/słupkowy) + +- Wspólny partial `browse/autor_sekcje/_wykres_lata.html`: dane = lista + `(rok, wartosc)` + `maks`. Jeśli `len(dane) > 10` → SVG `` (liniowy), + inaczej słupki (jak obecnie). Bezzależnościowo (czysty SVG/HTML). +- Buildery w `profil_autora_dane.py`: + - `_wykres_lata` (jest) — liczba prac/rok. + - `_wykres_pk_lata` (NOWY) — `prace_autora` grupuj po `rok`, suma `punkty_kbn`. + - `_wykres_if_lata` (NOWY) — suma `impact_factor`/rok; **return None gdy suma=0**. + Histogramy: pobierz pary `(id, rok, wartosc)` z `values_list` (DISTINCT z `id` + neutralizuje duplikaty join `autorzy`), sumuj w Pythonie. +- Sekcje `wykres_pk_lata`, `wykres_if_lata` w rejestrze + szablony korzystają + z `_wykres_lata.html` (przekaż `dane`, `maks`, `etykieta`). + +### 3.6. Statystyki wg charakteru — klikalne → wyszukiwarka + +- `statystyki_charakter.html`: każdy wiersz = mały `
    ` z `autor=` + + `charakter_formalny=` (lub przycisk-link). Builder musi zwrócić też + `charakter_formalny_id` (nie tylko nazwę) — zmień `_statystyki_charakter` na + `values_list("id","charakter_formalny__id","charakter_formalny__nazwa")`. +- **DO WERYFIKACJI**: `BuildSearch` (`browse.py` ~632-691) obecnie obsługuje + `autor`, `typy`, `jednostka`, `rok`, `suggested-title`. Trzeba dodać obsługę + `charakter_formalny` → zmapować na multiseek query object dla charakteru + formalnego (sprawdź `bpp/multiseek_registry.py` — czy jest CharakterFormalny + QueryObject; jeśli nie, użyć `TypRekorduObject`/`charakter`). To jedyny + fragment wymagający rozpoznania przed kodowaniem. + +### 3.7. Historia zatrudnienia (lewa kolumna) + +- Metoda `Autor.historia_zatrudnienia()` → `Autor_Jednostka.objects.filter( + autor=self).select_related("jednostka","funkcja").order_by("-rozpoczal_prace")`. + (Model `Autor_Jednostka` w `bpp/models/autor.py` ~545: pola `jednostka`, + `rozpoczal_prace`, `zakonczyl_prace`, `funkcja`, `podstawowe_miejsce_pracy`.) +- Partial w lewej kolumnie: lista „Jednostka — od–do (funkcja)". Pominąć wiersze + bez dat lub pokazać „obecnie" gdy brak `zakonczyl_prace`. + +### 3.8. Self-service autora (Faza 2, zawężona) + +Autor edytuje TYLKO biogram (MD/HTML + live preview) i zdjęcie (upload+podgląd). +Brak edytora układu po stronie autora. Gate: zalogowany + `request.user.autor`. +Widok w „Mój profil" (`bpp:profil-uzytkownika`). + +## 4. Otwarte / do decyzji później + +- Edytor kafelkowy (drag-drop) układu prawej kolumny w `UczelniaAdmin` — MVP to + JSON w textarea; ładny kafelkowy UI to osobne zadanie. +- Eksport zbiorczy BibTeX/RIS — Faza 2. +- Próg „10 lat" i liczba linii line-clamp — wartości wstępne, do kalibracji + wizualnej. + +## 5. Po implementacji + +- `make baseline-update` **przy scalaniu** (migracje 0444 + 0445) — commit + `baseline-sql/baseline.sql` + `baseline.meta.json`. NIE w branchu równolegle. +- `grunt build` po zmianach SCSS (skompilowane CSS jest poza gitem — kontrakt + build-time; commituj tylko źródła SCSS). +- ruff/ruff-format + djLint przez pre-commit; CI „Lint changed files" odpala + ruff-format na zmienionym zakresie z `--exit-non-zero-on-fix` → KAŻDY nowy + plik .py musi być pre-formatowany (`uv run ruff format `), łącznie z + migracjami Django (mają długie linie). diff --git a/src/bpp/admin/autor.py b/src/bpp/admin/autor.py index 6708b4e5a..661ee9a65 100644 --- a/src/bpp/admin/autor.py +++ b/src/bpp/admin/autor.py @@ -1,6 +1,7 @@ from dal import autocomplete from django import forms from django.contrib import admin +from django.core.files.uploadedfile import UploadedFile from dynamic_admin_columns.mixins import DynamicColumnsMixin from bpp.admin.helpers.djangoql import BppDjangoQLSearchMixin @@ -14,7 +15,9 @@ Autor_Jednostka, Dyscyplina_Naukowa, Jednostka, + WybranaPublikacjaAutora, ) +from ..util.obrazy import MAKS_ROZMIAR_PLIKU_ZDJECIA, przetworz_zdjecie_autora from .actions import ustaw_pokazuj_false, ustaw_pokazuj_true from .core import BaseBppAdminMixin from .filters import ( @@ -177,6 +180,10 @@ class Meta: "zmarl", "opis", "pokazuj_opis", + "zdjecie", + "biogram", + "biogram_format", + "uklad_profilu", "poprzednie_nazwiska", "pokazuj_poprzednie_nazwiska", "orcid", @@ -188,6 +195,28 @@ class Meta: ] widgets = {"imiona": CHARMAP_SINGLE_LINE, "nazwisko": CHARMAP_SINGLE_LINE} + def clean_zdjecie(self): + """Waliduj rozmiar i przeskaluj świeżo wgrane zdjęcie do kwadratu WebP. + + Istniejący (niezmieniony) plik przechodzi bez przetwarzania. + """ + plik = self.cleaned_data.get("zdjecie") + if not isinstance(plik, UploadedFile): + return plik + if plik.size > MAKS_ROZMIAR_PLIKU_ZDJECIA: + raise forms.ValidationError("Maksymalny rozmiar pliku zdjęcia to 5 MB.") + return przetworz_zdjecie_autora(plik, nazwa=plik.name) + + +class WybranaPublikacjaAutoraInline(admin.TabularInline): + # Relacja do Autora to zwykły FK `autor`; content_type+object_id wskazują + # polimorficzną publikację (GenericForeignKey). W Fazie 1 edycja ręczna; + # przyjazny picker dostarcza self-service edytor z Fazy 2. + model = WybranaPublikacjaAutora + fk_name = "autor" + extra = 0 + fields = ["content_type", "object_id", "kolejnosc"] + class AutorAdmin( BppDjangoQLSearchMixin, @@ -249,6 +278,7 @@ class AutorAdmin( Autor_DyscyplinaInline, Autor_AbsencjaInline, IloscUdzialowDlaAutoraZaRokInline, + WybranaPublikacjaAutoraInline, ] list_filter = [ JednostkaFilter, @@ -313,6 +343,20 @@ class AutorAdmin( ), }, ), + ( + "Profil na podstronie autora", + { + "classes": ("grp-collapse grp-closed",), + "fields": ( + "zdjecie", + "biogram", + "biogram_format", + "opis", + "pokazuj_opis", + "uklad_profilu", + ), + }, + ), ADNOTACJE_FIELDSET, ) diff --git a/src/bpp/migrations/0444_autor_biogram_autor_biogram_format_and_more.py b/src/bpp/migrations/0444_autor_biogram_autor_biogram_format_and_more.py new file mode 100644 index 000000000..972a5c525 --- /dev/null +++ b/src/bpp/migrations/0444_autor_biogram_autor_biogram_format_and_more.py @@ -0,0 +1,96 @@ +# Generated by Django 5.2.15 on 2026-06-18 20:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("bpp", "0443_drop_pl_PL_collation"), + ("contenttypes", "0002_remove_content_type_name"), + ] + + operations = [ + migrations.AddField( + model_name="autor", + name="biogram", + field=models.TextField( + blank=True, + default="", + help_text="Notka biograficzna pokazywana na podstronie autora.", + verbose_name="Biogram", + ), + ), + migrations.AddField( + model_name="autor", + name="biogram_format", + field=models.CharField( + choices=[("md", "Markdown"), ("html", "HTML")], + default="md", + max_length=4, + verbose_name="Format biogramu", + ), + ), + migrations.AddField( + model_name="autor", + name="uklad_profilu", + field=models.JSONField( + blank=True, + default=None, + help_text="Kolejność, widoczność i limity sekcji podstrony autora. Puste = układ domyślny.", + null=True, + verbose_name="Układ profilu", + ), + ), + migrations.AddField( + model_name="autor", + name="zdjecie", + field=models.ImageField( + blank=True, + help_text="Zdjęcie profilowe autora. Przy zapisie przez formularz jest przeskalowane do kwadratu.", + null=True, + upload_to="autor_zdjecia", + verbose_name="Zdjęcie", + ), + ), + migrations.CreateModel( + name="WybranaPublikacjaAutora", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("object_id", models.PositiveIntegerField()), + ( + "kolejnosc", + models.PositiveIntegerField(default=0, verbose_name="Kolejność"), + ), + ( + "autor", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="wybrane_publikacje", + to="bpp.autor", + ), + ), + ( + "content_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + ), + ), + ], + options={ + "verbose_name": "wybrana publikacja autora", + "verbose_name_plural": "wybrane publikacje autora", + "ordering": ("autor", "kolejnosc"), + "unique_together": {("autor", "content_type", "object_id")}, + }, + ), + ] diff --git a/src/bpp/models/__init__.py b/src/bpp/models/__init__.py index 7d57ab66c..0a9f9d4fe 100644 --- a/src/bpp/models/__init__.py +++ b/src/bpp/models/__init__.py @@ -1,6 +1,7 @@ # Zaimportujmy wszystko from .abstract import * # noqa from .autor import * # noqa +from .wybrana_publikacja import * # noqa from .cache import * # noqa from .dyscyplina_naukowa import * # noqa from .grant import * # noqa diff --git a/src/bpp/models/autor.py b/src/bpp/models/autor.py index 6ebeb53e3..5f7d1c816 100644 --- a/src/bpp/models/autor.py +++ b/src/bpp/models/autor.py @@ -14,6 +14,7 @@ from django.db.models import CASCADE, SET_NULL, Count, Q, Sum, UniqueConstraint from django.urls.base import reverse from django.utils import timezone +from django.utils.functional import cached_property from tinymce.models import HTMLField from bpp import const @@ -21,6 +22,7 @@ from bpp.models import LinkDoPBNMixin, ModelZAdnotacjami, ModelZNazwa, NazwaISkrot from bpp.models.abstract import ModelZPBN_ID from bpp.util import FulltextSearchMixin +from bpp.util.biogram import FORMAT_MARKDOWN, FORMATY_BIOGRAMU class Tytul(NazwaISkrot): @@ -130,6 +132,35 @@ class Autor(LinkDoPBNMixin, ModelZAdnotacjami, ModelZPBN_ID): pokazuj_opis = models.BooleanField( default=False, help_text="""Czy pokazywać tekst z pola 'Opis' na stronie?""" ) + + zdjecie = models.ImageField( + "Zdjęcie", + upload_to="autor_zdjecia", + blank=True, + null=True, + help_text="Zdjęcie profilowe autora. Przy zapisie przez formularz " + "jest przeskalowane do kwadratu.", + ) + biogram = models.TextField( + "Biogram", + blank=True, + default="", + help_text="Notka biograficzna pokazywana na podstronie autora.", + ) + biogram_format = models.CharField( + "Format biogramu", + max_length=4, + choices=FORMATY_BIOGRAMU, + default=FORMAT_MARKDOWN, + ) + uklad_profilu = models.JSONField( + "Układ profilu", + blank=True, + null=True, + default=None, + help_text="Kolejność, widoczność i limity sekcji podstrony autora. " + "Puste = układ domyślny.", + ) poprzednie_nazwiska = models.CharField( max_length=1024, blank=True, @@ -208,6 +239,13 @@ class Autor(LinkDoPBNMixin, ModelZAdnotacjami, ModelZPBN_ID): def get_absolute_url(self): return reverse("bpp:browse_autor", args=(self.slug,)) + @cached_property + def biogram_html(self): + """Bezpieczny HTML biogramu (Markdown/HTML wg ``biogram_format``).""" + from bpp.util.biogram import renderuj_biogram + + return renderuj_biogram(self.biogram, self.biogram_format) + def czy_pokazywac_siec_powiazan(self, uczelnia): """Efektywne ustawienie "pokazuj sieć powiązań" dla tego autora. diff --git a/src/bpp/models/wybrana_publikacja.py b/src/bpp/models/wybrana_publikacja.py new file mode 100644 index 000000000..9ffc4e078 --- /dev/null +++ b/src/bpp/models/wybrana_publikacja.py @@ -0,0 +1,34 @@ +"""Wybrane (wyróżnione) publikacje autora — ręcznie wskazane prace. + +Publikacje w BPP są polimorficzne (Wydawnictwo_Zwarte / Wydawnictwo_Ciagle / +Patent / Praca_*), więc wskazujemy je przez GenericForeignKey +(``content_type`` + ``object_id``) — tak samo jak identyfikuje je cache +``Rekord``. +""" + +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.db.models import CASCADE + +from bpp.models.autor import Autor + +__all__ = ["WybranaPublikacjaAutora"] + + +class WybranaPublikacjaAutora(models.Model): + autor = models.ForeignKey(Autor, CASCADE, related_name="wybrane_publikacje") + content_type = models.ForeignKey(ContentType, CASCADE) + object_id = models.PositiveIntegerField() + publikacja = GenericForeignKey("content_type", "object_id") + kolejnosc = models.PositiveIntegerField("Kolejność", default=0) + + class Meta: + app_label = "bpp" + verbose_name = "wybrana publikacja autora" + verbose_name_plural = "wybrane publikacje autora" + ordering = ("autor", "kolejnosc") + unique_together = [("autor", "content_type", "object_id")] + + def __str__(self): + return f"{self.autor}: {self.publikacja}" diff --git a/src/bpp/profil_autora.py b/src/bpp/profil_autora.py new file mode 100644 index 000000000..4399d976f --- /dev/null +++ b/src/bpp/profil_autora.py @@ -0,0 +1,155 @@ +"""Profil autora: rejestr sekcji podstrony + rozwiązywanie układu. + +Katalog typów sekcji żyje w kodzie (``KATALOG_SEKCJI``); per-autor pole +``Autor.uklad_profilu`` (JSON) trzyma jedynie kolejność, widoczność i limit +pozycji. Dzięki temu dodanie nowej sekcji to zmiana kodu, bez migracji danych — +``rozwiaz_uklad`` dokleja sekcje nieobecne w zapisanej konfiguracji z ich +domyślnymi ustawieniami. + +Ten moduł jest czysty (nie importuje modeli), więc można go bezpiecznie używać +w formularzach i adminie. Budowanie danych sekcji (zapytania) jest w +``bpp.profil_autora_dane``. +""" + +from dataclasses import dataclass + +# --- Klucze sekcji --------------------------------------------------------- + +KLUCZ_BIOGRAM = "biogram" +KLUCZ_WYSZUKIWARKA = "wyszukiwarka" +KLUCZ_NAJLEPSZE_PK = "najlepsze_pk" +KLUCZ_NAJLEPSZE_IF = "najlepsze_if" +KLUCZ_NAJNOWSZE_ARTYKULY = "najnowsze_artykuly" +KLUCZ_NAJNOWSZE_ZWARTE = "najnowsze_zwarte" +KLUCZ_OSTATNIO_EDYTOWANE = "ostatnio_edytowane" +KLUCZ_WYBRANE_PUBLIKACJE = "wybrane_publikacje" +KLUCZ_STATYSTYKI_CHARAKTER = "statystyki_charakter" +KLUCZ_WYKRES_LATA = "wykres_lata" +KLUCZ_PUNKTY_LATA = "punkty_lata" +KLUCZ_DYSCYPLINY = "dyscypliny" +KLUCZ_ZRODLA = "zrodla" +KLUCZ_WSPOLAUTORZY = "wspolautorzy" +KLUCZ_EKSPORT = "eksport" + +# --- Limity list ----------------------------------------------------------- + +DOZWOLONE_LIMITY = (10, 20, 30, 50) +DOMYSLNY_LIMIT = 10 + + +@dataclass(frozen=True) +class TypSekcji: + klucz: str + nazwa: str + obowiazkowa: bool = False + ma_limit: bool = False + domyslnie_widoczna: bool = True + + @property + def template(self): + return f"browse/autor_sekcje/{self.klucz}.html" + + +# Kanoniczny porządek domyślny (wariant "biogram-najpierw"); sekcje-sugestie +# tanie/efektowne (wykres lat, współautorzy) domyślnie ON, reszta OFF. +KATALOG_SEKCJI = ( + TypSekcji(KLUCZ_BIOGRAM, "Biogram"), + TypSekcji(KLUCZ_WYSZUKIWARKA, "Wyszukiwarka prac", obowiazkowa=True), + TypSekcji(KLUCZ_NAJLEPSZE_PK, "Najlepsze prace (punkty MNiSW)", ma_limit=True), + TypSekcji(KLUCZ_NAJLEPSZE_IF, "Najlepsze prace (Impact Factor)", ma_limit=True), + TypSekcji(KLUCZ_NAJNOWSZE_ARTYKULY, "Najnowsze artykuły", ma_limit=True), + TypSekcji(KLUCZ_NAJNOWSZE_ZWARTE, "Najnowsze książki / rozdziały", ma_limit=True), + TypSekcji(KLUCZ_OSTATNIO_EDYTOWANE, "Ostatnio edytowane", ma_limit=True), + TypSekcji(KLUCZ_WYBRANE_PUBLIKACJE, "Wybrane publikacje", domyslnie_widoczna=False), + TypSekcji(KLUCZ_STATYSTYKI_CHARAKTER, "Statystyki wg charakteru"), + TypSekcji(KLUCZ_WYKRES_LATA, "Publikacje w latach"), + TypSekcji(KLUCZ_PUNKTY_LATA, "Punkty / sloty w latach", domyslnie_widoczna=False), + TypSekcji(KLUCZ_DYSCYPLINY, "Udział dyscyplin", domyslnie_widoczna=False), + TypSekcji( + KLUCZ_ZRODLA, "Najczęstsze źródła", ma_limit=True, domyslnie_widoczna=False + ), + TypSekcji(KLUCZ_WSPOLAUTORZY, "Najczęstsi współautorzy", ma_limit=True), + TypSekcji(KLUCZ_EKSPORT, "Eksport publikacji", domyslnie_widoczna=False), +) + +KATALOG_WG_KLUCZA = {t.klucz: t for t in KATALOG_SEKCJI} + + +def domyslny_uklad(): + """Zwróć domyślny układ (lista pozycji) — wszystkie sekcje katalogu.""" + return [ + { + "klucz": t.klucz, + "widoczna": t.domyslnie_widoczna, + "limit": DOMYSLNY_LIMIT if t.ma_limit else None, + } + for t in KATALOG_SEKCJI + ] + + +def waliduj_uklad(dane): + """Oczyść surową konfigurację z JSON-a do listy poprawnych pozycji. + + Odrzuca nieznane i zduplikowane klucze, wymusza widoczność sekcji + obowiązkowych, koryguje limit do dozwolonego (lub ``None`` dla sekcji bez + limitu). Zachowuje kolejność wejścia. + """ + if not dane: + return [] + + wynik = [] + widziane = set() + for pozycja in dane: + if not isinstance(pozycja, dict): + continue + klucz = pozycja.get("klucz") + typ = KATALOG_WG_KLUCZA.get(klucz) + if typ is None or klucz in widziane: + continue + widziane.add(klucz) + + widoczna = True if typ.obowiazkowa else bool(pozycja.get("widoczna", True)) + + if typ.ma_limit: + limit = pozycja.get("limit") + if limit not in DOZWOLONE_LIMITY: + limit = DOMYSLNY_LIMIT + else: + limit = None + + wynik.append({"klucz": klucz, "widoczna": widoczna, "limit": limit}) + + return wynik + + +def rozwiaz_uklad(autor): + """Zwróć listę WIDOCZNYCH sekcji do wyrenderowania, w docelowej kolejności. + + Łączy zapisaną konfigurację autora z katalogiem: sekcje nieobecne w + konfiguracji dokleja na końcu z domyślnymi ustawieniami (forward-compat dla + nowo dodanych typów sekcji). Każda pozycja wynikowa zawiera ``klucz``, + ``nazwa``, ``template`` i ``limit``. + """ + zapisany = waliduj_uklad(getattr(autor, "uklad_profilu", None)) + klucze_zapisane = {p["klucz"] for p in zapisany} + + domyslne_wg_klucza = {p["klucz"]: p for p in domyslny_uklad()} + pozycje = list(zapisany) + for typ in KATALOG_SEKCJI: + if typ.klucz not in klucze_zapisane: + pozycje.append(domyslne_wg_klucza[typ.klucz]) + + wynik = [] + for pozycja in pozycje: + if not pozycja["widoczna"]: + continue + typ = KATALOG_WG_KLUCZA[pozycja["klucz"]] + wynik.append( + { + "klucz": typ.klucz, + "nazwa": typ.nazwa, + "template": typ.template, + "limit": pozycja["limit"], + } + ) + return wynik diff --git a/src/bpp/profil_autora_dane.py b/src/bpp/profil_autora_dane.py new file mode 100644 index 000000000..46ddb22f1 --- /dev/null +++ b/src/bpp/profil_autora_dane.py @@ -0,0 +1,240 @@ +"""Budowanie danych poszczególnych sekcji profilu autora. + +Każdy builder dostaje (autor, limit, request) i zwraca słownik z danymi dla +szablonu albo ``None``, gdy sekcja jest pusta (wtedy zostaje automatycznie +ukryta, nawet jeśli włączona w konfiguracji). Modele importujemy leniwie, żeby +ten moduł dało się zaimportować wcześnie (np. w widoku). + +Uwaga dot. ``Rekord.objects.prace_autora`` — queryset robi +``filter(autorzy__autor=...).distinct()``. Dla list (order_by + slice) join na +``autorzy`` zwija się przez DISTINCT (wszystkie kolumny pochodzą z rekordu). +Dla histogramów pobieramy pary ``(id, X)`` — włączenie unikalnego ``id`` do +DISTINCT neutralizuje duplikaty z joinu, a właściwy rozkład liczymy w Pythonie +(zbiór prac jednego autora jest ograniczony). +""" + +from collections import Counter + +from django.db.models import Q, Sum + +from bpp import const +from bpp.profil_autora import ( + KLUCZ_BIOGRAM, + KLUCZ_DYSCYPLINY, + KLUCZ_EKSPORT, + KLUCZ_NAJLEPSZE_IF, + KLUCZ_NAJLEPSZE_PK, + KLUCZ_NAJNOWSZE_ARTYKULY, + KLUCZ_NAJNOWSZE_ZWARTE, + KLUCZ_OSTATNIO_EDYTOWANE, + KLUCZ_PUNKTY_LATA, + KLUCZ_STATYSTYKI_CHARAKTER, + KLUCZ_WSPOLAUTORZY, + KLUCZ_WYBRANE_PUBLIKACJE, + KLUCZ_WYKRES_LATA, + KLUCZ_WYSZUKIWARKA, + KLUCZ_ZRODLA, +) + +_SELECT_RELATED = ("charakter_formalny", "zrodlo", "wydawca") + + +def _lista(qs, limit): + prace = list(qs.select_related(*_SELECT_RELATED)[:limit]) + return {"prace": prace} if prace else None + + +def _prace(autor): + from bpp.models import Rekord + + return Rekord.objects.prace_autora(autor) + + +# --- buildery sekcji ------------------------------------------------------- + + +def _biogram(autor, limit, request): + html = autor.biogram_html + return {"html": html} if html else None + + +def _wyszukiwarka(autor, limit, request): + # Sekcja obowiązkowa — zawsze widoczna, szablon renderuje istniejący + # formularz wyszukiwania prac autora. + return {} + + +def _najlepsze_pk(autor, limit, request): + return _lista( + _prace(autor).filter(punkty_kbn__gt=0).order_by("-punkty_kbn", "-rok"), limit + ) + + +def _najlepsze_if(autor, limit, request): + return _lista( + _prace(autor).filter(impact_factor__gt=0).order_by("-impact_factor", "-rok"), + limit, + ) + + +def _najnowsze_artykuly(autor, limit, request): + return _lista( + _prace(autor) + .filter(charakter_formalny__charakter_ogolny=const.CHARAKTER_OGOLNY_ARTYKUL) + .order_by("-rok"), + limit, + ) + + +def _najnowsze_zwarte(autor, limit, request): + return _lista( + _prace(autor) + .filter( + charakter_formalny__charakter_ogolny__in=[ + const.CHARAKTER_OGOLNY_KSIAZKA, + const.CHARAKTER_OGOLNY_ROZDZIAL, + ] + ) + .order_by("-rok"), + limit, + ) + + +def _ostatnio_edytowane(autor, limit, request): + return _lista(_prace(autor).order_by("-ostatnio_zmieniony"), limit) + + +def _wybrane_publikacje(autor, limit, request): + wybrane = list( + autor.wybrane_publikacje.select_related("content_type").order_by("kolejnosc") + ) + prace = [w.publikacja for w in wybrane if w.publikacja is not None] + return {"prace": prace} if prace else None + + +def _statystyki_charakter(autor, limit, request): + pary = _prace(autor).values_list("id", "charakter_formalny__nazwa") + licznik = Counter(nazwa for _id, nazwa in pary if nazwa) + if not licznik: + return None + wiersze = sorted(licznik.items(), key=lambda x: (-x[1], x[0])) + return {"wiersze": wiersze, "suma": sum(licznik.values())} + + +def _wykres_lata(autor, limit, request): + pary = _prace(autor).values_list("id", "rok") + licznik = Counter(rok for _id, rok in pary if rok is not None) + if not licznik: + return None + dane = sorted(licznik.items()) + maks = max(licznik.values()) + return {"dane": dane, "maks": maks} + + +def _punkty_lata(autor, limit, request): + from bpp.models.cache.punktacja import Cache_Punktacja_Autora_Query + + wiersze = list( + Cache_Punktacja_Autora_Query.objects.filter(autor=autor) + .values("rekord__rok") + .annotate(pkd=Sum("pkdaut"), sloty=Sum("slot")) + .order_by("rekord__rok") + ) + return {"wiersze": wiersze} if wiersze else None + + +def _dyscypliny(autor, limit, request): + from bpp.models import Autorzy + + pary = ( + Autorzy.objects.filter(autor=autor) + .exclude(dyscyplina_naukowa=None) + .values_list("rekord_id", "dyscyplina_naukowa__nazwa") + .distinct() + ) + licznik = Counter(nazwa for _r, nazwa in pary if nazwa) + if not licznik: + return None + wiersze = sorted(licznik.items(), key=lambda x: (-x[1], x[0])) + return {"wiersze": wiersze, "suma": sum(licznik.values())} + + +def _zrodla(autor, limit, request): + pary = _prace(autor).exclude(zrodlo=None).values_list("id", "zrodlo__nazwa") + licznik = Counter(nazwa for _id, nazwa in pary if nazwa) + if not licznik: + return None + wiersze = sorted(licznik.items(), key=lambda x: (-x[1], x[0]))[:limit] + return {"wiersze": wiersze} + + +def _wspolautorzy(autor, limit, request): + from powiazania_autorow.models import AuthorConnection + + polaczenia = ( + AuthorConnection.objects.filter( + Q(primary_author=autor) | Q(secondary_author=autor) + ) + .select_related("primary_author", "secondary_author") + .order_by("-shared_publications_count")[:limit] + ) + wspolautorzy = [] + for p in polaczenia: + inny = ( + p.secondary_author if p.primary_author_id == autor.pk else p.primary_author + ) + wspolautorzy.append({"autor": inny, "liczba": p.shared_publications_count}) + return {"wspolautorzy": wspolautorzy} if wspolautorzy else None + + +def _eksport(autor, limit, request): + # Eksport zbiorczy (BibTeX/RIS) dostarcza Faza 2; w Fazie 1 sekcja jest + # domyślnie wyłączona i nie renderuje treści. + return None + + +_BUILDERY = { + KLUCZ_BIOGRAM: _biogram, + KLUCZ_WYSZUKIWARKA: _wyszukiwarka, + KLUCZ_NAJLEPSZE_PK: _najlepsze_pk, + KLUCZ_NAJLEPSZE_IF: _najlepsze_if, + KLUCZ_NAJNOWSZE_ARTYKULY: _najnowsze_artykuly, + KLUCZ_NAJNOWSZE_ZWARTE: _najnowsze_zwarte, + KLUCZ_OSTATNIO_EDYTOWANE: _ostatnio_edytowane, + KLUCZ_WYBRANE_PUBLIKACJE: _wybrane_publikacje, + KLUCZ_STATYSTYKI_CHARAKTER: _statystyki_charakter, + KLUCZ_WYKRES_LATA: _wykres_lata, + KLUCZ_PUNKTY_LATA: _punkty_lata, + KLUCZ_DYSCYPLINY: _dyscypliny, + KLUCZ_ZRODLA: _zrodla, + KLUCZ_WSPOLAUTORZY: _wspolautorzy, + KLUCZ_EKSPORT: _eksport, +} + + +def przygotuj_sekcje(autor, request=None): + """Zwróć listę sekcji do wyrenderowania (z danymi), z pominięciem pustych. + + Każdy element: ``{"klucz", "nazwa", "template", "dane"}``. ``dane`` to + słownik zwrócony przez builder. Sekcje, których builder zwrócił ``None``, + są pomijane (auto-ukrywanie pustych). + """ + from bpp.profil_autora import rozwiaz_uklad + + sekcje = [] + for s in rozwiaz_uklad(autor): + builder = _BUILDERY.get(s["klucz"]) + if builder is None: + continue + dane = builder(autor, s["limit"], request) + if dane is None: + continue + sekcje.append( + { + "klucz": s["klucz"], + "nazwa": s["nazwa"], + "template": s["template"], + "dane": dane, + } + ) + return sekcje diff --git a/src/bpp/static/scss/_autor-bem.scss b/src/bpp/static/scss/_autor-bem.scss index c1ad437f3..eb092d17d 100644 --- a/src/bpp/static/scss/_autor-bem.scss +++ b/src/bpp/static/scss/_autor-bem.scss @@ -315,4 +315,113 @@ &__icon--white { color: white; } + + // --- Profil: zdjęcie/awatar w nagłówku --- + &__avatar { + width: 120px; + height: 120px; + object-fit: cover; + border-radius: 8px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2); + } + + // --- Profil: linki do raportu autora --- + &__raport-links { + margin: 1rem 0; + } + + &__raport-links-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + // --- Profil: sekcje treści --- + &__sekcja { + margin-bottom: 1rem; + } + + &__lista-prac { + line-height: 1.7; + + li { + margin-bottom: 0.6rem; + } + } + + &__praca-link { + font-size: 0.85em; + white-space: nowrap; + } + + &__biogram-tresc { + line-height: 1.6; + } + + // Tabele statystyk (charakter / dyscypliny / źródła / punkty) + &__staty { + width: auto; + min-width: 320px; + + td, + th { + padding: 4px 12px; + } + + td:last-child, + th:last-child { + text-align: right; + } + } + + // Wykres słupkowy publikacji w latach + &__wykres-lata { + display: flex; + align-items: flex-end; + flex-wrap: wrap; + gap: 6px; + padding-top: 10px; + } + + &__slupek { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + min-width: 34px; + } + + &__slupek-bar { + width: 22px; + min-height: 2px; + background: #2c3e50; + border-radius: 2px 2px 0 0; + } + + &__slupek-rok { + font-size: 0.7rem; + margin-top: 3px; + transform: rotate(-45deg); + } + + &__slupek-liczba { + font-size: 0.7rem; + color: #555; + } + + // Lista współautorów + &__wspolautorzy { + list-style: none; + margin-left: 0; + columns: 2; + + @media screen and (max-width: 39.99875em) { + columns: 1; + } + } + + &__wspolautor-liczba { + color: #777; + font-size: 0.85em; + } } diff --git a/src/bpp/templates/browse/autor.html b/src/bpp/templates/browse/autor.html index 0c285acfb..2672ca1a4 100644 --- a/src/bpp/templates/browse/autor.html +++ b/src/bpp/templates/browse/autor.html @@ -13,6 +13,13 @@ {% block content %}
    + {% if autor.zdjecie %} +
    + {{ autor }} +
    + {% endif %}

    {{ autor }}

    {% if autor.aktualna_funkcja.pokazuj_za_nazwiskiem %} @@ -243,122 +250,27 @@

    Cytowania

    - -
    - +
    +{% endif %} + +{# Konfigurowalne sekcje profilu (kolejność/widoczność/limit wg #} +{# Autor.uklad_profilu albo układ domyślny). Puste sekcje są pomijane. #} +{% for sekcja in sekcje_profilu %} + {% include sekcja.template with dane=sekcja.dane nazwa=sekcja.nazwa klucz=sekcja.klucz %} +{% endfor %}
    diff --git a/src/bpp/templates/browse/autor_sekcje/_lista_prac.html b/src/bpp/templates/browse/autor_sekcje/_lista_prac.html new file mode 100644 index 000000000..020c28c39 --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/_lista_prac.html @@ -0,0 +1,14 @@ +{# Współdzielony render listy prac (Rekord). Oczekuje: nazwa, dane.prace. #} +
    +

    {{ nazwa }}

    +
      + {% for praca in dane.prace %} +
    1. + {{ praca.opis_bibliograficzny_cache|safe }} + [szczegóły] +
    2. + {% endfor %} +
    +
    +
    diff --git a/src/bpp/templates/browse/autor_sekcje/biogram.html b/src/bpp/templates/browse/autor_sekcje/biogram.html new file mode 100644 index 000000000..51eac0dbb --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/biogram.html @@ -0,0 +1,6 @@ +{# Biogram autora — dane.html to już zsanityzowany HTML. #} +
    +

    {{ nazwa }}

    +
    {{ dane.html|safe }}
    +
    +
    diff --git a/src/bpp/templates/browse/autor_sekcje/dyscypliny.html b/src/bpp/templates/browse/autor_sekcje/dyscypliny.html new file mode 100644 index 000000000..9c9c48fe5 --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/dyscypliny.html @@ -0,0 +1,18 @@ +{# Udział dyscyplin naukowych. Oczekuje: dane.wiersze, dane.suma. #} +
    +

    {{ nazwa }}

    + + + + + + {% for nazwa_dyscypliny, liczba in dane.wiersze %} + + {% endfor %} + + + + +
    DyscyplinaLiczba prac
    {{ nazwa_dyscypliny }}{{ liczba }}
    Razem{{ dane.suma }}
    +
    +
    diff --git a/src/bpp/templates/browse/autor_sekcje/eksport.html b/src/bpp/templates/browse/autor_sekcje/eksport.html new file mode 100644 index 000000000..52f94d66a --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/eksport.html @@ -0,0 +1,2 @@ +{# Eksport listy publikacji. Faza 1: builder zwraca None (sekcja się nie #} +{# renderuje); eksport zbiorczy BibTeX/RIS dostarcza Faza 2. #} diff --git a/src/bpp/templates/browse/autor_sekcje/najlepsze_if.html b/src/bpp/templates/browse/autor_sekcje/najlepsze_if.html new file mode 100644 index 000000000..80cecc531 --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/najlepsze_if.html @@ -0,0 +1 @@ +{% include "browse/autor_sekcje/_lista_prac.html" %} diff --git a/src/bpp/templates/browse/autor_sekcje/najlepsze_pk.html b/src/bpp/templates/browse/autor_sekcje/najlepsze_pk.html new file mode 100644 index 000000000..80cecc531 --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/najlepsze_pk.html @@ -0,0 +1 @@ +{% include "browse/autor_sekcje/_lista_prac.html" %} diff --git a/src/bpp/templates/browse/autor_sekcje/najnowsze_artykuly.html b/src/bpp/templates/browse/autor_sekcje/najnowsze_artykuly.html new file mode 100644 index 000000000..80cecc531 --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/najnowsze_artykuly.html @@ -0,0 +1 @@ +{% include "browse/autor_sekcje/_lista_prac.html" %} diff --git a/src/bpp/templates/browse/autor_sekcje/najnowsze_zwarte.html b/src/bpp/templates/browse/autor_sekcje/najnowsze_zwarte.html new file mode 100644 index 000000000..80cecc531 --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/najnowsze_zwarte.html @@ -0,0 +1 @@ +{% include "browse/autor_sekcje/_lista_prac.html" %} diff --git a/src/bpp/templates/browse/autor_sekcje/ostatnio_edytowane.html b/src/bpp/templates/browse/autor_sekcje/ostatnio_edytowane.html new file mode 100644 index 000000000..80cecc531 --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/ostatnio_edytowane.html @@ -0,0 +1 @@ +{% include "browse/autor_sekcje/_lista_prac.html" %} diff --git a/src/bpp/templates/browse/autor_sekcje/punkty_lata.html b/src/bpp/templates/browse/autor_sekcje/punkty_lata.html new file mode 100644 index 000000000..61fb6bfd4 --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/punkty_lata.html @@ -0,0 +1,19 @@ +{# Punkty / sloty autora w latach. Oczekuje: dane.wiersze (rekord__rok, pkd, sloty). #} +
    +

    {{ nazwa }}

    + + + + + + {% for w in dane.wiersze %} + + + + + + {% endfor %} + +
    RokPunkty (PKdAut)Sloty
    {{ w.rekord__rok }}{{ w.pkd|floatformat:2 }}{{ w.sloty|floatformat:4 }}
    +
    +
    diff --git a/src/bpp/templates/browse/autor_sekcje/statystyki_charakter.html b/src/bpp/templates/browse/autor_sekcje/statystyki_charakter.html new file mode 100644 index 000000000..3b40a90bd --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/statystyki_charakter.html @@ -0,0 +1,18 @@ +{# Liczba prac wg charakteru formalnego. Oczekuje: dane.wiersze, dane.suma. #} +
    +

    {{ nazwa }}

    + + + + + + {% for nazwa_charakteru, liczba in dane.wiersze %} + + {% endfor %} + + + + +
    Charakter formalnyLiczba prac
    {{ nazwa_charakteru }}{{ liczba }}
    Razem{{ dane.suma }}
    +
    +
    diff --git a/src/bpp/templates/browse/autor_sekcje/wspolautorzy.html b/src/bpp/templates/browse/autor_sekcje/wspolautorzy.html new file mode 100644 index 000000000..c29fd1cf7 --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/wspolautorzy.html @@ -0,0 +1,19 @@ +{# Najczęstsi współautorzy + CTA do wizualnej sieci powiązań (gdy dostępna). #} +
    +

    {{ nazwa }}

    +
      + {% for w in dane.wspolautorzy %} +
    • + {{ w.autor }} + ({{ w.liczba }}) +
    • + {% endfor %} +
    + {% if ma_powiazania %} + + Zobacz pełną sieć powiązań + + {% endif %} +
    +
    diff --git a/src/bpp/templates/browse/autor_sekcje/wybrane_publikacje.html b/src/bpp/templates/browse/autor_sekcje/wybrane_publikacje.html new file mode 100644 index 000000000..80cecc531 --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/wybrane_publikacje.html @@ -0,0 +1 @@ +{% include "browse/autor_sekcje/_lista_prac.html" %} diff --git a/src/bpp/templates/browse/autor_sekcje/wykres_lata.html b/src/bpp/templates/browse/autor_sekcje/wykres_lata.html new file mode 100644 index 000000000..6360a7e46 --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/wykres_lata.html @@ -0,0 +1,15 @@ +{# Wykres słupkowy publikacji w latach. Oczekuje: dane.dane (lista (rok, liczba)), dane.maks. #} +
    +

    {{ nazwa }}

    +
    + {% for rok, liczba in dane.dane %} +
    +
    {{ liczba }}
    +
    +
    {{ rok }}
    +
    + {% endfor %} +
    +
    +
    diff --git a/src/bpp/templates/browse/autor_sekcje/wyszukiwarka.html b/src/bpp/templates/browse/autor_sekcje/wyszukiwarka.html new file mode 100644 index 000000000..aa61a0790 --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/wyszukiwarka.html @@ -0,0 +1,114 @@ +{# Sekcja obowiązkowa: formularz wyszukiwania prac autora (POST → multiseek). #} +
    +
    +

    + Wyszukaj publikacje autora +

    +

    + Znajdź publikacje powiązane z autorem {{ autor }} +

    +
    + +
    + {% csrf_token %} + + + +
    +
    +
    + Typ publikacji: +
    + {% include "browse/typy.html" %} +
    +
    + + {% if autor.jednostki_gdzie_ma_publikacje %} +
    +
    +
    + Opracowane w jednostkach: +
    +
    + {% for jednostka in autor.jednostki_gdzie_ma_publikacje %} +
    + + +
    + {% endfor %} +
    +
    +
    + {% endif %} + +
    +
    +
    + Opracowane w latach: +
    +
    + {% for rok in autor.prace_w_latach reversed %} +
    + + +
    + {% endfor %} +
    +
    +
    + +
    +
    +
    + Tytuł raportu: +
    +
    +
    + +
    +
    + + Tytuł wyszukiwania możesz zmienić, klikając go dwukrotnie. + +
    +
    + +
    + + {% if ma_powiazania %} + + Zobacz sieć powiązań + + {% endif %} +
    +
    +
    +
    diff --git a/src/bpp/templates/browse/autor_sekcje/zrodla.html b/src/bpp/templates/browse/autor_sekcje/zrodla.html new file mode 100644 index 000000000..1dbdb0a01 --- /dev/null +++ b/src/bpp/templates/browse/autor_sekcje/zrodla.html @@ -0,0 +1,15 @@ +{# Najczęstsze źródła / czasopisma. Oczekuje: dane.wiersze. #} +
    +

    {{ nazwa }}

    + + + + + + {% for nazwa_zrodla, liczba in dane.wiersze %} + + {% endfor %} + +
    ŹródłoLiczba prac
    {{ nazwa_zrodla }}{{ liczba }}
    +
    +
    diff --git a/src/bpp/tests/test_profil/__init__.py b/src/bpp/tests/test_profil/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/bpp/tests/test_profil/test_biogram.py b/src/bpp/tests/test_profil/test_biogram.py new file mode 100644 index 000000000..78030ec15 --- /dev/null +++ b/src/bpp/tests/test_profil/test_biogram.py @@ -0,0 +1,62 @@ +"""Testy renderowania biogramu autora (Markdown/HTML → bezpieczny HTML). + +Biogram używa własnego, bogatszego zestawu dozwolonych tagów niż globalny +``safe_html`` (który jest celowo wąski) — autor potrzebuje akapitów, nagłówków, +list i linków. +""" + +from bpp.util.biogram import renderuj_biogram + + +def test_markdown_pogrubienie(): + assert "ala" in renderuj_biogram("**ala**", "md") + + +def test_markdown_naglowek_i_lista(): + out = renderuj_biogram("## Tytul\n\n- raz\n- dwa", "md") + assert "

    " in out + assert "
  • raz
  • " in out + + +def test_html_przepuszcza_dozwolone_tagi(): + out = renderuj_biogram("

    tekst pogrubiony

    ", "html") + assert "

    tekst pogrubiony

    " in out + + +def test_html_usuwa_skrypt_wraz_z_trescia(): + out = renderuj_biogram("

    ok

    ", "html") + assert "script" not in out + assert "alert(1)" not in out + assert "

    ok

    " in out + + +def test_markdown_usuwa_wstrzykniety_html(): + out = renderuj_biogram("**a** ", "md") + assert "script" not in out + assert "alert(1)" not in out + assert "a" in out + + +def test_link_dostaje_rel_nofollow(): + out = renderuj_biogram('x', "html") + assert 'href="http://example.com"' in out + assert "nofollow" in out + assert "noopener" in out + + +def test_link_javascript_wycina_schemat(): + out = renderuj_biogram('x', "html") + assert "javascript:" not in out + + +def test_pusty_biogram_daje_pusty_string(): + assert renderuj_biogram("", "md") == "" + assert renderuj_biogram(None, "html") == "" + + +def test_nieznany_format_traktowany_jak_html(): + # Bezpieczny fallback: nieznany format nie renderuje Markdowna, + # tylko sanityzuje wejście jako HTML. + out = renderuj_biogram("**x** ", "cokolwiek") + assert "script" not in out + assert "**x**" in out diff --git a/src/bpp/tests/test_profil/test_models.py b/src/bpp/tests/test_profil/test_models.py new file mode 100644 index 000000000..a90d92ea3 --- /dev/null +++ b/src/bpp/tests/test_profil/test_models.py @@ -0,0 +1,40 @@ +"""Testy pól profilu na modelu Autor oraz modelu WybranaPublikacjaAutora.""" + +import pytest +from django.contrib.contenttypes.models import ContentType +from model_bakery import baker + +from bpp.models import Autor + + +@pytest.mark.django_db +def test_autor_ma_domyslnie_pusty_uklad(): + autor = baker.make(Autor) + assert autor.uklad_profilu is None + + +@pytest.mark.django_db +def test_autor_biogram_html_renderuje_markdown(): + autor = baker.make(Autor, biogram="**x**", biogram_format="md") + assert "x" in autor.biogram_html + + +@pytest.mark.django_db +def test_autor_biogram_html_pusty_daje_pusty_string(): + autor = baker.make(Autor, biogram="", biogram_format="md") + assert autor.biogram_html == "" + + +@pytest.mark.django_db +def test_wybrana_publikacja_resolves_gfk(wydawnictwo_zwarte): + from bpp.models import WybranaPublikacjaAutora + + autor = baker.make(Autor) + wp = WybranaPublikacjaAutora.objects.create( + autor=autor, + content_type=ContentType.objects.get_for_model(wydawnictwo_zwarte), + object_id=wydawnictwo_zwarte.pk, + kolejnosc=1, + ) + assert wp.publikacja == wydawnictwo_zwarte + assert list(autor.wybrane_publikacje.all()) == [wp] diff --git a/src/bpp/tests/test_profil/test_obrazy.py b/src/bpp/tests/test_profil/test_obrazy.py new file mode 100644 index 000000000..7778eebe2 --- /dev/null +++ b/src/bpp/tests/test_profil/test_obrazy.py @@ -0,0 +1,59 @@ +"""Testy przetwarzania zdjęcia autora (Pillow → kwadrat WebP 400x400).""" + +import io + +import pytest +from PIL import Image + +from bpp.util.obrazy import ROZMIAR_ZDJECIA_AUTORA, przetworz_zdjecie_autora + + +def _obraz(w, h, kolor=(10, 20, 30), format="PNG"): + buf = io.BytesIO() + Image.new("RGB", (w, h), kolor).save(buf, format=format) + buf.seek(0) + return buf + + +def test_skaluje_do_kwadratu(): + wynik = przetworz_zdjecie_autora(_obraz(600, 800)) + assert Image.open(wynik).size == ( + ROZMIAR_ZDJECIA_AUTORA, + ROZMIAR_ZDJECIA_AUTORA, + ) + + +def test_zapisuje_jako_webp(): + wynik = przetworz_zdjecie_autora(_obraz(500, 500)) + assert Image.open(wynik).format == "WEBP" + + +def test_szeroki_obraz_przyciety_do_kwadratu(): + wynik = przetworz_zdjecie_autora(_obraz(1000, 200)) + assert Image.open(wynik).size == ( + ROZMIAR_ZDJECIA_AUTORA, + ROZMIAR_ZDJECIA_AUTORA, + ) + + +def test_nazwa_pliku_konczy_sie_webp(): + wynik = przetworz_zdjecie_autora(_obraz(400, 400), nazwa="moje zdjecie.png") + assert wynik.name.endswith(".webp") + + +def test_obraz_z_alfa_jest_obslugiwany(): + buf = io.BytesIO() + Image.new("RGBA", (500, 500), (10, 20, 30, 128)).save(buf, format="PNG") + buf.seek(0) + wynik = przetworz_zdjecie_autora(buf) + assert Image.open(wynik).size == ( + ROZMIAR_ZDJECIA_AUTORA, + ROZMIAR_ZDJECIA_AUTORA, + ) + + +def test_niepoprawny_plik_rzuca_blad_walidacji(): + from django.core.exceptions import ValidationError + + with pytest.raises(ValidationError): + przetworz_zdjecie_autora(io.BytesIO(b"to nie jest obraz")) diff --git a/src/bpp/tests/test_profil/test_uklad.py b/src/bpp/tests/test_profil/test_uklad.py new file mode 100644 index 000000000..e920f0aad --- /dev/null +++ b/src/bpp/tests/test_profil/test_uklad.py @@ -0,0 +1,95 @@ +"""Testy rejestru sekcji i rozwiązywania układu profilu autora (czysta logika).""" + +from types import SimpleNamespace + +from bpp.profil_autora import ( + DOMYSLNY_LIMIT, + KATALOG_SEKCJI, + KLUCZ_BIOGRAM, + KLUCZ_DYSCYPLINY, + KLUCZ_NAJLEPSZE_PK, + KLUCZ_WYSZUKIWARKA, + domyslny_uklad, + rozwiaz_uklad, + waliduj_uklad, +) + + +def _autor(uklad=None): + return SimpleNamespace(uklad_profilu=uklad) + + +def _klucze(sekcje): + return [s["klucz"] for s in sekcje] + + +def test_domyslny_uklad_pokrywa_caly_katalog(): + assert {s["klucz"] for s in domyslny_uklad()} == {t.klucz for t in KATALOG_SEKCJI} + + +def test_bez_konfiguracji_widoczne_sa_domyslne_sekcje(): + klucze = _klucze(rozwiaz_uklad(_autor(None))) + assert KLUCZ_WYSZUKIWARKA in klucze + assert KLUCZ_BIOGRAM in klucze + # dyscypliny domyślnie wyłączone + assert KLUCZ_DYSCYPLINY not in klucze + + +def test_wyszukiwarka_zawsze_widoczna(): + uklad = [{"klucz": KLUCZ_WYSZUKIWARKA, "widoczna": False, "limit": None}] + assert KLUCZ_WYSZUKIWARKA in _klucze(rozwiaz_uklad(_autor(uklad))) + + +def test_konfiguracja_steruje_kolejnoscia(): + uklad = [ + {"klucz": KLUCZ_WYSZUKIWARKA, "widoczna": True, "limit": None}, + {"klucz": KLUCZ_BIOGRAM, "widoczna": True, "limit": None}, + ] + klucze = _klucze(rozwiaz_uklad(_autor(uklad))) + assert klucze.index(KLUCZ_WYSZUKIWARKA) < klucze.index(KLUCZ_BIOGRAM) + + +def test_ukrycie_sekcji_usuwa_ja(): + uklad = [{"klucz": KLUCZ_BIOGRAM, "widoczna": False, "limit": None}] + assert KLUCZ_BIOGRAM not in _klucze(rozwiaz_uklad(_autor(uklad))) + + +def test_sekcja_spoza_configu_dolaczana_z_domyslem(): + # config zawiera tylko wyszukiwarkę; biogram (domyślnie ON) i tak ma się pojawić + uklad = [{"klucz": KLUCZ_WYSZUKIWARKA, "widoczna": True, "limit": None}] + assert KLUCZ_BIOGRAM in _klucze(rozwiaz_uklad(_autor(uklad))) + + +def test_waliduj_odrzuca_nieznany_klucz(): + assert waliduj_uklad([{"klucz": "xxx", "widoczna": True, "limit": None}]) == [] + + +def test_waliduj_koryguje_niedozwolony_limit(): + out = waliduj_uklad([{"klucz": KLUCZ_NAJLEPSZE_PK, "widoczna": True, "limit": 7}]) + assert out[0]["limit"] == DOMYSLNY_LIMIT + + +def test_waliduj_akceptuje_dozwolony_limit(): + out = waliduj_uklad([{"klucz": KLUCZ_NAJLEPSZE_PK, "widoczna": True, "limit": 30}]) + assert out[0]["limit"] == 30 + + +def test_waliduj_zeruje_limit_dla_sekcji_bez_limitu(): + out = waliduj_uklad([{"klucz": KLUCZ_BIOGRAM, "widoczna": True, "limit": 30}]) + assert out[0]["limit"] is None + + +def test_waliduj_deduplikuje_klucze(): + out = waliduj_uklad( + [ + {"klucz": KLUCZ_BIOGRAM, "widoczna": True, "limit": None}, + {"klucz": KLUCZ_BIOGRAM, "widoczna": False, "limit": None}, + ] + ) + assert len(out) == 1 + + +def test_rozwiazane_sekcje_maja_nazwe_i_template(): + sekcja = rozwiaz_uklad(_autor(None))[0] + assert sekcja["nazwa"] + assert sekcja["template"].startswith("browse/autor_sekcje/") diff --git a/src/bpp/tests/test_profil/test_widok.py b/src/bpp/tests/test_profil/test_widok.py new file mode 100644 index 000000000..f66aa7cd7 --- /dev/null +++ b/src/bpp/tests/test_profil/test_widok.py @@ -0,0 +1,62 @@ +"""Testy integracyjne podstrony autora: render sekcji + linki do raportu.""" + +import pytest +from django.urls import reverse +from model_bakery import baker + +from bpp.models import Autor +from bpp.profil_autora import KLUCZ_BIOGRAM, KLUCZ_WYSZUKIWARKA + + +@pytest.mark.django_db +def test_strona_renderuje_biogram_i_wyszukiwarke(client): + autor = baker.make(Autor, biogram="**Bio** autora", biogram_format="md") + resp = client.get(autor.get_absolute_url()) + assert resp.status_code == 200 + tresc = resp.content.decode() + assert "Bio" in tresc + assert "Wyszukaj publikacje autora" in tresc + + +@pytest.mark.django_db +def test_uklad_pozwala_ukryc_biogram(client): + autor = baker.make( + Autor, + biogram="**Bio** autora", + biogram_format="md", + uklad_profilu=[ + {"klucz": KLUCZ_BIOGRAM, "widoczna": False, "limit": None}, + {"klucz": KLUCZ_WYSZUKIWARKA, "widoczna": True, "limit": None}, + ], + ) + tresc = client.get(autor.get_absolute_url()).content.decode() + assert "Bio" not in tresc + # wyszukiwarka (obowiązkowa) nadal jest + assert "Wyszukaj publikacje autora" in tresc + + +@pytest.mark.django_db +def test_brak_linkow_raportu_gdy_raport_nieaktywny(client): + from nowe_raporty.models import DefinicjaRaportu + + DefinicjaRaportu.objects.filter(slug="raport-autorow").update(aktywny=False) + autor = baker.make(Autor) + tresc = client.get(autor.get_absolute_url()).content.decode() + assert "Raport autora" not in tresc + + +@pytest.mark.django_db +def test_linki_raportu_dla_publicznego_raportu(client): + from nowe_raporty.models import DefinicjaRaportu + + DefinicjaRaportu.objects.filter(slug="raport-autorow").delete() + baker.make( + DefinicjaRaportu, + slug="raport-autorow", + aktywny=True, + poziom_dostepu=DefinicjaRaportu.DOSTEP_WSZYSCY, + ) + autor = baker.make(Autor) + tresc = client.get(autor.get_absolute_url()).content.decode() + assert "Raport autora" in tresc + assert reverse("nowe_raporty:raport_form", args=["raport-autorow"]) in tresc diff --git a/src/bpp/util/biogram.py b/src/bpp/util/biogram.py new file mode 100644 index 000000000..8da34e6ff --- /dev/null +++ b/src/bpp/util/biogram.py @@ -0,0 +1,36 @@ +"""Renderowanie biogramu autora do bezpiecznego HTML. + +Autor podaje biogram w jednym z dwóch formatów: Markdown (``md``) albo HTML +(``html``). Niezależnie od formatu wynik przechodzi przez ``safe_html`` (nh3), +więc istnieje dokładnie jeden punkt sanityzacji — niemożliwe jest wyrenderowanie +niezaufanego HTML-a z pominięciem czyszczenia. +""" + +import markdown as _markdown + +from bpp.util.text import safe_biogram_html + +FORMAT_MARKDOWN = "md" +FORMAT_HTML = "html" + +FORMATY_BIOGRAMU = ( + (FORMAT_MARKDOWN, "Markdown"), + (FORMAT_HTML, "HTML"), +) + + +def renderuj_biogram(tekst, format): + """Zwróć bezpieczny HTML biogramu. + + Dla ``md`` najpierw renderuje Markdown do HTML, następnie sanityzuje. Dla + każdego innego formatu (w tym ``html``) sanityzuje wejście wprost — to + bezpieczny domyślny wybór, bo nigdy nie przepuszcza surowego HTML-a bez + czyszczenia. + """ + if not tekst: + return "" + + if format == FORMAT_MARKDOWN: + tekst = _markdown.markdown(tekst) + + return safe_biogram_html(tekst) diff --git a/src/bpp/util/obrazy.py b/src/bpp/util/obrazy.py new file mode 100644 index 000000000..fa0f5e7ab --- /dev/null +++ b/src/bpp/util/obrazy.py @@ -0,0 +1,48 @@ +"""Przetwarzanie zdjęcia autora. + +Wejściowy plik (dowolny format obsługiwany przez Pillow, do 5 MB) jest +korygowany wg orientacji EXIF, przycinany centralnie do kwadratu, skalowany do +``ROZMIAR_ZDJECIA_AUTORA`` i zapisywany jako WebP. Walidacja rozmiaru pliku +i typu MIME należy do formularza; tutaj walidujemy tylko, że da się to w ogóle +otworzyć jako obraz. +""" + +import io +from pathlib import Path + +from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile +from PIL import Image, ImageOps, UnidentifiedImageError + +ROZMIAR_ZDJECIA_AUTORA = 400 +MAKS_ROZMIAR_PLIKU_ZDJECIA = 5 * 1024 * 1024 # 5 MB +JAKOSC_WEBP = 85 + + +def przetworz_zdjecie_autora(plik, nazwa=None, rozmiar=ROZMIAR_ZDJECIA_AUTORA): + """Zwróć ``ContentFile`` z kwadratowym zdjęciem WebP gotowym do zapisu. + + :raises ValidationError: gdy pliku nie da się odczytać jako obrazu. + """ + try: + obraz = Image.open(plik) + obraz.load() + except (UnidentifiedImageError, OSError) as e: + raise ValidationError( + "Nie udało się odczytać przesłanego pliku jako obrazu." + ) from e + + # Korekta orientacji wg EXIF (zdjęcia z telefonów bywają obrócone), a potem + # spłaszczenie do RGB (WebP zapisujemy bez kanału alfa) i przycięcie+skala + # do kwadratu jednym przejściem (ImageOps.fit centruje kadr). + obraz = ImageOps.exif_transpose(obraz) + obraz = obraz.convert("RGB") + obraz = ImageOps.fit( + obraz, (rozmiar, rozmiar), method=Image.LANCZOS, centering=(0.5, 0.5) + ) + + bufor = io.BytesIO() + obraz.save(bufor, format="WEBP", quality=JAKOSC_WEBP) + + rdzen_nazwy = Path(nazwa).stem if nazwa else "zdjecie" + return ContentFile(bufor.getvalue(), name=f"{rdzen_nazwy}.webp") diff --git a/src/bpp/util/text.py b/src/bpp/util/text.py index 2f06e9547..7150a80d5 100644 --- a/src/bpp/util/text.py +++ b/src/bpp/util/text.py @@ -218,6 +218,72 @@ class safe_streszczenie_defaults: ALLOWED_TAGS = safe_html_defaults.ALLOWED_TAGS + ("sub", "sup") +class safe_biogram_defaults: + # Biogram autora jest treścią blokową (akapity, nagłówki, listy, linki), + # więc dozwolony zestaw jest znacznie szerszy niż globalny ``safe_html``. + # Świadomie pomijamy ``img`` (tracking-piksele / mixed content) oraz + # ``style``/``class`` (spoofing wyglądu strony) — można dodać później. + ALLOWED_TAGS = ( + "p", + "br", + "hr", + "strong", + "b", + "em", + "i", + "u", + "strike", + "sub", + "sup", + "a", + "ul", + "ol", + "li", + "blockquote", + "h2", + "h3", + "h4", + "h5", + "h6", + "code", + "pre", + "table", + "thead", + "tbody", + "tr", + "td", + "th", + "dl", + "dt", + "dd", + ) + ALLOWED_ATTRIBUTES = {"a": ["href", "title"]} + + +def safe_biogram_html(html): + """Zwróć bezpieczny HTML biogramu autora. + + W odróżnieniu od ``safe_html``: (a) bogatszy zestaw tagów blokowych, + (b) ``clean_content_tags`` usuwa treść ``script``/``style`` (nie zostawia + jej jako tekst), (c) linki dostają ``rel="nofollow noopener noreferrer"``. + """ + html = html or "" + + ALLOWED_TAGS = getattr( + settings, "BIOGRAM_ALLOWED_TAGS", safe_biogram_defaults.ALLOWED_TAGS + ) + ALLOWED_ATTRIBUTES = getattr( + settings, "BIOGRAM_ALLOWED_ATTRIBUTES", safe_biogram_defaults.ALLOWED_ATTRIBUTES + ) + return nh3.clean( + html, + tags=set(ALLOWED_TAGS), + attributes={k: set(v) for k, v in ALLOWED_ATTRIBUTES.items()}, + clean_content_tags={"script", "style"}, + link_rel="nofollow noopener noreferrer", + ) + + def safe_streszczenie_html(html): """Zwróć bezpieczny, zbalansowany HTML streszczenia. diff --git a/src/bpp/views/browse.py b/src/bpp/views/browse.py index a36bffbcb..01ebd2ab1 100644 --- a/src/bpp/views/browse.py +++ b/src/bpp/views/browse.py @@ -161,10 +161,56 @@ def get_context_data(self, **kwargs): Q(primary_author=self.object) | Q(secondary_author=self.object) ).exists() ) + + from bpp.profil_autora_dane import przygotuj_sekcje + + request = getattr(self, "request", None) return super().get_context_data( - typy=TYPY, ma_powiazania=ma_powiazania, **kwargs + typy=TYPY, + ma_powiazania=ma_powiazania, + sekcje_profilu=przygotuj_sekcje(self.object, request), + raport_links=self._raport_links(request), + **kwargs, ) + def _raport_links(self, request): + """Linki do raportu autora — tylko gdy raport jest widoczny dla + oglądającego (reużycie ``DefinicjaRaportu.widoczny_dla``). Trzy linki: + bieżący rok, ostatnie 4 lata oraz szczegółowy formularz.""" + if request is None: + return [] + + from nowe_raporty.models import DefinicjaRaportu + + for definicja in DefinicjaRaportu.objects.filter(slug="raport-autorow"): + if definicja.widoczny_dla(request): + break + else: + return [] + + rok = timezone.now().date().year + pk = self.object.pk + return [ + { + "label": f"Raport za rok {rok}", + "url": reverse( + "nowe_raporty:raport_generuj", + args=["raport-autorow", pk, rok, rok], + ), + }, + { + "label": "Raport za ostatnie 4 lata", + "url": reverse( + "nowe_raporty:raport_generuj", + args=["raport-autorow", pk, rok - 3, rok], + ), + }, + { + "label": "Raport szczegółowy…", + "url": reverse("nowe_raporty:raport_form", args=["raport-autorow"]), + }, + ] + LITERKI = "ABCDEFGHIJKLMNOPQRSTUVWYXZ"