diff --git a/README.md b/README.md
index 32b60b1..6d36b4c 100644
--- a/README.md
+++ b/README.md
@@ -88,7 +88,7 @@ Compose запускает `api`, `worker` и `postgres`. API привязан
## Кандидаты в диссертационный совет
-Страница `/admin/council` доступна без авторизации. Она показывает сотрудников с докторской степенью в профиле и хотя бы одной публикацией в CSV или таблице `employee_publications`. Порог количества работ не применяется; сотрудники без степени, с кандидатской степенью или только PhD в список не включаются; уволенные сотрудники отмечены отдельно. Есть поиск по ФИО и страницы по 25 сотрудников. У каждого сотрудника видны степень, число уникальных работ из CSV с Q1/Q2 за скользящие пять лет, число уникальных работ в БД и общий перечень без дублей с источниками, типами и подтверждёнными критериями.
+Страница `/admin/council` доступна без авторизации. Она показывает сотрудников с докторской степенью в профиле и хотя бы одной публикацией в CSV или таблице `employee_publications`. Порог количества работ не применяется; сотрудники без степени, с кандидатской степенью или только PhD в список не включаются; уволенные сотрудники отмечены отдельно. Есть поиск по ФИО, сортировка по ФИО и каждому из трёх счётчиков публикаций в обоих направлениях и страницы по 25 сотрудников. Сортировка применяется ко всему списку и сохраняется при поиске и переходе между страницами. У каждого сотрудника видны степень, число уникальных работ из CSV с Q1/Q2 за скользящие пять лет, число уникальных работ в БД и общий перечень без дублей с источниками, типами и подтверждёнными критериями.
CSV `app/data/council_articles.csv` сопоставляется по полному ФИО только при единственном совпадении. Публикации объединяются по DOI, затем по нормализованным названию и году, если нет конфликтующих DOI. Совпадение в двух источниках сохраняет оба источника, но учитывается один раз в общем количестве. Импорт CSV в БД не выдаётся за данные сайта. CSV не содержит типа материала, точной даты и подтверждения К1/RSCI: неизвестные сведения обозначены явно; счётчик CSV предварительный. Публичная страница не открывает контакты, служебные данные или операции админки.
diff --git a/app/admin.py b/app/admin.py
index 6282104..2692ac9 100644
--- a/app/admin.py
+++ b/app/admin.py
@@ -134,9 +134,10 @@ def directory(
@router.get("/council", response_class=HTMLResponse)
-def council(request: Request, q: str = "", offset: int = 0, db: Session = Depends(get_db)):
+def council(request: Request, q: str = "", offset: int = 0, sort: str = "csv_count", direction: str = "desc",
+ db: Session = Depends(get_db)):
try:
- result = council_candidates(db, q=q, offset=offset)
+ result = council_candidates(db, q=q, offset=offset, sort=sort, direction=direction)
except (SQLAlchemyError, OSError, ValueError, KeyError):
db.rollback()
return _render(request, "council.html", {"error": True, "public_page": True}, status_code=503)
diff --git a/app/services/council.py b/app/services/council.py
index 72f7106..b41ecca 100644
--- a/app/services/council.py
+++ b/app/services/council.py
@@ -149,7 +149,8 @@ def _unique_articles(articles: list[dict], start: date, end: date) -> list[dict]
return sorted(unique.values(), key=lambda item: (not item["csv_qualifies"], -(item["year"] or 0), item["title"].casefold()))
-def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0) -> dict:
+def council_candidates(db: Session, today: date | None = None, q: str = "", offset: int = 0,
+ sort: str = "csv_count", direction: str = "desc") -> dict:
today = today or datetime.now(ZoneInfo("Europe/Moscow")).date()
start, end = period(today)
employees = db.scalars(select(Employee).options(selectinload(Employee.publications))).all()
@@ -175,7 +176,11 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
"db_count": sum(a["in_db"] for a in articles), "articles": articles,
"profile_url": _safe_url(employee.canonical_url), "ambiguous": ambiguous,
"dismissed": employee.status == "dismissed"})
- candidates.sort(key=lambda item: (-item["csv_count"], -item["count"], item["name"].casefold()))
+ sort = sort if sort in {"full_name", "csv_count", "db_count", "count"} else "csv_count"
+ direction = direction if direction in {"asc", "desc"} else "desc"
+ candidates.sort(key=lambda item: _name(item["name"]), reverse=sort == "full_name" and direction == "desc")
+ if sort != "full_name":
+ candidates.sort(key=lambda item: (item[sort], item["count"]), reverse=direction == "desc")
total = len(candidates)
query = _name(q)
candidates = [item for item in candidates if query in _name(item["name"])]
@@ -183,4 +188,4 @@ def council_candidates(db: Session, today: date | None = None, q: str = "", offs
limit = 25
offset = min(max(0, offset), ((matched - 1) // limit) * limit) if matched else 0
return {"candidates": candidates[offset:offset + limit], "start": start, "end": end, "total": total,
- "matched": matched, "q": q, "offset": offset, "limit": limit}
+ "matched": matched, "q": q, "offset": offset, "limit": limit, "sort": sort, "direction": direction}
diff --git a/app/templates/council.html b/app/templates/council.html
index c4cd2fc..da2a614 100644
--- a/app/templates/council.html
+++ b/app/templates/council.html
@@ -26,8 +26,21 @@
-
- {% if q %}Сбросить поиск{% endif %}
+
+
+
+ {% if q %}Сбросить поиск{% endif %}
Сотрудники
@@ -76,16 +89,16 @@
{% if matched > limit %}
{% endif %}
{% else %}
{% if q %}Сотрудники не найдены{% else %}Сотрудники с докторской степенью и публикациями пока не найдены{% endif %}
{% if q %}Попробуйте другую фамилию или сбросьте поиск.{% else %}Для включения в список нужны докторская степень в профиле и хотя бы одна публикация, однозначно связанная с сотрудником.{% endif %}