feat: filter employees by academic degree #30
@@ -1,5 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.2
|
||||
|
||||
- В каталоге сотрудников добавлены фильтр и колонка учёной степени.
|
||||
|
||||
## 0.7.1
|
||||
|
||||
- Добавлена кнопка «Проверить уволенных» для принудительной сверки статуса уволенных сотрудников с текущим списком источника.
|
||||
|
||||
14
app/admin.py
14
app/admin.py
@@ -78,8 +78,9 @@ def directory(
|
||||
status: str | None = None,
|
||||
q: str | None = None,
|
||||
started_from: str | None = None,
|
||||
started_to: str | None = None,
|
||||
has_email: str | None = None,
|
||||
started_to: str | None = None,
|
||||
has_email: str | None = None,
|
||||
has_academic_degree: str | None = None,
|
||||
sort: str = "full_name",
|
||||
direction: str = "asc",
|
||||
limit: int = 50,
|
||||
@@ -90,14 +91,16 @@ def directory(
|
||||
require_admin(request, settings)
|
||||
parsed_started_from = _parse_date(started_from)
|
||||
parsed_started_to = _parse_date(started_to)
|
||||
parsed_has_email = None if has_email in (None, "") else has_email == "true"
|
||||
parsed_has_email = None if has_email in (None, "") else has_email == "true"
|
||||
parsed_has_academic_degree = None if has_academic_degree in (None, "") else has_academic_degree == "true"
|
||||
page = list_employees_page(
|
||||
db,
|
||||
status=status,
|
||||
q=q,
|
||||
started_from=parsed_started_from,
|
||||
started_to=parsed_started_to,
|
||||
has_email=parsed_has_email,
|
||||
has_email=parsed_has_email,
|
||||
has_academic_degree=parsed_has_academic_degree,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
@@ -113,7 +116,8 @@ def directory(
|
||||
"q": q or "",
|
||||
"started_from": started_from or "",
|
||||
"started_to": started_to or "",
|
||||
"has_email": has_email or "",
|
||||
"has_email": has_email or "",
|
||||
"has_academic_degree": has_academic_degree or "",
|
||||
"sort": sort,
|
||||
"direction": direction,
|
||||
"limit": page["limit"],
|
||||
|
||||
@@ -28,6 +28,7 @@ def list_employees(
|
||||
started_from: date | None = None,
|
||||
started_to: date | None = None,
|
||||
has_email: bool | None = None,
|
||||
has_academic_degree: bool | None = None,
|
||||
sort: str = "full_name",
|
||||
direction: str = "asc",
|
||||
limit: int = 50,
|
||||
@@ -43,6 +44,7 @@ def list_employees(
|
||||
started_from=started_from,
|
||||
started_to=started_to,
|
||||
has_email=has_email,
|
||||
has_academic_degree=has_academic_degree,
|
||||
sort=sort,
|
||||
direction=direction,
|
||||
limit=limit,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, datetime, time
|
||||
from math import ceil
|
||||
from typing import Any
|
||||
@@ -19,6 +20,8 @@ EMPLOYEE_SORTS = {
|
||||
"hse_start_year": Employee.current_data["hse_start_year"].as_integer(),
|
||||
}
|
||||
|
||||
_ACADEMIC_DEGREE_PATTERN = re.compile(r"\b(?:кандидат|доктор)\s+[\w\s-]{0,80}?\s+наук\b|\bph\.?\s*d\.?\b", re.IGNORECASE)
|
||||
|
||||
|
||||
def employee_display_payload(employee: Employee) -> dict[str, Any]:
|
||||
data = _as_dict(employee.current_data)
|
||||
@@ -28,6 +31,7 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]:
|
||||
positions = _clean_list(data.get("positions"))
|
||||
emails = _clean_list(contacts.get("emails"))
|
||||
phones = _clean_list(contacts.get("phones"))
|
||||
academic_degrees = _academic_degrees(sections)
|
||||
return {
|
||||
"id": employee.id,
|
||||
"full_name": employee.full_name,
|
||||
@@ -42,6 +46,7 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]:
|
||||
"phones": phones,
|
||||
"phone_text": ", ".join(phones),
|
||||
"address": contacts.get("address"),
|
||||
"academic_degree_text": "; ".join(academic_degrees),
|
||||
"publications_count": _count_section_items(sections, "publications"),
|
||||
"courses_count": _count_section_items(sections, "courses_by_year"),
|
||||
"news_count": len(stored_news_links) or _count_section_items(sections, "news"),
|
||||
@@ -81,6 +86,7 @@ def build_employee_query(
|
||||
started_from: date | None = None,
|
||||
started_to: date | None = None,
|
||||
has_email: bool | None = None,
|
||||
has_academic_degree: bool | None = None,
|
||||
) -> Select[tuple[Employee]]:
|
||||
stmt = select(Employee)
|
||||
filters = []
|
||||
@@ -97,6 +103,15 @@ def build_employee_query(
|
||||
filters.append(Employee.current_data.cast(Text).ilike("%@%"))
|
||||
elif has_email is False:
|
||||
filters.append(or_(Employee.current_data.is_(None), ~Employee.current_data.cast(Text).ilike("%@%")))
|
||||
if has_academic_degree is not None:
|
||||
data_text = Employee.current_data.cast(Text)
|
||||
degree_condition = or_(
|
||||
and_(_json_text_contains(data_text, "кандидат"), _json_text_contains(data_text, "наук")),
|
||||
and_(_json_text_contains(data_text, "доктор"), _json_text_contains(data_text, "наук")),
|
||||
_json_text_contains(data_text, "phd"),
|
||||
_json_text_contains(data_text, "ph.d."),
|
||||
)
|
||||
filters.append(degree_condition if has_academic_degree else or_(Employee.current_data.is_(None), ~degree_condition))
|
||||
if filters:
|
||||
stmt = stmt.where(and_(*filters))
|
||||
return stmt
|
||||
@@ -110,6 +125,7 @@ def list_employees_page(
|
||||
started_from: date | None = None,
|
||||
started_to: date | None = None,
|
||||
has_email: bool | None = None,
|
||||
has_academic_degree: bool | None = None,
|
||||
sort: str = "full_name",
|
||||
direction: str = "asc",
|
||||
limit: int = 50,
|
||||
@@ -123,6 +139,7 @@ def list_employees_page(
|
||||
started_from=started_from,
|
||||
started_to=started_to,
|
||||
has_email=has_email,
|
||||
has_academic_degree=has_academic_degree,
|
||||
)
|
||||
total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0
|
||||
sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name)
|
||||
@@ -214,6 +231,36 @@ def format_admin_datetime(value: Any) -> str:
|
||||
return value.strftime("%d.%m.%Y %H:%M")
|
||||
|
||||
|
||||
def _academic_degrees(sections: list[Any]) -> list[str]:
|
||||
degrees = []
|
||||
for section in sections:
|
||||
section_data = _as_dict(section)
|
||||
title = str(section_data.get("title") or "")
|
||||
if not re.search(r"уч[её]н.*степен|academic degree", title, re.IGNORECASE):
|
||||
continue
|
||||
values = [
|
||||
*(_as_dict(entry).get("text") for entry in _as_list(section_data.get("year_entries"))),
|
||||
*_clean_list(section_data.get("paragraphs")),
|
||||
*_clean_list(section_data.get("items")),
|
||||
section_data.get("raw_text"),
|
||||
]
|
||||
for value in values:
|
||||
text = str(value or "").strip()
|
||||
if text and _ACADEMIC_DEGREE_PATTERN.search(text) and text not in degrees:
|
||||
degrees.append(text)
|
||||
return degrees
|
||||
|
||||
|
||||
def _json_text_contains(data_text: Any, value: str) -> Any:
|
||||
escaped = value.encode("unicode_escape").decode("ascii")
|
||||
escaped_capitalized = value.capitalize().encode("unicode_escape").decode("ascii")
|
||||
return or_(
|
||||
data_text.ilike(f"%{value}%"),
|
||||
data_text.ilike(f"%{escaped}%"),
|
||||
data_text.ilike(f"%{escaped_capitalized}%"),
|
||||
)
|
||||
|
||||
|
||||
def _employee_status_display(status: str | None) -> str:
|
||||
labels = {"active": "Работает", "dismissed": "Уволен"}
|
||||
return labels.get(status or "", status or "Не указано")
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"positions",
|
||||
"hse_start_year",
|
||||
"email",
|
||||
"academic_degree",
|
||||
"last_seen_at",
|
||||
"dismissed_at",
|
||||
"profile",
|
||||
|
||||
@@ -22,6 +22,11 @@
|
||||
<option value="true" {% if filters.has_email == "true" %}selected{% endif %}>Есть email</option>
|
||||
<option value="false" {% if filters.has_email == "false" %}selected{% endif %}>Нет email</option>
|
||||
</select>
|
||||
<select class="directory__input" name="has_academic_degree" aria-label="Учёная степень">
|
||||
<option value="" {% if not filters.has_academic_degree %}selected{% endif %}>Любая учёная степень</option>
|
||||
<option value="true" {% if filters.has_academic_degree == "true" %}selected{% endif %}>Есть учёная степень</option>
|
||||
<option value="false" {% if filters.has_academic_degree == "false" %}selected{% endif %}>Нет учёной степени</option>
|
||||
</select>
|
||||
<input class="directory__input" type="date" name="started_from" value="{{ filters.started_from }}" aria-label="Впервые найден с">
|
||||
<input class="directory__input" type="date" name="started_to" value="{{ filters.started_to }}" aria-label="Впервые найден по">
|
||||
<select class="directory__input" name="sort">
|
||||
@@ -53,6 +58,7 @@
|
||||
<th class="directory-table__head" data-column="email">Email</th>
|
||||
<th class="directory-table__head" data-column="phone">Телефон</th>
|
||||
<th class="directory-table__head" data-column="address">Адрес</th>
|
||||
<th class="directory-table__head" data-column="academic_degree">Учёная степень</th>
|
||||
<th class="directory-table__head" data-column="publications_count">Публикации</th>
|
||||
<th class="directory-table__head" data-column="courses_count">Курсы</th>
|
||||
<th class="directory-table__head" data-column="news_count">Новости</th>
|
||||
@@ -72,6 +78,7 @@
|
||||
<td class="directory-table__cell" data-column="email">{{ employee.email_text }}</td>
|
||||
<td class="directory-table__cell" data-column="phone">{{ employee.phone_text }}</td>
|
||||
<td class="directory-table__cell" data-column="address">{{ employee.address or "" }}</td>
|
||||
<td class="directory-table__cell" data-column="academic_degree">{{ employee.academic_degree_text }}</td>
|
||||
<td class="directory-table__cell" data-column="publications_count">{{ employee.publications_count }}</td>
|
||||
<td class="directory-table__cell" data-column="courses_count">{{ employee.courses_count }}</td>
|
||||
<td class="directory-table__cell" data-column="news_count">{{ employee.news_count }}</td>
|
||||
@@ -81,7 +88,7 @@
|
||||
<td class="directory-table__cell" data-column="profile"><a class="admin__link" href="{{ employee.canonical_url }}">Открыть</a></td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr><td class="directory-table__empty" colspan="14">По этим фильтрам сотрудники не найдены.</td></tr>
|
||||
<tr><td class="directory-table__empty" colspan="15">По этим фильтрам сотрудники не найдены.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -108,7 +115,7 @@
|
||||
<button class="button button--ghost" type="button" data-columns-close>Закрыть</button>
|
||||
</div>
|
||||
<div class="columns-modal__grid">
|
||||
{% for key, label in [("full_name", "ФИО"), ("status", "Статус"), ("positions", "Должности"), ("hse_start_year", "Год начала"), ("email", "Email"), ("phone", "Телефон"), ("address", "Адрес"), ("publications_count", "Публикации"), ("courses_count", "Курсы"), ("news_count", "Новости"), ("first_seen_at", "Впервые найден"), ("last_seen_at", "Последний раз найден"), ("dismissed_at", "Дата увольнения"), ("profile", "Профиль")] %}
|
||||
{% for key, label in [("full_name", "ФИО"), ("status", "Статус"), ("positions", "Должности"), ("hse_start_year", "Год начала"), ("email", "Email"), ("phone", "Телефон"), ("address", "Адрес"), ("academic_degree", "Учёная степень"), ("publications_count", "Публикации"), ("courses_count", "Курсы"), ("news_count", "Новости"), ("first_seen_at", "Впервые найден"), ("last_seen_at", "Последний раз найден"), ("dismissed_at", "Дата увольнения"), ("profile", "Профиль")] %}
|
||||
<label class="columns-modal__option"><input class="columns-modal__checkbox" type="checkbox" value="{{ key }}" data-column-toggle> {{ label }}</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.7.1"
|
||||
FRONTEND_VERSION = "0.7.1"
|
||||
BACKEND_VERSION = "0.7.1"
|
||||
APP_VERSION = "0.7.2"
|
||||
FRONTEND_VERSION = "0.7.2"
|
||||
BACKEND_VERSION = "0.7.2"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.7.1"
|
||||
version = "0.7.2"
|
||||
description = "MIEM employees parser, admin API, and MCP server"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -51,6 +51,45 @@ def test_employee_display_payload_extracts_common_fields(db_session):
|
||||
assert payload["first_seen_display"] != "Не указано"
|
||||
|
||||
|
||||
def test_list_employees_page_filters_and_displays_academic_degrees(db_session):
|
||||
db_session.add_all(
|
||||
[
|
||||
Employee(
|
||||
profile_key="staff:degree",
|
||||
canonical_url="https://www.hse.ru/staff/degree",
|
||||
full_name="Doctor",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
current_data={
|
||||
"sections": [
|
||||
{
|
||||
"title": "Образование и учёные степени",
|
||||
"year_entries": [{"year": 2020, "text": "Доктор технических наук"}],
|
||||
}
|
||||
]
|
||||
},
|
||||
),
|
||||
Employee(
|
||||
profile_key="staff:no-degree",
|
||||
canonical_url="https://www.hse.ru/staff/no-degree",
|
||||
full_name="Master",
|
||||
status="active",
|
||||
first_seen_at=datetime.now(timezone.utc),
|
||||
last_seen_at=datetime.now(timezone.utc),
|
||||
current_data={"sections": [{"title": "Образование", "items": ["Магистратура"]}]},
|
||||
),
|
||||
]
|
||||
)
|
||||
db_session.commit()
|
||||
|
||||
page = list_employees_page(db_session, has_academic_degree=True)
|
||||
|
||||
assert page["total"] == 1
|
||||
assert page["employees"][0]["full_name"] == "Doctor"
|
||||
assert page["employees"][0]["academic_degree_text"] == "Доктор технических наук"
|
||||
|
||||
|
||||
def test_employee_detail_payload_normalizes_human_readable_sections(db_session):
|
||||
employee = Employee(
|
||||
profile_key="staff:person",
|
||||
|
||||
@@ -22,7 +22,9 @@ def test_directory_template_is_russian_and_uses_display_dates():
|
||||
assert "На странице: {{ value }}" in template
|
||||
assert "{% for value in [25, 50, 100] %}" in template
|
||||
assert "Найдено:" in template
|
||||
assert "Новости" in template
|
||||
assert "Новости" in template
|
||||
assert "Есть учёная степень" in template
|
||||
assert 'data-column="academic_degree"' in template
|
||||
assert "employee.news_count" in template
|
||||
assert "employee.first_seen_display" in template
|
||||
assert "employee.last_seen_display" in template
|
||||
|
||||
Reference in New Issue
Block a user