fix: restore profile verification schema #31
@@ -1,5 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.3
|
||||
|
||||
- Восстановлена проверка профилей сотрудников и хранение истории URL профиля.
|
||||
|
||||
## 0.7.2
|
||||
|
||||
- В каталоге сотрудников добавлены фильтр и колонка учёной степени.
|
||||
|
||||
@@ -147,4 +147,4 @@ docker compose exec postgres pg_dump -U miem miem_workers > backup.sql
|
||||
docker compose down
|
||||
```
|
||||
|
||||
Версия сервиса: `0.7.1`. Админка всегда показывает версии backend и frontend в footer.
|
||||
Версия сервиса: `0.7.3`. Админка всегда показывает версии backend и frontend в footer.
|
||||
|
||||
14
app/db.py
14
app/db.py
@@ -41,6 +41,20 @@ def _ensure_runtime_schema() -> None:
|
||||
models.EmployeeNewsLink.__table__.create(bind=engine, checkfirst=True)
|
||||
inspector = inspect(engine)
|
||||
table_names = set(inspector.get_table_names())
|
||||
if "employees" in table_names and "employee_profile_urls" not in table_names:
|
||||
models.EmployeeProfileUrl.__table__.create(bind=engine, checkfirst=True)
|
||||
inspector = inspect(engine)
|
||||
if "employees" in table_names:
|
||||
employee_columns = {column["name"] for column in inspector.get_columns("employees")}
|
||||
missing_columns = []
|
||||
if "profile_unavailable_streak" not in employee_columns:
|
||||
missing_columns.append("profile_unavailable_streak INTEGER NOT NULL DEFAULT 0")
|
||||
if "last_profile_check_at" not in employee_columns:
|
||||
missing_columns.append("last_profile_check_at TIMESTAMPTZ")
|
||||
if missing_columns:
|
||||
with engine.begin() as connection:
|
||||
for column in missing_columns:
|
||||
connection.execute(text(f"ALTER TABLE employees ADD COLUMN {column}"))
|
||||
if "crawl_runs" not in table_names:
|
||||
return
|
||||
crawl_run_columns = {column["name"] for column in inspector.get_columns("crawl_runs")}
|
||||
|
||||
@@ -33,6 +33,8 @@ class Employee(Base):
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
dismissed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
profile_unavailable_streak: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
last_profile_check_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
parser_version: Mapped[str | None] = mapped_column(String(32))
|
||||
current_data: Mapped[dict | None] = mapped_column(json_type)
|
||||
current_checksum: Mapped[str | None] = mapped_column(String(64))
|
||||
@@ -40,12 +42,29 @@ class Employee(Base):
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False)
|
||||
|
||||
snapshots: Mapped[list["EmployeeSnapshot"]] = relationship(back_populates="employee")
|
||||
profile_urls: Mapped[list["EmployeeProfileUrl"]] = relationship(back_populates="employee", cascade="all, delete-orphan")
|
||||
tabs: Mapped[list["ProfileTab"]] = relationship(back_populates="employee", cascade="all, delete-orphan")
|
||||
publications: Mapped[list["EmployeePublication"]] = relationship(back_populates="employee", cascade="all, delete-orphan")
|
||||
news_links: Mapped[list["EmployeeNewsLink"]] = relationship(back_populates="employee", cascade="all, delete-orphan")
|
||||
crawl_run_changes: Mapped[list["CrawlRunEmployeeChange"]] = relationship(back_populates="employee")
|
||||
|
||||
|
||||
class EmployeeProfileUrl(Base):
|
||||
__tablename__ = "employee_profile_urls"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("employee_id", "url", name="uq_employee_profile_urls_employee_url"),
|
||||
Index("ix_employee_profile_urls_employee_id", "employee_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
employee_id: Mapped[int] = mapped_column(ForeignKey("employees.id", ondelete="CASCADE"), nullable=False)
|
||||
url: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
employee: Mapped[Employee] = relationship(back_populates="profile_urls")
|
||||
|
||||
|
||||
class EmployeeSnapshot(Base):
|
||||
__tablename__ = "employee_snapshots"
|
||||
__table_args__ = (Index("ix_employee_snapshots_employee_id", "employee_id"),)
|
||||
|
||||
@@ -162,6 +162,10 @@ def stats_payload(db: Session) -> dict[str, Any]:
|
||||
return {
|
||||
"total": db.scalar(select(func.count()).select_from(Employee)) or 0,
|
||||
"active": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "active")) or 0,
|
||||
"verification_required": db.scalar(
|
||||
select(func.count()).select_from(Employee).where(Employee.status == "verification_required")
|
||||
)
|
||||
or 0,
|
||||
"dismissed": db.scalar(select(func.count()).select_from(Employee).where(Employee.status == "dismissed")) or 0,
|
||||
"new_in_last_run": latest_run.new_count if latest_run else 0,
|
||||
"latest_added": employee_display_payload(latest_added) if latest_added else None,
|
||||
@@ -205,7 +209,7 @@ def run_detail_payload(db: Session, run: CrawlRun | None) -> dict[str, Any] | No
|
||||
.order_by(CrawlRunEmployeeChange.created_at, CrawlRunEmployeeChange.id)
|
||||
).all()
|
||||
errors = db.scalars(select(CrawlError).where(CrawlError.crawl_run_id == run.id).order_by(CrawlError.created_at)).all()
|
||||
grouped_changes = {"new": [], "missing_from_source": [], "dismissed": []}
|
||||
grouped_changes = {"new": [], "missing_from_source": [], "verification_required": [], "dismissed": []}
|
||||
for change in changes:
|
||||
grouped_changes.setdefault(change.change_type, []).append(_change_payload(change))
|
||||
return {
|
||||
@@ -262,7 +266,7 @@ def _json_text_contains(data_text: Any, value: str) -> Any:
|
||||
|
||||
|
||||
def _employee_status_display(status: str | None) -> str:
|
||||
labels = {"active": "Работает", "dismissed": "Уволен"}
|
||||
labels = {"active": "Работает", "verification_required": "Требует проверки", "dismissed": "Уволен"}
|
||||
return labels.get(status or "", status or "Не указано")
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<select class="directory__input" name="status">
|
||||
<option value="" {% if not filters.status %}selected{% endif %}>Все статусы</option>
|
||||
<option value="active" {% if filters.status == "active" %}selected{% endif %}>Работает</option>
|
||||
<option value="verification_required" {% if filters.status == "verification_required" %}selected{% endif %}>Требует проверки</option>
|
||||
<option value="dismissed" {% if filters.status == "dismissed" %}selected{% endif %}>Уволен</option>
|
||||
</select>
|
||||
<select class="directory__input" name="has_email">
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% for group, title in [("new", "Новые сотрудники"), ("missing_from_source", "Потеряшки"), ("dismissed", "Уволенные")] %}
|
||||
{% for group, title in [("new", "Новые сотрудники"), ("missing_from_source", "Потеряшки"), ("verification_required", "Требуют проверки"), ("dismissed", "Уволенные")] %}
|
||||
<section class="panel"{% if group == "new" %} id="new-employees"{% endif %}>
|
||||
<h2 class="panel__title">{{ title }}</h2>
|
||||
{% set items = run.changes[group] %}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
APP_VERSION = "0.7.2"
|
||||
FRONTEND_VERSION = "0.7.2"
|
||||
BACKEND_VERSION = "0.7.2"
|
||||
APP_VERSION = "0.7.3"
|
||||
FRONTEND_VERSION = "0.7.3"
|
||||
BACKEND_VERSION = "0.7.3"
|
||||
|
||||
17
migrations/008_profile_verification.sql
Normal file
17
migrations/008_profile_verification.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
ALTER TABLE employees
|
||||
ADD COLUMN IF NOT EXISTS profile_unavailable_streak INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE employees
|
||||
ADD COLUMN IF NOT EXISTS last_profile_check_at TIMESTAMPTZ;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS employee_profile_urls (
|
||||
id SERIAL PRIMARY KEY,
|
||||
employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
|
||||
url TEXT NOT NULL,
|
||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT uq_employee_profile_urls_employee_url UNIQUE (employee_id, url)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS ix_employee_profile_urls_employee_id
|
||||
ON employee_profile_urls (employee_id);
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "miem-workers"
|
||||
version = "0.7.2"
|
||||
version = "0.7.3"
|
||||
description = "MIEM employees parser, admin API, and MCP server"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -20,7 +20,7 @@ def test_health_returns_versions():
|
||||
response = client.get("/api/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["backend_version"] == "0.7.1"
|
||||
assert response.json()["backend_version"] == "0.7.3"
|
||||
|
||||
|
||||
def test_mcp_lists_tools_without_auth_and_ignores_auth_header():
|
||||
@@ -154,7 +154,7 @@ def test_mcp_service_info_returns_tools_and_dataset_hash():
|
||||
assert response.status_code == 200
|
||||
payload = json.loads(response.json()["result"]["content"][0]["text"])
|
||||
assert payload["service_name"] == "miem-employees"
|
||||
assert payload["backend_version"] == "0.7.1"
|
||||
assert payload["backend_version"] == "0.7.3"
|
||||
assert payload["dataset"]["hash"]
|
||||
assert any(tool["name"] == "sync_employees" for tool in payload["tools"])
|
||||
|
||||
|
||||
@@ -113,3 +113,32 @@ def test_runtime_schema_creates_employee_news_links_table_when_employees_exist(m
|
||||
assert "employee_news_links" in inspector.get_table_names()
|
||||
columns = {column["name"] for column in inspector.get_columns("employee_news_links")}
|
||||
assert {"employee_id", "title", "url", "summary", "published_at", "published_year", "source_hash", "raw_data"}.issubset(columns)
|
||||
|
||||
|
||||
def test_runtime_schema_adds_profile_verification_fields(monkeypatch):
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
with engine.begin() as connection:
|
||||
connection.execute(
|
||||
text(
|
||||
"""
|
||||
CREATE TABLE employees (
|
||||
id INTEGER PRIMARY KEY,
|
||||
profile_key VARCHAR(255) NOT NULL UNIQUE,
|
||||
canonical_url TEXT NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
first_seen_at DATETIME NOT NULL,
|
||||
last_seen_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr("app.db.engine", engine)
|
||||
|
||||
_ensure_runtime_schema()
|
||||
|
||||
inspector = inspect(engine)
|
||||
columns = {column["name"] for column in inspector.get_columns("employees")}
|
||||
assert {"profile_unavailable_streak", "last_profile_check_at"}.issubset(columns)
|
||||
assert "employee_profile_urls" in inspector.get_table_names()
|
||||
|
||||
Reference in New Issue
Block a user