Compare commits

..

3 Commits

10 changed files with 118 additions and 12 deletions

View File

@@ -1,5 +1,9 @@
# Changelog # Changelog
## 0.7.2
- В каталоге сотрудников добавлены фильтр и колонка учёной степени.
## 0.7.1 ## 0.7.1
- Добавлена кнопка «Проверить уволенных» для принудительной сверки статуса уволенных сотрудников с текущим списком источника. - Добавлена кнопка «Проверить уволенных» для принудительной сверки статуса уволенных сотрудников с текущим списком источника.

View File

@@ -78,8 +78,9 @@ def directory(
status: str | None = None, status: str | None = None,
q: str | None = None, q: str | None = None,
started_from: str | None = None, started_from: str | None = None,
started_to: str | None = None, started_to: str | None = None,
has_email: str | None = None, has_email: str | None = None,
has_academic_degree: str | None = None,
sort: str = "full_name", sort: str = "full_name",
direction: str = "asc", direction: str = "asc",
limit: int = 50, limit: int = 50,
@@ -90,14 +91,16 @@ def directory(
require_admin(request, settings) require_admin(request, settings)
parsed_started_from = _parse_date(started_from) parsed_started_from = _parse_date(started_from)
parsed_started_to = _parse_date(started_to) 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( page = list_employees_page(
db, db,
status=status, status=status,
q=q, q=q,
started_from=parsed_started_from, started_from=parsed_started_from,
started_to=parsed_started_to, started_to=parsed_started_to,
has_email=parsed_has_email, has_email=parsed_has_email,
has_academic_degree=parsed_has_academic_degree,
sort=sort, sort=sort,
direction=direction, direction=direction,
limit=limit, limit=limit,
@@ -113,7 +116,8 @@ def directory(
"q": q or "", "q": q or "",
"started_from": started_from or "", "started_from": started_from or "",
"started_to": started_to 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, "sort": sort,
"direction": direction, "direction": direction,
"limit": page["limit"], "limit": page["limit"],

View File

@@ -28,6 +28,7 @@ def list_employees(
started_from: date | None = None, started_from: date | None = None,
started_to: date | None = None, started_to: date | None = None,
has_email: bool | None = None, has_email: bool | None = None,
has_academic_degree: bool | None = None,
sort: str = "full_name", sort: str = "full_name",
direction: str = "asc", direction: str = "asc",
limit: int = 50, limit: int = 50,
@@ -43,6 +44,7 @@ def list_employees(
started_from=started_from, started_from=started_from,
started_to=started_to, started_to=started_to,
has_email=has_email, has_email=has_email,
has_academic_degree=has_academic_degree,
sort=sort, sort=sort,
direction=direction, direction=direction,
limit=limit, limit=limit,

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import re
from datetime import date, datetime, time from datetime import date, datetime, time
from math import ceil from math import ceil
from typing import Any from typing import Any
@@ -19,6 +20,8 @@ EMPLOYEE_SORTS = {
"hse_start_year": Employee.current_data["hse_start_year"].as_integer(), "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]: def employee_display_payload(employee: Employee) -> dict[str, Any]:
data = _as_dict(employee.current_data) 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")) positions = _clean_list(data.get("positions"))
emails = _clean_list(contacts.get("emails")) emails = _clean_list(contacts.get("emails"))
phones = _clean_list(contacts.get("phones")) phones = _clean_list(contacts.get("phones"))
academic_degrees = _academic_degrees(sections)
return { return {
"id": employee.id, "id": employee.id,
"full_name": employee.full_name, "full_name": employee.full_name,
@@ -42,6 +46,7 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]:
"phones": phones, "phones": phones,
"phone_text": ", ".join(phones), "phone_text": ", ".join(phones),
"address": contacts.get("address"), "address": contacts.get("address"),
"academic_degree_text": "; ".join(academic_degrees),
"publications_count": _count_section_items(sections, "publications"), "publications_count": _count_section_items(sections, "publications"),
"courses_count": _count_section_items(sections, "courses_by_year"), "courses_count": _count_section_items(sections, "courses_by_year"),
"news_count": len(stored_news_links) or _count_section_items(sections, "news"), "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_from: date | None = None,
started_to: date | None = None, started_to: date | None = None,
has_email: bool | None = None, has_email: bool | None = None,
has_academic_degree: bool | None = None,
) -> Select[tuple[Employee]]: ) -> Select[tuple[Employee]]:
stmt = select(Employee) stmt = select(Employee)
filters = [] filters = []
@@ -97,6 +103,15 @@ def build_employee_query(
filters.append(Employee.current_data.cast(Text).ilike("%@%")) filters.append(Employee.current_data.cast(Text).ilike("%@%"))
elif has_email is False: elif has_email is False:
filters.append(or_(Employee.current_data.is_(None), ~Employee.current_data.cast(Text).ilike("%@%"))) 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: if filters:
stmt = stmt.where(and_(*filters)) stmt = stmt.where(and_(*filters))
return stmt return stmt
@@ -110,6 +125,7 @@ def list_employees_page(
started_from: date | None = None, started_from: date | None = None,
started_to: date | None = None, started_to: date | None = None,
has_email: bool | None = None, has_email: bool | None = None,
has_academic_degree: bool | None = None,
sort: str = "full_name", sort: str = "full_name",
direction: str = "asc", direction: str = "asc",
limit: int = 50, limit: int = 50,
@@ -123,6 +139,7 @@ def list_employees_page(
started_from=started_from, started_from=started_from,
started_to=started_to, started_to=started_to,
has_email=has_email, has_email=has_email,
has_academic_degree=has_academic_degree,
) )
total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0 total = db.scalar(select(func.count()).select_from(base_stmt.subquery())) or 0
sort_column = EMPLOYEE_SORTS.get(sort, Employee.full_name) 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") 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: def _employee_status_display(status: str | None) -> str:
labels = {"active": "Работает", "dismissed": "Уволен"} labels = {"active": "Работает", "dismissed": "Уволен"}
return labels.get(status or "", status or "Не указано") return labels.get(status or "", status or "Не указано")

View File

@@ -5,6 +5,7 @@
"positions", "positions",
"hse_start_year", "hse_start_year",
"email", "email",
"academic_degree",
"last_seen_at", "last_seen_at",
"dismissed_at", "dismissed_at",
"profile", "profile",

View File

@@ -22,6 +22,11 @@
<option value="true" {% if filters.has_email == "true" %}selected{% endif %}>Есть email</option> <option value="true" {% if filters.has_email == "true" %}selected{% endif %}>Есть email</option>
<option value="false" {% if filters.has_email == "false" %}selected{% endif %}>Нет email</option> <option value="false" {% if filters.has_email == "false" %}selected{% endif %}>Нет email</option>
</select> </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_from" value="{{ filters.started_from }}" aria-label="Впервые найден с">
<input class="directory__input" type="date" name="started_to" value="{{ filters.started_to }}" aria-label="Впервые найден по"> <input class="directory__input" type="date" name="started_to" value="{{ filters.started_to }}" aria-label="Впервые найден по">
<select class="directory__input" name="sort"> <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="email">Email</th>
<th class="directory-table__head" data-column="phone">Телефон</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="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="publications_count">Публикации</th>
<th class="directory-table__head" data-column="courses_count">Курсы</th> <th class="directory-table__head" data-column="courses_count">Курсы</th>
<th class="directory-table__head" data-column="news_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="email">{{ employee.email_text }}</td>
<td class="directory-table__cell" data-column="phone">{{ employee.phone_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="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="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="courses_count">{{ employee.courses_count }}</td>
<td class="directory-table__cell" data-column="news_count">{{ employee.news_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> <td class="directory-table__cell" data-column="profile"><a class="admin__link" href="{{ employee.canonical_url }}">Открыть</a></td>
</tr> </tr>
{% else %} {% else %}
<tr><td class="directory-table__empty" colspan="14">По этим фильтрам сотрудники не найдены.</td></tr> <tr><td class="directory-table__empty" colspan="15">По этим фильтрам сотрудники не найдены.</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@@ -108,7 +115,7 @@
<button class="button button--ghost" type="button" data-columns-close>Закрыть</button> <button class="button button--ghost" type="button" data-columns-close>Закрыть</button>
</div> </div>
<div class="columns-modal__grid"> <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> <label class="columns-modal__option"><input class="columns-modal__checkbox" type="checkbox" value="{{ key }}" data-column-toggle> {{ label }}</label>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -1,3 +1,3 @@
APP_VERSION = "0.7.1" APP_VERSION = "0.7.2"
FRONTEND_VERSION = "0.7.1" FRONTEND_VERSION = "0.7.2"
BACKEND_VERSION = "0.7.1" BACKEND_VERSION = "0.7.2"

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "miem-workers" name = "miem-workers"
version = "0.7.1" version = "0.7.2"
description = "MIEM employees parser, admin API, and MCP server" description = "MIEM employees parser, admin API, and MCP server"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View File

@@ -51,6 +51,45 @@ def test_employee_display_payload_extracts_common_fields(db_session):
assert payload["first_seen_display"] != "Не указано" 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): def test_employee_detail_payload_normalizes_human_readable_sections(db_session):
employee = Employee( employee = Employee(
profile_key="staff:person", profile_key="staff:person",

View File

@@ -22,7 +22,9 @@ def test_directory_template_is_russian_and_uses_display_dates():
assert "На странице: {{ value }}" in template assert "На странице: {{ value }}" in template
assert "{% for value in [25, 50, 100] %}" in template assert "{% for value in [25, 50, 100] %}" in template
assert "Найдено:" 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.news_count" in template
assert "employee.first_seen_display" in template assert "employee.first_seen_display" in template
assert "employee.last_seen_display" in template assert "employee.last_seen_display" in template