Compare commits
12 Commits
fix/templa
...
fix/empty-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
765efa1a1c | ||
| 86330885e3 | |||
|
|
866e2b44d5 | ||
| f411de740e | |||
|
|
cdfbb26875 | ||
| 5eaad38076 | |||
|
|
af87fa8af3 | ||
| 26db5832fd | |||
|
|
7530cbdb60 | ||
| ce90414654 | |||
|
|
755135d6ba | ||
| 69ad41da66 |
@@ -110,4 +110,4 @@ docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
|
|||||||
docker compose down
|
docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
Версия сервиса: `0.2.1`. Админка всегда показывает версии backend и frontend в footer.
|
Версия сервиса: `0.2.7`. Админка всегда показывает версии backend и frontend в footer.
|
||||||
|
|||||||
37
app/admin.py
37
app/admin.py
@@ -1,14 +1,14 @@
|
|||||||
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, Form, Request
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy import desc, func, or_, select
|
from sqlalchemy import desc, func, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.db import SessionLocal, get_db
|
from app.db import SessionLocal, get_db
|
||||||
from app.models import CrawlError, CrawlRun, Employee
|
from app.models import CrawlError, CrawlRun, Employee
|
||||||
from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin
|
from app.security import SESSION_COOKIE, require_admin, sign_session, verify_admin
|
||||||
from app.services.admin_data import list_employees_page, run_payload, stats_payload
|
from app.services.admin_data import employee_detail_payload, format_admin_datetime, list_employees_page, run_payload, stats_payload
|
||||||
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
from app.services.crawl_control import get_running_run, run_crawl_if_idle
|
||||||
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
from app.version import BACKEND_VERSION, FRONTEND_VERSION
|
||||||
|
|
||||||
@@ -22,8 +22,9 @@ def dashboard(request: Request, db: Session = Depends(get_db), settings: Setting
|
|||||||
counts = stats_payload(db)
|
counts = stats_payload(db)
|
||||||
counts["runs"] = db.scalar(select(func.count()).select_from(CrawlRun)) or 0
|
counts["runs"] = db.scalar(select(func.count()).select_from(CrawlRun)) or 0
|
||||||
counts["errors"] = db.scalar(select(func.count()).select_from(CrawlError)) or 0
|
counts["errors"] = db.scalar(select(func.count()).select_from(CrawlError)) or 0
|
||||||
runs = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(10)).all()
|
run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(10)).all()
|
||||||
return _render(request, "dashboard.html", {"counts": counts, "runs": runs, "latest_run": run_payload(runs[0]) if runs else None})
|
runs = [run_payload(run) for run in run_models]
|
||||||
|
return _render(request, "dashboard.html", {"counts": counts, "runs": runs, "latest_run": runs[0] if runs else None})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/login", response_class=HTMLResponse)
|
@router.get("/login", response_class=HTMLResponse)
|
||||||
@@ -57,18 +58,10 @@ def employees(
|
|||||||
request: Request,
|
request: Request,
|
||||||
status: str | None = None,
|
status: str | None = None,
|
||||||
q: str | None = None,
|
q: str | None = None,
|
||||||
db: Session = Depends(get_db),
|
|
||||||
settings: Settings = Depends(get_settings),
|
settings: Settings = Depends(get_settings),
|
||||||
):
|
):
|
||||||
require_admin(request, settings)
|
require_admin(request, settings)
|
||||||
stmt = select(Employee)
|
return RedirectResponse("/admin/directory", status_code=303)
|
||||||
if status:
|
|
||||||
stmt = stmt.where(Employee.status == status)
|
|
||||||
if q:
|
|
||||||
pattern = f"%{q}%"
|
|
||||||
stmt = stmt.where(or_(Employee.full_name.ilike(pattern), Employee.canonical_url.ilike(pattern)))
|
|
||||||
items = db.scalars(stmt.order_by(Employee.full_name).limit(200)).all()
|
|
||||||
return _render(request, "employees.html", {"employees": items, "status": status or "", "q": q or ""})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/directory", response_class=HTMLResponse)
|
@router.get("/directory", response_class=HTMLResponse)
|
||||||
@@ -133,14 +126,26 @@ def employee_detail(
|
|||||||
employee = db.get(Employee, employee_id)
|
employee = db.get(Employee, employee_id)
|
||||||
if not employee:
|
if not employee:
|
||||||
return RedirectResponse("/admin/employees", status_code=303)
|
return RedirectResponse("/admin/employees", status_code=303)
|
||||||
snapshots = sorted(employee.snapshots, key=lambda item: item.captured_at, reverse=True)[:20]
|
snapshots = [
|
||||||
return _render(request, "employee_detail.html", {"employee": employee, "snapshots": snapshots})
|
{
|
||||||
|
"captured_display": format_admin_datetime(snapshot.captured_at),
|
||||||
|
"checksum": snapshot.checksum,
|
||||||
|
"parser_version": snapshot.parser_version,
|
||||||
|
}
|
||||||
|
for snapshot in sorted(employee.snapshots, key=lambda item: item.captured_at, reverse=True)[:20]
|
||||||
|
]
|
||||||
|
return _render(
|
||||||
|
request,
|
||||||
|
"employee_detail.html",
|
||||||
|
{"employee": employee, "employee_view": employee_detail_payload(employee), "snapshots": snapshots},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/runs", response_class=HTMLResponse)
|
@router.get("/runs", response_class=HTMLResponse)
|
||||||
def runs(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
def runs(request: Request, db: Session = Depends(get_db), settings: Settings = Depends(get_settings)):
|
||||||
require_admin(request, settings)
|
require_admin(request, settings)
|
||||||
items = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(50)).all()
|
run_models = db.scalars(select(CrawlRun).order_by(desc(CrawlRun.started_at)).limit(50)).all()
|
||||||
|
items = [run_payload(run) for run in run_models]
|
||||||
errors = db.scalars(select(CrawlError).order_by(desc(CrawlError.created_at)).limit(50)).all()
|
errors = db.scalars(select(CrawlError).order_by(desc(CrawlError.created_at)).limit(50)).all()
|
||||||
return _render(request, "runs.html", {"runs": items, "errors": errors})
|
return _render(request, "runs.html", {"runs": items, "errors": errors})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pydantic import Field
|
from pydantic import Field, field_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
@@ -19,6 +19,13 @@ class Settings(BaseSettings):
|
|||||||
session_secret: str = Field(default="dev-session-secret", min_length=8)
|
session_secret: str = Field(default="dev-session-secret", min_length=8)
|
||||||
mcp_token: str = "dev-mcp-token"
|
mcp_token: str = "dev-mcp-token"
|
||||||
|
|
||||||
|
@field_validator("crawl_limit", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def empty_crawl_limit_as_none(cls, value):
|
||||||
|
if value == "":
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|||||||
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
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from sqlalchemy import Select, Text, and_, desc, func, or_, select
|
from sqlalchemy import Select, Text, and_, desc, func, or_, select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -20,18 +21,20 @@ EMPLOYEE_SORTS = {
|
|||||||
|
|
||||||
|
|
||||||
def employee_display_payload(employee: Employee) -> dict[str, Any]:
|
def employee_display_payload(employee: Employee) -> dict[str, Any]:
|
||||||
data = employee.current_data or {}
|
data = _as_dict(employee.current_data)
|
||||||
contacts = data.get("contacts") or {}
|
contacts = _as_dict(data.get("contacts"))
|
||||||
sections = data.get("sections") or []
|
sections = _as_list(data.get("sections"))
|
||||||
emails = contacts.get("emails") or []
|
positions = _clean_list(data.get("positions"))
|
||||||
phones = contacts.get("phones") or []
|
emails = _clean_list(contacts.get("emails"))
|
||||||
|
phones = _clean_list(contacts.get("phones"))
|
||||||
return {
|
return {
|
||||||
"id": employee.id,
|
"id": employee.id,
|
||||||
"full_name": employee.full_name,
|
"full_name": employee.full_name,
|
||||||
"status": employee.status,
|
"status": employee.status,
|
||||||
|
"status_display": _employee_status_display(employee.status),
|
||||||
"canonical_url": employee.canonical_url,
|
"canonical_url": employee.canonical_url,
|
||||||
"positions": data.get("positions") or [],
|
"positions": positions,
|
||||||
"positions_text": "; ".join(data.get("positions") or []),
|
"positions_text": "; ".join(positions),
|
||||||
"hse_start_year": data.get("hse_start_year"),
|
"hse_start_year": data.get("hse_start_year"),
|
||||||
"emails": emails,
|
"emails": emails,
|
||||||
"email_text": ", ".join(emails),
|
"email_text": ", ".join(emails),
|
||||||
@@ -43,6 +46,28 @@ def employee_display_payload(employee: Employee) -> dict[str, Any]:
|
|||||||
"first_seen_at": employee.first_seen_at.isoformat() if employee.first_seen_at else None,
|
"first_seen_at": employee.first_seen_at.isoformat() if employee.first_seen_at else None,
|
||||||
"last_seen_at": employee.last_seen_at.isoformat() if employee.last_seen_at else None,
|
"last_seen_at": employee.last_seen_at.isoformat() if employee.last_seen_at else None,
|
||||||
"dismissed_at": employee.dismissed_at.isoformat() if employee.dismissed_at else None,
|
"dismissed_at": employee.dismissed_at.isoformat() if employee.dismissed_at else None,
|
||||||
|
"first_seen_display": format_admin_datetime(employee.first_seen_at),
|
||||||
|
"last_seen_display": format_admin_datetime(employee.last_seen_at),
|
||||||
|
"dismissed_display": format_admin_datetime(employee.dismissed_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def employee_detail_payload(employee: Employee) -> dict[str, Any]:
|
||||||
|
data = _as_dict(employee.current_data)
|
||||||
|
contacts = _as_dict(data.get("contacts"))
|
||||||
|
return {
|
||||||
|
**employee_display_payload(employee),
|
||||||
|
"profile_type": employee.profile_type or data.get("profile_type"),
|
||||||
|
"profile_id": employee.profile_id or data.get("profile_id"),
|
||||||
|
"parser_version": employee.parser_version or data.get("parser_version"),
|
||||||
|
"contacts": {
|
||||||
|
"emails": _clean_list(contacts.get("emails")),
|
||||||
|
"phones": _clean_list(contacts.get("phones")),
|
||||||
|
"address": contacts.get("address"),
|
||||||
|
"contact_items": _normalize_contact_items(contacts.get("items")),
|
||||||
|
},
|
||||||
|
"external_ids": _normalize_external_ids(data.get("external_ids")),
|
||||||
|
"sections": [_normalize_section(section) for section in _as_list(data.get("sections"))],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -101,7 +126,7 @@ def list_employees_page(
|
|||||||
order = desc(sort_column) if direction == "desc" else sort_column
|
order = desc(sort_column) if direction == "desc" else sort_column
|
||||||
employees = db.scalars(base_stmt.order_by(order).limit(limit).offset(offset)).all()
|
employees = db.scalars(base_stmt.order_by(order).limit(limit).offset(offset)).all()
|
||||||
return {
|
return {
|
||||||
"items": [employee_display_payload(employee) for employee in employees],
|
"employees": [employee_display_payload(employee) for employee in employees],
|
||||||
"total": total,
|
"total": total,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
@@ -134,8 +159,11 @@ def run_payload(run: CrawlRun | None) -> dict[str, Any] | None:
|
|||||||
"id": run.id,
|
"id": run.id,
|
||||||
"source_url": run.source_url,
|
"source_url": run.source_url,
|
||||||
"status": run.status,
|
"status": run.status,
|
||||||
|
"status_display": _run_status_display(run.status),
|
||||||
"started_at": run.started_at.isoformat() if run.started_at else None,
|
"started_at": run.started_at.isoformat() if run.started_at else None,
|
||||||
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
|
"finished_at": run.finished_at.isoformat() if run.finished_at else None,
|
||||||
|
"started_display": format_admin_datetime(run.started_at),
|
||||||
|
"finished_display": format_admin_datetime(run.finished_at),
|
||||||
"found_count": run.found_count,
|
"found_count": run.found_count,
|
||||||
"parsed_count": run.parsed_count,
|
"parsed_count": run.parsed_count,
|
||||||
"new_count": run.new_count,
|
"new_count": run.new_count,
|
||||||
@@ -147,6 +175,31 @@ def run_payload(run: CrawlRun | None) -> dict[str, Any] | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def format_admin_datetime(value: Any) -> str:
|
||||||
|
if not value:
|
||||||
|
return "Не указано"
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
value = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return value
|
||||||
|
if not isinstance(value, datetime):
|
||||||
|
return str(value)
|
||||||
|
if value.tzinfo:
|
||||||
|
value = value.astimezone(ZoneInfo("Europe/Moscow"))
|
||||||
|
return value.strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def _employee_status_display(status: str | None) -> str:
|
||||||
|
labels = {"active": "Работает", "dismissed": "Уволен"}
|
||||||
|
return labels.get(status or "", status or "Не указано")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_status_display(status: str | None) -> str:
|
||||||
|
labels = {"running": "Выполняется", "completed": "Завершен", "failed": "Ошибка"}
|
||||||
|
return labels.get(status or "", status or "Не указано")
|
||||||
|
|
||||||
|
|
||||||
def _count_section_items(sections: list[dict[str, Any]], section_type: str) -> int:
|
def _count_section_items(sections: list[dict[str, Any]], section_type: str) -> int:
|
||||||
total = 0
|
total = 0
|
||||||
for section in sections:
|
for section in sections:
|
||||||
@@ -157,3 +210,156 @@ def _count_section_items(sections: list[dict[str, Any]], section_type: str) -> i
|
|||||||
elif section_type == "courses_by_year":
|
elif section_type == "courses_by_year":
|
||||||
total += len(section.get("courses") or [])
|
total += len(section.get("courses") or [])
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_list(values: Any) -> list[str]:
|
||||||
|
if values is None:
|
||||||
|
return []
|
||||||
|
if not isinstance(values, list):
|
||||||
|
values = [values]
|
||||||
|
return [str(value).strip() for value in values if str(value or "").strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _as_dict(value: Any) -> dict[str, Any]:
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value: Any) -> list[Any]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
return value if isinstance(value, list) else [value]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_contact_items(items: Any) -> list[str]:
|
||||||
|
normalized = []
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return normalized
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
value = item.get("raw") or item.get("value") or item.get("text")
|
||||||
|
else:
|
||||||
|
value = item
|
||||||
|
value = str(value or "").strip()
|
||||||
|
if value:
|
||||||
|
normalized.append(value)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_external_ids(items: Any) -> list[dict[str, str | None]]:
|
||||||
|
normalized = []
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return normalized
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
system = str(item.get("system") or "").strip()
|
||||||
|
value = str(item.get("value") or "").strip()
|
||||||
|
url = str(item.get("url") or "").strip()
|
||||||
|
if system or value or url:
|
||||||
|
normalized.append({"system": system or "ID", "value": value or url, "url": url or None})
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_section(section: Any) -> dict[str, Any]:
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
return {"title": "Раздел", "type": "generic", "paragraphs": [str(section)], "items": [], "links": []}
|
||||||
|
|
||||||
|
section_type = section.get("type") or "generic"
|
||||||
|
paragraphs = _clean_list(section.get("paragraphs"))
|
||||||
|
items = _clean_list(section.get("items"))
|
||||||
|
raw_text = str(section.get("raw_text") or "").strip()
|
||||||
|
if not paragraphs and not items and raw_text:
|
||||||
|
paragraphs = [raw_text]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": section.get("title") or "Раздел",
|
||||||
|
"type": section_type,
|
||||||
|
"raw_text": raw_text,
|
||||||
|
"paragraphs": paragraphs,
|
||||||
|
"list_items": items,
|
||||||
|
"links": _normalize_links(section.get("links")),
|
||||||
|
"year_entries": _normalize_year_entries(section.get("year_entries")),
|
||||||
|
"publications": _normalize_publications(section.get("publications")),
|
||||||
|
"publications_count": section.get("publications_count"),
|
||||||
|
"academic_year": section.get("academic_year"),
|
||||||
|
"courses": _normalize_courses(section.get("courses")),
|
||||||
|
"table": _normalize_table(section.get("table")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_links(items: Any) -> list[dict[str, str | None]]:
|
||||||
|
normalized = []
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return normalized
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
text = str(item.get("text") or item.get("url") or "").strip()
|
||||||
|
url = str(item.get("url") or "").strip()
|
||||||
|
if text and url:
|
||||||
|
normalized.append({"text": text, "url": url})
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_year_entries(items: Any) -> list[dict[str, Any]]:
|
||||||
|
normalized = []
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return normalized
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
text = str(item.get("text") or "").strip()
|
||||||
|
if text:
|
||||||
|
normalized.append({"year": item.get("year"), "text": text, "links": _normalize_links(item.get("links"))})
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_publications(items: Any) -> list[dict[str, str | None]]:
|
||||||
|
normalized = []
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return normalized
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
text = str(item or "").strip()
|
||||||
|
if text:
|
||||||
|
normalized.append({"title": text, "text": text, "url": None})
|
||||||
|
continue
|
||||||
|
title = str(item.get("title") or "").strip()
|
||||||
|
text = str(item.get("text") or title).strip()
|
||||||
|
url = str(item.get("url") or "").strip()
|
||||||
|
if title or text:
|
||||||
|
normalized.append({"title": title or text, "text": text or title, "url": url or None})
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_courses(items: Any) -> list[dict[str, str | None]]:
|
||||||
|
normalized = []
|
||||||
|
if not isinstance(items, list):
|
||||||
|
return normalized
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
title = str(item or "").strip()
|
||||||
|
if title:
|
||||||
|
normalized.append({"title": title, "url": None})
|
||||||
|
continue
|
||||||
|
title = str(item.get("title") or "").strip()
|
||||||
|
url = str(item.get("url") or "").strip()
|
||||||
|
if title or url:
|
||||||
|
normalized.append({"title": title or url, "url": url or None})
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_table(table: Any) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(table, dict):
|
||||||
|
return None
|
||||||
|
headers = _clean_list(table.get("headers"))
|
||||||
|
rows = []
|
||||||
|
for row in table.get("rows") or []:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
continue
|
||||||
|
cells = _clean_list(row.get("cells"))
|
||||||
|
if cells:
|
||||||
|
rows.append({"cells": cells, "link_url": row.get("link_url")})
|
||||||
|
if not headers and not rows:
|
||||||
|
return None
|
||||||
|
return {"headers": headers, "rows": rows}
|
||||||
|
|||||||
@@ -152,6 +152,165 @@
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.employee-card {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-card__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 22px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d9dee7;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-card__identity {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-card__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-card__section {
|
||||||
|
padding: 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d9dee7;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-card__meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-card__meta-item {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-card__meta-item--wide {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-card__meta-label {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-card__meta-value {
|
||||||
|
margin: 0;
|
||||||
|
color: #1f2937;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-card__list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-card__list-item {
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-card__sections {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section {
|
||||||
|
padding: 16px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section__header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section__title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section__type {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 3px 8px;
|
||||||
|
color: #475569;
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section__note {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #4b5563;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section__text {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section__table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section__table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section__head,
|
||||||
|
.employee-section__cell {
|
||||||
|
padding: 10px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section__head {
|
||||||
|
color: #374151;
|
||||||
|
background: #f3f4f6;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section__links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.employee-section__link {
|
||||||
|
padding: 5px 9px;
|
||||||
|
color: #0f766e;
|
||||||
|
background: #ccfbf1;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.stats-strip {
|
.stats-strip {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
@@ -399,7 +558,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-panel__header,
|
.progress-panel__header,
|
||||||
.directory__header {
|
.directory__header,
|
||||||
|
.employee-card__header {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@
|
|||||||
const errors = document.querySelector("[data-progress-errors]");
|
const errors = document.querySelector("[data-progress-errors]");
|
||||||
const fill = document.querySelector("[data-progress-fill]");
|
const fill = document.querySelector("[data-progress-fill]");
|
||||||
const percent = document.querySelector("[data-progress-percent]");
|
const percent = document.querySelector("[data-progress-percent]");
|
||||||
if (status) status.textContent = run.status;
|
if (status) status.textContent = run.status_display || run.status;
|
||||||
if (processed) processed.textContent = run.processed_count;
|
if (processed) processed.textContent = run.processed_count;
|
||||||
if (found) found.textContent = run.found_count;
|
if (found) found.textContent = run.found_count;
|
||||||
if (errors) errors.textContent = run.error_count;
|
if (errors) errors.textContent = run.error_count;
|
||||||
|
|||||||
@@ -10,12 +10,11 @@
|
|||||||
<header class="admin__header">
|
<header class="admin__header">
|
||||||
<h1 class="admin__brand">MIEM Employees</h1>
|
<h1 class="admin__brand">MIEM Employees</h1>
|
||||||
<nav class="admin__nav">
|
<nav class="admin__nav">
|
||||||
<a class="admin__link" href="/admin">Dashboard</a>
|
<a class="admin__link" href="/admin">Обзор</a>
|
||||||
<a class="admin__link" href="/admin/directory">Directory</a>
|
<a class="admin__link" href="/admin/directory">Сотрудники</a>
|
||||||
<a class="admin__link" href="/admin/employees">Employees</a>
|
<a class="admin__link" href="/admin/runs">Запуски</a>
|
||||||
<a class="admin__link" href="/admin/runs">Runs</a>
|
|
||||||
<form method="post" action="/admin/logout">
|
<form method="post" action="/admin/logout">
|
||||||
<button class="button button--ghost" type="submit">Logout</button>
|
<button class="button button--ghost" type="submit">Выйти</button>
|
||||||
</form>
|
</form>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Dashboard · MIEM Employees{% endblock %}
|
{% block title %}Обзор · MIEM Employees{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="admin__grid">
|
<section class="admin__grid">
|
||||||
<div class="metric"><div class="metric__label">Total</div><div class="metric__value">{{ counts.total }}</div></div>
|
<div class="metric"><div class="metric__label">Всего в базе</div><div class="metric__value">{{ counts.total }}</div></div>
|
||||||
<div class="metric"><div class="metric__label">Active</div><div class="metric__value">{{ counts.active }}</div></div>
|
<div class="metric"><div class="metric__label">Работают</div><div class="metric__value">{{ counts.active }}</div></div>
|
||||||
<div class="metric"><div class="metric__label">New in last run</div><div class="metric__value">{{ counts.new_in_last_run }}</div></div>
|
<div class="metric"><div class="metric__label">Новые за запуск</div><div class="metric__value">{{ counts.new_in_last_run }}</div></div>
|
||||||
<div class="metric"><div class="metric__label">Dismissed</div><div class="metric__value">{{ counts.dismissed }}</div></div>
|
<div class="metric"><div class="metric__label">Уволены</div><div class="metric__value">{{ counts.dismissed }}</div></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="stats-strip">
|
<section class="stats-strip">
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<span class="stats-strip__label">Latest added</span>
|
<span class="stats-strip__label">Последний добавленный</span>
|
||||||
{% if counts.latest_added %}
|
{% if counts.latest_added %}
|
||||||
<a class="stats-strip__value" href="/admin/employees/{{ counts.latest_added.id }}">{{ counts.latest_added.full_name or counts.latest_added.canonical_url }}</a>
|
<a class="stats-strip__value" href="/admin/employees/{{ counts.latest_added.id }}">{{ counts.latest_added.full_name or counts.latest_added.canonical_url }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="stats-strip__value">No employees yet</span>
|
<span class="stats-strip__value">Сотрудников пока нет</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<span class="stats-strip__label">Runs</span>
|
<span class="stats-strip__label">Запуски</span>
|
||||||
<span class="stats-strip__value">{{ counts.runs }}</span>
|
<span class="stats-strip__value">{{ counts.runs }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<span class="stats-strip__label">Errors</span>
|
<span class="stats-strip__label">Ошибки</span>
|
||||||
<span class="stats-strip__value">{{ counts.errors }}</span>
|
<span class="stats-strip__value">{{ counts.errors }}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel progress-panel" data-progress-panel>
|
<section class="panel progress-panel" data-progress-panel>
|
||||||
<div class="progress-panel__header">
|
<div class="progress-panel__header">
|
||||||
<h2 class="panel__title">Parsing progress</h2>
|
<h2 class="panel__title">Прогресс парсинга</h2>
|
||||||
<form method="post" action="/admin/crawl-now">
|
<form method="post" action="/admin/crawl-now">
|
||||||
<button class="button" type="submit">Start crawl now</button>
|
<button class="button" type="submit">Запустить парсинг</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% set run = counts.current_running_run or latest_run %}
|
{% set run = counts.current_running_run or latest_run %}
|
||||||
<div class="progress-panel__body" data-progress-body>
|
<div class="progress-panel__body" data-progress-body>
|
||||||
<div class="progress-panel__meta">
|
<div class="progress-panel__meta">
|
||||||
<span data-progress-status>{{ run.status if run else "idle" }}</span>
|
<span data-progress-status>{{ run.status_display if run else "Ожидание" }}</span>
|
||||||
<span><span data-progress-processed>{{ run.processed_count if run else 0 }}</span> / <span data-progress-found>{{ run.found_count if run else 0 }}</span> processed</span>
|
<span>обработано: <span data-progress-processed>{{ run.processed_count if run else 0 }}</span> / <span data-progress-found>{{ run.found_count if run else 0 }}</span></span>
|
||||||
<span><span data-progress-errors>{{ run.error_count if run else 0 }}</span> errors</span>
|
<span>ошибок: <span data-progress-errors>{{ run.error_count if run else 0 }}</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-bar" aria-label="Parsing progress">
|
<div class="progress-bar" aria-label="Parsing progress">
|
||||||
<div class="progress-bar__fill" data-progress-fill style="width: {{ run.progress_percent if run else 0 }}%"></div>
|
<div class="progress-bar__fill" data-progress-fill style="width: {{ run.progress_percent if run else 0 }}%"></div>
|
||||||
@@ -46,12 +46,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2 class="panel__title">Latest runs</h2>
|
<h2 class="panel__title">Последние запуски</h2>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th class="table__head">ID</th><th class="table__head">Status</th><th class="table__head">Parsed</th><th class="table__head">Errors</th><th class="table__head">Started</th></tr></thead>
|
<thead><tr><th class="table__head">ID</th><th class="table__head">Статус</th><th class="table__head">Обработано</th><th class="table__head">Ошибки</th><th class="table__head">Старт</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for run in runs %}
|
{% for run in runs %}
|
||||||
<tr><td class="table__cell">{{ run.id }}</td><td class="table__cell">{{ run.status }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.started_at }}</td></tr>
|
<tr><td class="table__cell">{{ run.id }}</td><td class="table__cell">{{ run.status_display }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.started_display }}</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,65 +1,65 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Directory · MIEM Employees{% endblock %}
|
{% block title %}Сотрудники · MIEM Employees{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="directory">
|
<section class="directory">
|
||||||
<div class="directory__header">
|
<div class="directory__header">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="directory__title">Directory</h2>
|
<h2 class="directory__title">Сотрудники</h2>
|
||||||
<p class="directory__summary">{{ page.total }} employees found</p>
|
<p class="directory__summary">Найдено: {{ page.total }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button class="button" type="button" data-columns-open>Columns</button>
|
<button class="button" type="button" data-columns-open>Колонки</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="directory__filters" method="get" action="/admin/directory">
|
<form class="directory__filters" method="get" action="/admin/directory">
|
||||||
<input class="directory__input" name="q" value="{{ filters.q }}" placeholder="Name or URL">
|
<input class="directory__input" name="q" value="{{ filters.q }}" placeholder="ФИО или ссылка">
|
||||||
<select class="directory__input" name="status">
|
<select class="directory__input" name="status">
|
||||||
<option value="" {% if not filters.status %}selected{% endif %}>All statuses</option>
|
<option value="" {% if not filters.status %}selected{% endif %}>Все статусы</option>
|
||||||
<option value="active" {% if filters.status == "active" %}selected{% endif %}>Active</option>
|
<option value="active" {% if filters.status == "active" %}selected{% endif %}>Работает</option>
|
||||||
<option value="dismissed" {% if filters.status == "dismissed" %}selected{% endif %}>Dismissed</option>
|
<option value="dismissed" {% if filters.status == "dismissed" %}selected{% endif %}>Уволен</option>
|
||||||
</select>
|
</select>
|
||||||
<select class="directory__input" name="has_email">
|
<select class="directory__input" name="has_email">
|
||||||
<option value="" {% if not filters.has_email %}selected{% endif %}>Any email</option>
|
<option value="" {% if not filters.has_email %}selected{% endif %}>Любой email</option>
|
||||||
<option value="true" {% if filters.has_email == "true" %}selected{% endif %}>Has email</option>
|
<option value="true" {% if filters.has_email == "true" %}selected{% endif %}>Есть email</option>
|
||||||
<option value="false" {% if filters.has_email == "false" %}selected{% endif %}>No email</option>
|
<option value="false" {% if filters.has_email == "false" %}selected{% endif %}>Нет email</option>
|
||||||
</select>
|
</select>
|
||||||
<input class="directory__input" type="date" name="started_from" value="{{ filters.started_from }}" aria-label="First seen from">
|
<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="First seen to">
|
<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">
|
||||||
{% for value, label in [("full_name", "Name"), ("status", "Status"), ("hse_start_year", "HSE start"), ("first_seen_at", "First seen"), ("last_seen_at", "Last seen"), ("dismissed_at", "Dismissed")] %}
|
{% for value, label in [("full_name", "ФИО"), ("status", "Статус"), ("hse_start_year", "Год начала"), ("first_seen_at", "Впервые найден"), ("last_seen_at", "Последний раз найден"), ("dismissed_at", "Дата увольнения")] %}
|
||||||
<option value="{{ value }}" {% if filters.sort == value %}selected{% endif %}>Sort: {{ label }}</option>
|
<option value="{{ value }}" {% if filters.sort == value %}selected{% endif %}>Сортировка: {{ label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<select class="directory__input" name="direction">
|
<select class="directory__input" name="direction">
|
||||||
<option value="asc" {% if filters.direction == "asc" %}selected{% endif %}>Ascending</option>
|
<option value="asc" {% if filters.direction == "asc" %}selected{% endif %}>По возрастанию</option>
|
||||||
<option value="desc" {% if filters.direction == "desc" %}selected{% endif %}>Descending</option>
|
<option value="desc" {% if filters.direction == "desc" %}selected{% endif %}>По убыванию</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="button" type="submit">Apply</button>
|
<button class="button" type="submit">Применить</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="directory__table-wrap">
|
<div class="directory__table-wrap">
|
||||||
<table class="directory-table" data-directory-table>
|
<table class="directory-table" data-directory-table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="directory-table__head" data-column="full_name">Name</th>
|
<th class="directory-table__head" data-column="full_name">ФИО</th>
|
||||||
<th class="directory-table__head" data-column="status">Status</th>
|
<th class="directory-table__head" data-column="status">Статус</th>
|
||||||
<th class="directory-table__head" data-column="positions">Positions</th>
|
<th class="directory-table__head" data-column="positions">Должности</th>
|
||||||
<th class="directory-table__head" data-column="hse_start_year">HSE start</th>
|
<th class="directory-table__head" data-column="hse_start_year">Год начала</th>
|
||||||
<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">Phone</th>
|
<th class="directory-table__head" data-column="phone">Телефон</th>
|
||||||
<th class="directory-table__head" data-column="address">Address</th>
|
<th class="directory-table__head" data-column="address">Адрес</th>
|
||||||
<th class="directory-table__head" data-column="publications_count">Publications</th>
|
<th class="directory-table__head" data-column="publications_count">Публикации</th>
|
||||||
<th class="directory-table__head" data-column="courses_count">Courses</th>
|
<th class="directory-table__head" data-column="courses_count">Курсы</th>
|
||||||
<th class="directory-table__head" data-column="first_seen_at">First seen</th>
|
<th class="directory-table__head" data-column="first_seen_at">Впервые найден</th>
|
||||||
<th class="directory-table__head" data-column="last_seen_at">Last seen</th>
|
<th class="directory-table__head" data-column="last_seen_at">Последний раз найден</th>
|
||||||
<th class="directory-table__head" data-column="dismissed_at">Dismissed</th>
|
<th class="directory-table__head" data-column="dismissed_at">Дата увольнения</th>
|
||||||
<th class="directory-table__head" data-column="profile">Profile</th>
|
<th class="directory-table__head" data-column="profile">Профиль</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for employee in page.items %}
|
{% for employee in page.employees %}
|
||||||
<tr class="directory-table__row" data-row-href="/admin/employees/{{ employee.id }}">
|
<tr class="directory-table__row" data-row-href="/admin/employees/{{ employee.id }}">
|
||||||
<td class="directory-table__cell" data-column="full_name">{{ employee.full_name or "No name" }}</td>
|
<td class="directory-table__cell" data-column="full_name">{{ employee.full_name or "Без имени" }}</td>
|
||||||
<td class="directory-table__cell" data-column="status"><span class="badge {% if employee.status == "dismissed" %}badge--dismissed{% endif %}">{{ employee.status }}</span></td>
|
<td class="directory-table__cell" data-column="status"><span class="badge {% if employee.status == "dismissed" %}badge--dismissed{% endif %}">{{ employee.status_display }}</span></td>
|
||||||
<td class="directory-table__cell" data-column="positions">{{ employee.positions_text }}</td>
|
<td class="directory-table__cell" data-column="positions">{{ employee.positions_text }}</td>
|
||||||
<td class="directory-table__cell" data-column="hse_start_year">{{ employee.hse_start_year or "" }}</td>
|
<td class="directory-table__cell" data-column="hse_start_year">{{ employee.hse_start_year or "" }}</td>
|
||||||
<td class="directory-table__cell" data-column="email">{{ employee.email_text }}</td>
|
<td class="directory-table__cell" data-column="email">{{ employee.email_text }}</td>
|
||||||
@@ -67,13 +67,13 @@
|
|||||||
<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="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="first_seen_at">{{ employee.first_seen_at or "" }}</td>
|
<td class="directory-table__cell" data-column="first_seen_at">{{ employee.first_seen_display }}</td>
|
||||||
<td class="directory-table__cell" data-column="last_seen_at">{{ employee.last_seen_at or "" }}</td>
|
<td class="directory-table__cell" data-column="last_seen_at">{{ employee.last_seen_display }}</td>
|
||||||
<td class="directory-table__cell" data-column="dismissed_at">{{ employee.dismissed_at or "" }}</td>
|
<td class="directory-table__cell" data-column="dismissed_at">{{ employee.dismissed_display }}</td>
|
||||||
<td class="directory-table__cell" data-column="profile"><a class="admin__link" href="{{ employee.canonical_url }}">Open</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="13">No employees match these filters.</td></tr>
|
<tr><td class="directory-table__empty" colspan="13">По этим фильтрам сотрудники не найдены.</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -83,24 +83,24 @@
|
|||||||
{% set prev_offset = filters.offset - filters.limit %}
|
{% set prev_offset = filters.offset - filters.limit %}
|
||||||
{% set next_offset = filters.offset + filters.limit %}
|
{% set next_offset = filters.offset + filters.limit %}
|
||||||
{% if filters.offset > 0 %}
|
{% if filters.offset > 0 %}
|
||||||
<a class="admin__link" href="{{ request.url.include_query_params(offset=prev_offset) }}">Previous</a>
|
<a class="admin__link" href="{{ request.url.include_query_params(offset=prev_offset) }}">Назад</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<span class="directory__page">Page {{ page.page }}{% if page.pages %} of {{ page.pages }}{% endif %}</span>
|
<span class="directory__page">Страница {{ page.page }}{% if page.pages %} из {{ page.pages }}{% endif %}</span>
|
||||||
{% if next_offset < page.total %}
|
{% if next_offset < page.total %}
|
||||||
<a class="admin__link" href="{{ request.url.include_query_params(offset=next_offset) }}">Next</a>
|
<a class="admin__link" href="{{ request.url.include_query_params(offset=next_offset) }}">Вперед</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="columns-modal" data-columns-modal hidden>
|
<div class="columns-modal" data-columns-modal hidden>
|
||||||
<div class="columns-modal__backdrop" data-columns-close></div>
|
<div class="columns-modal__backdrop" data-columns-close></div>
|
||||||
<section class="columns-modal__panel" aria-label="Column settings">
|
<section class="columns-modal__panel" aria-label="Настройка колонок">
|
||||||
<div class="columns-modal__header">
|
<div class="columns-modal__header">
|
||||||
<h3 class="columns-modal__title">Visible columns</h3>
|
<h3 class="columns-modal__title">Отображаемые колонки</h3>
|
||||||
<button class="button button--ghost" type="button" data-columns-close>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", "Name"), ("status", "Status"), ("positions", "Positions"), ("hse_start_year", "HSE start"), ("email", "Email"), ("phone", "Phone"), ("address", "Address"), ("publications_count", "Publications"), ("courses_count", "Courses"), ("first_seen_at", "First seen"), ("last_seen_at", "Last seen"), ("dismissed_at", "Dismissed"), ("profile", "Profile")] %}
|
{% for key, label in [("full_name", "ФИО"), ("status", "Статус"), ("positions", "Должности"), ("hse_start_year", "Год начала"), ("email", "Email"), ("phone", "Телефон"), ("address", "Адрес"), ("publications_count", "Публикации"), ("courses_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,26 +1,198 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}{{ employee.full_name }} · MIEM Employees{% endblock %}
|
{% block title %}{{ employee_view.full_name }} · MIEM Employees{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="employee-card">
|
||||||
<h2 class="panel__title">{{ employee.full_name or employee.profile_key }}</h2>
|
<div class="employee-card__header">
|
||||||
<p><span class="badge {% if employee.status == "dismissed" %}badge--dismissed{% endif %}">{{ employee.status }}</span></p>
|
<div class="employee-card__identity">
|
||||||
<p><a class="admin__link" href="{{ employee.canonical_url }}">{{ employee.canonical_url }}</a></p>
|
<h2 class="employee-card__title">{{ employee_view.full_name or employee.profile_key }}</h2>
|
||||||
<h3>Tabs</h3>
|
<span class="badge {% if employee_view.status == "dismissed" %}badge--dismissed{% endif %}">{{ employee_view.status_display }}</span>
|
||||||
<ul>
|
</div>
|
||||||
{% for tab in employee.tabs %}
|
<a class="admin__link" href="{{ employee_view.canonical_url }}">{{ employee_view.canonical_url }}</a>
|
||||||
<li><a class="admin__link" href="{{ tab.href }}">{{ tab.title }}</a></li>
|
</div>
|
||||||
|
|
||||||
|
<section class="employee-card__section">
|
||||||
|
<h3 class="employee-section__title">Основная информация</h3>
|
||||||
|
<dl class="employee-card__meta">
|
||||||
|
<div class="employee-card__meta-item">
|
||||||
|
<dt class="employee-card__meta-label">Должности</dt>
|
||||||
|
<dd class="employee-card__meta-value">
|
||||||
|
{% if employee_view.positions %}
|
||||||
|
<ul class="employee-card__list">
|
||||||
|
{% for position in employee_view.positions %}
|
||||||
|
<li class="employee-card__list-item">{{ position }}</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<h3>Current data</h3>
|
{% else %}
|
||||||
<pre class="code">{{ employee.current_data | tojson(indent=2) }}</pre>
|
Не указано
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="employee-card__meta-item"><dt class="employee-card__meta-label">Год начала работы в ВШЭ</dt><dd class="employee-card__meta-value">{{ employee_view.hse_start_year or "Не указано" }}</dd></div>
|
||||||
|
<div class="employee-card__meta-item"><dt class="employee-card__meta-label">Тип профиля</dt><dd class="employee-card__meta-value">{{ employee_view.profile_type or "Не указано" }}</dd></div>
|
||||||
|
<div class="employee-card__meta-item"><dt class="employee-card__meta-label">ID профиля</dt><dd class="employee-card__meta-value">{{ employee_view.profile_id or "Не указано" }}</dd></div>
|
||||||
|
<div class="employee-card__meta-item"><dt class="employee-card__meta-label">Впервые найден</dt><dd class="employee-card__meta-value">{{ employee_view.first_seen_display }}</dd></div>
|
||||||
|
<div class="employee-card__meta-item"><dt class="employee-card__meta-label">Последний раз найден</dt><dd class="employee-card__meta-value">{{ employee_view.last_seen_display }}</dd></div>
|
||||||
|
<div class="employee-card__meta-item"><dt class="employee-card__meta-label">Дата увольнения</dt><dd class="employee-card__meta-value">{{ employee_view.dismissed_display }}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="employee-card__section">
|
||||||
|
<h3 class="employee-section__title">Контакты</h3>
|
||||||
|
<dl class="employee-card__meta">
|
||||||
|
<div class="employee-card__meta-item">
|
||||||
|
<dt class="employee-card__meta-label">Email</dt>
|
||||||
|
<dd class="employee-card__meta-value">
|
||||||
|
{% if employee_view.contacts.emails %}
|
||||||
|
<ul class="employee-card__list">
|
||||||
|
{% for email in employee_view.contacts.emails %}
|
||||||
|
<li class="employee-card__list-item"><a class="admin__link" href="mailto:{{ email }}">{{ email }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
Не указано
|
||||||
|
{% endif %}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div class="employee-card__meta-item">
|
||||||
|
<dt class="employee-card__meta-label">Телефоны</dt>
|
||||||
|
<dd class="employee-card__meta-value">{{ employee_view.contacts.phones | join(", ") if employee_view.contacts.phones else "Не указано" }}</dd>
|
||||||
|
</div>
|
||||||
|
<div class="employee-card__meta-item">
|
||||||
|
<dt class="employee-card__meta-label">Адрес</dt>
|
||||||
|
<dd class="employee-card__meta-value">{{ employee_view.contacts.address or "Не указано" }}</dd>
|
||||||
|
</div>
|
||||||
|
{% if employee_view.contacts.contact_items %}
|
||||||
|
<div class="employee-card__meta-item employee-card__meta-item--wide">
|
||||||
|
<dt class="employee-card__meta-label">Прочее</dt>
|
||||||
|
<dd class="employee-card__meta-value">
|
||||||
|
<ul class="employee-card__list">
|
||||||
|
{% for item in employee_view.contacts.contact_items %}
|
||||||
|
<li class="employee-card__list-item">{{ item }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{% if employee_view.external_ids %}
|
||||||
|
<section class="employee-card__section">
|
||||||
|
<h3 class="employee-section__title">Внешние идентификаторы</h3>
|
||||||
|
<ul class="employee-card__list">
|
||||||
|
{% for external_id in employee_view.external_ids %}
|
||||||
|
<li class="employee-card__list-item">
|
||||||
|
<strong>{{ external_id.system }}:</strong>
|
||||||
|
{% if external_id.url %}
|
||||||
|
<a class="admin__link" href="{{ external_id.url }}">{{ external_id.value }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ external_id.value }}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section class="employee-card__section">
|
||||||
|
<h3 class="employee-section__title">Разделы профиля</h3>
|
||||||
|
{% if employee_view.sections %}
|
||||||
|
<div class="employee-card__sections">
|
||||||
|
{% for section in employee_view.sections %}
|
||||||
|
<article class="employee-section">
|
||||||
|
<div class="employee-section__header">
|
||||||
|
<h4 class="employee-section__title">{{ section.title }}</h4>
|
||||||
|
<span class="employee-section__type">{{ section.type }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if section.type == "year_blocks" and section.year_entries %}
|
||||||
|
<ul class="employee-card__list">
|
||||||
|
{% for entry in section.year_entries %}
|
||||||
|
<li class="employee-card__list-item">{% if entry.year %}<strong>{{ entry.year }}:</strong> {% endif %}{{ entry.text }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% elif section.type == "publications" and section.publications %}
|
||||||
|
{% if section.publications_count %}<p class="employee-section__note">Всего: {{ section.publications_count }}</p>{% endif %}
|
||||||
|
<ul class="employee-card__list">
|
||||||
|
{% for publication in section.publications %}
|
||||||
|
<li class="employee-card__list-item">
|
||||||
|
{% if publication.url %}
|
||||||
|
<a class="admin__link" href="{{ publication.url }}">{{ publication.title }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ publication.title }}
|
||||||
|
{% endif %}
|
||||||
|
{% if publication.text and publication.text != publication.title %}<div class="employee-section__text">{{ publication.text }}</div>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% elif section.type == "courses_by_year" and section.courses %}
|
||||||
|
{% if section.academic_year %}<p class="employee-section__note">Учебный год: {{ section.academic_year }}</p>{% endif %}
|
||||||
|
<ul class="employee-card__list">
|
||||||
|
{% for course in section.courses %}
|
||||||
|
<li class="employee-card__list-item">
|
||||||
|
{% if course.url %}
|
||||||
|
<a class="admin__link" href="{{ course.url }}">{{ course.title }}</a>
|
||||||
|
{% else %}
|
||||||
|
{{ course.title }}
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% elif section.type == "table" and section.table %}
|
||||||
|
<div class="employee-section__table-wrap">
|
||||||
|
<table class="employee-section__table">
|
||||||
|
{% if section.table.headers %}
|
||||||
|
<thead><tr>{% for header in section.table.headers %}<th class="employee-section__head">{{ header }}</th>{% endfor %}</tr></thead>
|
||||||
|
{% endif %}
|
||||||
|
<tbody>
|
||||||
|
{% for row in section.table.rows %}
|
||||||
|
<tr>
|
||||||
|
{% for cell in row.cells %}
|
||||||
|
<td class="employee-section__cell">{{ cell }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{% if section.paragraphs %}
|
||||||
|
{% for paragraph in section.paragraphs %}
|
||||||
|
<p class="employee-section__text">{{ paragraph }}</p>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% if section.list_items %}
|
||||||
|
<ul class="employee-card__list">
|
||||||
|
{% for item in section.list_items %}
|
||||||
|
<li class="employee-card__list-item">{{ item }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if section.links and section.type not in ["courses_by_year"] %}
|
||||||
|
<div class="employee-section__links">
|
||||||
|
{% for link in section.links %}
|
||||||
|
<a class="employee-section__link" href="{{ link.url }}">{{ link.text }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="employee-section__text">Разделы профиля не найдены.</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2 class="panel__title">Snapshots</h2>
|
<h2 class="panel__title">Снапшоты</h2>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th class="table__head">Captured</th><th class="table__head">Checksum</th><th class="table__head">Parser</th></tr></thead>
|
<thead><tr><th class="table__head">Дата</th><th class="table__head">Checksum</th><th class="table__head">Парсер</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for snapshot in snapshots %}
|
{% for snapshot in snapshots %}
|
||||||
<tr><td class="table__cell">{{ snapshot.captured_at }}</td><td class="table__cell">{{ snapshot.checksum }}</td><td class="table__cell">{{ snapshot.parser_version }}</td></tr>
|
<tr><td class="table__cell">{{ snapshot.captured_display }}</td><td class="table__cell">{{ snapshot.checksum }}</td><td class="table__cell">{{ snapshot.parser_version }}</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% block title %}Employees · MIEM Employees{% endblock %}
|
|
||||||
{% block content %}
|
|
||||||
<section class="panel">
|
|
||||||
<h2 class="panel__title">Employees</h2>
|
|
||||||
<form class="form" method="get" action="/admin/employees">
|
|
||||||
<input class="form__input" name="q" value="{{ q }}" placeholder="Name or URL">
|
|
||||||
<select class="form__select" name="status">
|
|
||||||
<option value="" {% if not status %}selected{% endif %}>All</option>
|
|
||||||
<option value="active" {% if status == "active" %}selected{% endif %}>Active</option>
|
|
||||||
<option value="dismissed" {% if status == "dismissed" %}selected{% endif %}>Dismissed</option>
|
|
||||||
</select>
|
|
||||||
<button class="button" type="submit">Search</button>
|
|
||||||
</form>
|
|
||||||
<table class="table">
|
|
||||||
<thead><tr><th class="table__head">Name</th><th class="table__head">Status</th><th class="table__head">Last seen</th><th class="table__head">Profile</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for employee in employees %}
|
|
||||||
<tr>
|
|
||||||
<td class="table__cell"><a class="admin__link" href="/admin/employees/{{ employee.id }}">{{ employee.full_name or employee.profile_key }}</a></td>
|
|
||||||
<td class="table__cell"><span class="badge {% if employee.status == "dismissed" %}badge--dismissed{% endif %}">{{ employee.status }}</span></td>
|
|
||||||
<td class="table__cell">{{ employee.last_seen_at }}</td>
|
|
||||||
<td class="table__cell"><a class="admin__link" href="{{ employee.canonical_url }}">{{ employee.canonical_url }}</a></td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -3,18 +3,18 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Login · MIEM Employees</title>
|
<title>Вход · MIEM Employees</title>
|
||||||
<link rel="stylesheet" href="/static/admin.css">
|
<link rel="stylesheet" href="/static/admin.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="admin">
|
<body class="admin">
|
||||||
<main class="admin__main">
|
<main class="admin__main">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h1 class="panel__title">Admin login</h1>
|
<h1 class="panel__title">Вход в админку</h1>
|
||||||
{% if error %}<p>{{ error }}</p>{% endif %}
|
{% if error %}<p>{{ error }}</p>{% endif %}
|
||||||
<form class="form" method="post" action="/admin/login">
|
<form class="form" method="post" action="/admin/login">
|
||||||
<label class="form__label">Login <input class="form__input" name="username" autocomplete="username"></label>
|
<label class="form__label">Логин <input class="form__input" name="username" autocomplete="username"></label>
|
||||||
<label class="form__label">Password <input class="form__input" name="password" type="password" autocomplete="current-password"></label>
|
<label class="form__label">Пароль <input class="form__input" name="password" type="password" autocomplete="current-password"></label>
|
||||||
<button class="button" type="submit">Sign in</button>
|
<button class="button" type="submit">Войти</button>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Runs · MIEM Employees{% endblock %}
|
{% block title %}Запуски · MIEM Employees{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="progress-panel__header">
|
<div class="progress-panel__header">
|
||||||
<h2 class="panel__title">Crawl runs</h2>
|
<h2 class="panel__title">Запуски парсинга</h2>
|
||||||
<form method="post" action="/admin/runs"><button class="button" type="submit">Start crawl now</button></form>
|
<form method="post" action="/admin/runs"><button class="button" type="submit">Запустить парсинг</button></form>
|
||||||
</div>
|
</div>
|
||||||
{% set run = runs[0] if runs else none %}
|
{% set run = runs[0] if runs else none %}
|
||||||
{% if run %}
|
{% if run %}
|
||||||
@@ -12,9 +12,9 @@
|
|||||||
{% set percent = ((processed / run.found_count) * 100) | round(1) if run.found_count else 0 %}
|
{% set percent = ((processed / run.found_count) * 100) | round(1) if run.found_count else 0 %}
|
||||||
<div class="progress-panel" data-progress-panel>
|
<div class="progress-panel" data-progress-panel>
|
||||||
<div class="progress-panel__meta">
|
<div class="progress-panel__meta">
|
||||||
<span data-progress-status>{{ run.status }}</span>
|
<span data-progress-status>{{ run.status_display }}</span>
|
||||||
<span><span data-progress-processed>{{ processed }}</span> / <span data-progress-found>{{ run.found_count }}</span> processed</span>
|
<span>обработано: <span data-progress-processed>{{ processed }}</span> / <span data-progress-found>{{ run.found_count }}</span></span>
|
||||||
<span><span data-progress-errors>{{ run.error_count }}</span> errors</span>
|
<span>ошибок: <span data-progress-errors>{{ run.error_count }}</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-bar" aria-label="Parsing progress">
|
<div class="progress-bar" aria-label="Parsing progress">
|
||||||
<div class="progress-bar__fill" data-progress-fill style="width: {{ percent }}%"></div>
|
<div class="progress-bar__fill" data-progress-fill style="width: {{ percent }}%"></div>
|
||||||
@@ -24,9 +24,9 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<div class="progress-panel" data-progress-panel>
|
<div class="progress-panel" data-progress-panel>
|
||||||
<div class="progress-panel__meta">
|
<div class="progress-panel__meta">
|
||||||
<span data-progress-status>idle</span>
|
<span data-progress-status>Ожидание</span>
|
||||||
<span><span data-progress-processed>0</span> / <span data-progress-found>0</span> processed</span>
|
<span>обработано: <span data-progress-processed>0</span> / <span data-progress-found>0</span></span>
|
||||||
<span><span data-progress-errors>0</span> errors</span>
|
<span>ошибок: <span data-progress-errors>0</span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="progress-bar" aria-label="Parsing progress">
|
<div class="progress-bar" aria-label="Parsing progress">
|
||||||
<div class="progress-bar__fill" data-progress-fill style="width: 0%"></div>
|
<div class="progress-bar__fill" data-progress-fill style="width: 0%"></div>
|
||||||
@@ -35,18 +35,18 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th class="table__head">ID</th><th class="table__head">Status</th><th class="table__head">Found</th><th class="table__head">Parsed</th><th class="table__head">New</th><th class="table__head">Errors</th><th class="table__head">Dismissed</th></tr></thead>
|
<thead><tr><th class="table__head">ID</th><th class="table__head">Статус</th><th class="table__head">Найдено</th><th class="table__head">Обработано</th><th class="table__head">Новые</th><th class="table__head">Ошибки</th><th class="table__head">Уволены</th><th class="table__head">Старт</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for run in runs %}
|
{% for run in runs %}
|
||||||
<tr><td class="table__cell">{{ run.id }}</td><td class="table__cell">{{ run.status }}</td><td class="table__cell">{{ run.found_count }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.new_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.dismissed_count }}</td></tr>
|
<tr><td class="table__cell">{{ run.id }}</td><td class="table__cell">{{ run.status_display }}</td><td class="table__cell">{{ run.found_count }}</td><td class="table__cell">{{ run.parsed_count }}</td><td class="table__cell">{{ run.new_count }}</td><td class="table__cell">{{ run.error_count }}</td><td class="table__cell">{{ run.dismissed_count }}</td><td class="table__cell">{{ run.started_display }}</td></tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2 class="panel__title">Recent errors</h2>
|
<h2 class="panel__title">Последние ошибки</h2>
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead><tr><th class="table__head">Run</th><th class="table__head">Profile</th><th class="table__head">Error</th></tr></thead>
|
<thead><tr><th class="table__head">Запуск</th><th class="table__head">Профиль</th><th class="table__head">Ошибка</th></tr></thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for error in errors %}
|
{% for error in errors %}
|
||||||
<tr><td class="table__cell">{{ error.crawl_run_id }}</td><td class="table__cell">{{ error.profile_url }}</td><td class="table__cell">{{ error.error_type }}: {{ error.message }}</td></tr>
|
<tr><td class="table__cell">{{ error.crawl_run_id }}</td><td class="table__cell">{{ error.profile_url }}</td><td class="table__cell">{{ error.error_type }}: {{ error.message }}</td></tr>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
APP_VERSION = "0.2.1"
|
APP_VERSION = "0.2.7"
|
||||||
FRONTEND_VERSION = "0.2.1"
|
FRONTEND_VERSION = "0.2.7"
|
||||||
BACKEND_VERSION = "0.2.1"
|
BACKEND_VERSION = "0.2.7"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "miem-workers"
|
name = "miem-workers"
|
||||||
version = "0.1.0"
|
version = "0.2.6"
|
||||||
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 = [
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.models import CrawlRun, Employee
|
from app.models import CrawlRun, Employee
|
||||||
from app.services.admin_data import employee_display_payload, list_employees_page, run_payload, stats_payload
|
from app.services.admin_data import (
|
||||||
|
employee_detail_payload,
|
||||||
|
employee_display_payload,
|
||||||
|
format_admin_datetime,
|
||||||
|
list_employees_page,
|
||||||
|
run_payload,
|
||||||
|
stats_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_admin_datetime_handles_datetime_string_and_none():
|
||||||
|
value = datetime(2026, 4, 28, 17, 13, 34, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
assert format_admin_datetime(value) == "28.04.2026 20:13"
|
||||||
|
assert format_admin_datetime("2026-04-28T17:13:34.448605+00:00") == "28.04.2026 20:13"
|
||||||
|
assert format_admin_datetime(None) == "Не указано"
|
||||||
|
|
||||||
|
|
||||||
def test_employee_display_payload_extracts_common_fields(db_session):
|
def test_employee_display_payload_extracts_common_fields(db_session):
|
||||||
@@ -26,9 +41,89 @@ def test_employee_display_payload_extracts_common_fields(db_session):
|
|||||||
payload = employee_display_payload(employee)
|
payload = employee_display_payload(employee)
|
||||||
|
|
||||||
assert payload["positions_text"] == "Professor"
|
assert payload["positions_text"] == "Professor"
|
||||||
|
assert payload["status_display"] == "Работает"
|
||||||
assert payload["email_text"] == "person@hse.ru"
|
assert payload["email_text"] == "person@hse.ru"
|
||||||
assert payload["publications_count"] == 1
|
assert payload["publications_count"] == 1
|
||||||
assert payload["courses_count"] == 1
|
assert payload["courses_count"] == 1
|
||||||
|
assert payload["first_seen_display"] != "Не указано"
|
||||||
|
|
||||||
|
|
||||||
|
def test_employee_detail_payload_normalizes_human_readable_sections(db_session):
|
||||||
|
employee = Employee(
|
||||||
|
profile_key="staff:person",
|
||||||
|
profile_type="staff",
|
||||||
|
profile_id="person",
|
||||||
|
canonical_url="https://www.hse.ru/staff/person",
|
||||||
|
full_name="Person Name",
|
||||||
|
status="active",
|
||||||
|
first_seen_at=datetime.now(timezone.utc),
|
||||||
|
last_seen_at=datetime.now(timezone.utc),
|
||||||
|
current_data={
|
||||||
|
"positions": ["Professor"],
|
||||||
|
"hse_start_year": 2024,
|
||||||
|
"contacts": {
|
||||||
|
"emails": ["person@hse.ru"],
|
||||||
|
"phones": ["+79990000000"],
|
||||||
|
"address": "Moscow",
|
||||||
|
"items": [{"raw": "consultation hours"}],
|
||||||
|
},
|
||||||
|
"external_ids": [{"system": "ORCID", "value": "0000", "url": "https://orcid.org/0000"}],
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"title": "Education",
|
||||||
|
"type": "year_blocks",
|
||||||
|
"year_entries": [{"year": 2020, "text": "Master degree"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Publications",
|
||||||
|
"type": "publications",
|
||||||
|
"publications": [{"title": "Paper", "text": "Paper details", "url": "https://example.test/paper"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Courses",
|
||||||
|
"type": "courses_by_year",
|
||||||
|
"academic_year": "2025/2026",
|
||||||
|
"courses": [{"title": "Course", "url": "https://example.test/course"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "Fallback",
|
||||||
|
"type": "generic",
|
||||||
|
"raw_text": "Fallback text",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = employee_detail_payload(employee)
|
||||||
|
|
||||||
|
assert payload["contacts"]["emails"] == ["person@hse.ru"]
|
||||||
|
assert payload["contacts"]["contact_items"] == ["consultation hours"]
|
||||||
|
assert payload["external_ids"][0]["system"] == "ORCID"
|
||||||
|
assert payload["sections"][0]["year_entries"][0]["text"] == "Master degree"
|
||||||
|
assert payload["sections"][1]["publications"][0]["title"] == "Paper"
|
||||||
|
assert payload["sections"][2]["courses"][0]["title"] == "Course"
|
||||||
|
assert payload["sections"][3]["paragraphs"] == ["Fallback text"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_employee_payloads_tolerate_malformed_current_data(db_session):
|
||||||
|
employee = Employee(
|
||||||
|
profile_key="staff:broken",
|
||||||
|
canonical_url="https://www.hse.ru/staff/broken",
|
||||||
|
full_name="Broken Data",
|
||||||
|
status="active",
|
||||||
|
first_seen_at=datetime.now(timezone.utc),
|
||||||
|
last_seen_at=datetime.now(timezone.utc),
|
||||||
|
current_data="not-a-dict",
|
||||||
|
)
|
||||||
|
|
||||||
|
display = employee_display_payload(employee)
|
||||||
|
detail = employee_detail_payload(employee)
|
||||||
|
|
||||||
|
assert display["positions"] == []
|
||||||
|
assert display["email_text"] == ""
|
||||||
|
assert detail["contacts"]["emails"] == []
|
||||||
|
assert detail["contacts"]["contact_items"] == []
|
||||||
|
assert detail["sections"] == []
|
||||||
|
|
||||||
|
|
||||||
def test_list_employees_page_filters_sorts_and_paginates(db_session):
|
def test_list_employees_page_filters_sorts_and_paginates(db_session):
|
||||||
@@ -59,7 +154,7 @@ def test_list_employees_page_filters_sorts_and_paginates(db_session):
|
|||||||
page = list_employees_page(db_session, status="active", sort="full_name", direction="asc", limit=10)
|
page = list_employees_page(db_session, status="active", sort="full_name", direction="asc", limit=10)
|
||||||
|
|
||||||
assert page["total"] == 1
|
assert page["total"] == 1
|
||||||
assert page["items"][0]["full_name"] == "Alpha"
|
assert page["employees"][0]["full_name"] == "Alpha"
|
||||||
|
|
||||||
|
|
||||||
def test_stats_payload_uses_latest_run_new_count(db_session):
|
def test_stats_payload_uses_latest_run_new_count(db_session):
|
||||||
@@ -96,3 +191,4 @@ def test_run_payload_calculates_progress():
|
|||||||
|
|
||||||
assert payload["processed_count"] == 5
|
assert payload["processed_count"] == 5
|
||||||
assert payload["progress_percent"] == 50.0
|
assert payload["progress_percent"] == 50.0
|
||||||
|
assert payload["status_display"] == "Выполняется"
|
||||||
|
|||||||
32
tests/test_admin_templates.py
Normal file
32
tests/test_admin_templates.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_navigation_is_russian_and_has_no_legacy_employees_link():
|
||||||
|
template = Path("app/templates/base.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "Обзор" in template
|
||||||
|
assert "Сотрудники" in template
|
||||||
|
assert "Запуски" in template
|
||||||
|
assert "Выйти" in template
|
||||||
|
assert ">Employees<" not in template
|
||||||
|
assert "/admin/employees" not in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_directory_template_is_russian_and_uses_display_dates():
|
||||||
|
template = Path("app/templates/directory.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "Сотрудники" in template
|
||||||
|
assert "Колонки" in template
|
||||||
|
assert "Применить" in template
|
||||||
|
assert "Найдено:" in template
|
||||||
|
assert "employee.first_seen_display" in template
|
||||||
|
assert "employee.last_seen_display" in template
|
||||||
|
assert "employee.dismissed_display" in template
|
||||||
|
assert "Directory" not in template
|
||||||
|
assert "employees found" not in template
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_employees_route_redirects_to_directory():
|
||||||
|
source = Path("app/admin.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'RedirectResponse("/admin/directory", status_code=303)' in source
|
||||||
@@ -18,7 +18,7 @@ def test_health_returns_versions():
|
|||||||
response = client.get("/api/health")
|
response = client.get("/api/health")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["backend_version"] == "0.2.1"
|
assert response.json()["backend_version"] == "0.2.7"
|
||||||
|
|
||||||
|
|
||||||
def test_mcp_requires_token_and_lists_tools():
|
def test_mcp_requires_token_and_lists_tools():
|
||||||
|
|||||||
13
tests/test_config.py
Normal file
13
tests/test_config.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_crawl_limit_is_treated_as_none():
|
||||||
|
settings = Settings(crawl_limit="")
|
||||||
|
|
||||||
|
assert settings.crawl_limit is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_numeric_crawl_limit_is_parsed():
|
||||||
|
settings = Settings(crawl_limit="25")
|
||||||
|
|
||||||
|
assert settings.crawl_limit == 25
|
||||||
28
tests/test_employee_detail_template.py
Normal file
28
tests/test_employee_detail_template.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def test_employee_detail_template_is_human_readable():
|
||||||
|
template = Path("app/templates/employee_detail.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert "Current data" not in template
|
||||||
|
assert "<pre class=\"code\"" not in template
|
||||||
|
assert ">Tabs<" not in template
|
||||||
|
assert "contacts.items" not in template
|
||||||
|
assert "contacts.contact_items" in template
|
||||||
|
assert "section.items" not in template
|
||||||
|
assert "section.list_items" in template
|
||||||
|
assert "Основная информация" in template
|
||||||
|
assert "Контакты" in template
|
||||||
|
assert "Разделы профиля" in template
|
||||||
|
assert "Parser version" not in template
|
||||||
|
assert "First seen" not in template
|
||||||
|
assert "Last seen" not in template
|
||||||
|
assert "Dismissed at" not in template
|
||||||
|
assert "Profile type" not in template
|
||||||
|
assert "Profile ID" not in template
|
||||||
|
assert "Впервые найден" in template
|
||||||
|
assert "Последний раз найден" in template
|
||||||
|
assert "Дата увольнения" in template
|
||||||
|
assert "Тип профиля" in template
|
||||||
|
assert "ID профиля" in template
|
||||||
|
assert "Снапшоты" in template
|
||||||
Reference in New Issue
Block a user