Compare commits
3 Commits
feature/ch
...
feature/ac
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d3ec617be | |||
| f3403b371e | |||
| 3cfd1d5532 |
@@ -1,5 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.7.2
|
||||||
|
|
||||||
|
- В каталоге сотрудников добавлены фильтр и колонка учёной степени.
|
||||||
|
|
||||||
## 0.7.1
|
## 0.7.1
|
||||||
|
|
||||||
- Добавлена кнопка «Проверить уволенных» для принудительной сверки статуса уволенных сотрудников с текущим списком источника.
|
- Добавлена кнопка «Проверить уволенных» для принудительной сверки статуса уволенных сотрудников с текущим списком источника.
|
||||||
|
|||||||
14
app/admin.py
14
app/admin.py
@@ -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"],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 "Не указано")
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user