diff --git a/.gitignore b/.gitignore index 3590f5f..d6ebb25 100644 --- a/.gitignore +++ b/.gitignore @@ -84,11 +84,20 @@ htmlcov/ # MinIO / S3 кэш .minio/ -# ML модели (скачиваются при старте) -models/ +# ML модели (скачиваются при старте) — только каталоги в корне/данных, +# НЕ код приложений в services/*/app/models/ +/models/ +/data/models/ *.bin *.safetensors +# Явно версионируем код моделей приложений (SQLAlchemy и т.п.) +!services/**/app/models/ +!services/**/app/models/** +# ...но не байткод внутри них +services/**/app/models/**/__pycache__/ +services/**/app/models/**/*.py[cod] + # Jupyter .ipynb_checkpoints/ *.ipynb diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..93dfbbb --- /dev/null +++ b/LICENSE @@ -0,0 +1,48 @@ +Proprietary License +Проприетарная лицензия + +Copyright (c) 2026 jze9. Все права защищены. All rights reserved. + +================================================================================ +RU +================================================================================ + +Программное обеспечение «Академический помощник» (далее «ПО») и весь +сопутствующий исходный код, документация и материалы являются собственностью +правообладателя (jze9) и защищены законодательством об авторском праве. + +1. Без предварительного письменного разрешения правообладателя ЗАПРЕЩАЕТСЯ: + - копировать, распространять или публиковать ПО или его части; + - изменять, декомпилировать, дизассемблировать ПО или создавать + производные работы; + - использовать ПО в коммерческих или некоммерческих целях третьими лицами; + - передавать, сублицензировать, сдавать в аренду или иным образом + предоставлять доступ к ПО третьим лицам. + +2. Доступ к исходному коду предоставляется исключительно для целей разработки + и эксплуатации, согласованных с правообладателем. + +3. ПО предоставляется «КАК ЕСТЬ», без каких-либо гарантий, явных или + подразумеваемых. Правообладатель не несёт ответственности за любой ущерб, + возникший в результате использования ПО. + +================================================================================ +EN +================================================================================ + +The "Academic Assistant" software (the "Software") and all accompanying source +code, documentation, and materials are the property of the copyright holder +(jze9) and are protected by copyright law. + +1. Without prior written permission of the copyright holder, it is PROHIBITED to: + - copy, distribute, or publish the Software or any part of it; + - modify, decompile, disassemble, or create derivative works; + - use the Software for commercial or non-commercial purposes by third parties; + - transfer, sublicense, rent, or otherwise provide access to third parties. + +2. Access to the source code is granted solely for development and operation + purposes agreed upon with the copyright holder. + +3. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED. THE COPYRIGHT HOLDER SHALL NOT BE LIABLE FOR ANY DAMAGES ARISING + FROM THE USE OF THE SOFTWARE. diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..0c3d792 --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,139 @@ +# ═══════════════════════════════════════════════════════════════════════════════ +# ТЕСТОВЫЙ стек: локально — api, frontend, воркеры, elasticsearch, flower. +# Удалённо (через .env) — postgres, redis, minio, ollama, rabbitmq (сервер .82). +# Запуск: docker compose -f docker-compose.test.yml up -d --build +# ═══════════════════════════════════════════════════════════════════════════════ + +networks: + aptest: + driver: bridge + +volumes: + es_test_data: + faiss_index: + +x-app-env: &app-env + env_file: .env + networks: + - aptest + restart: unless-stopped + +services: + + # ─── Локальная инфраструктура ────────────────────────────────────────────── + elasticsearch: + image: elasticsearch:8.13.0 + container_name: aptest-elasticsearch + environment: + - discovery.type=single-node + - xpack.security.enabled=false + - xpack.ml.enabled=false + - ES_JAVA_OPTS=-Xms512m -Xmx512m + - bootstrap.memory_lock=false + - cluster.name=antiplagiator-test + volumes: + - es_test_data:/usr/share/elasticsearch/data + networks: + - aptest + ports: + - "9200:9200" + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "curl -s http://localhost:9200/_cluster/health | grep -qv '\"status\":\"red\"'"] + interval: 15s + timeout: 10s + retries: 10 + start_period: 60s + + # ─── Приложение ──────────────────────────────────────────────────────────── + # RabbitMQ вынесен на отдельный сервер 192.168.20.82 (см. RABBITMQ_URL в .env) + api: + build: + context: ./services/api + dockerfile: Dockerfile + container_name: aptest-api + <<: *app-env + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload --reload-dir /app/app + volumes: + - ./services/api:/app + ports: + - "8000:8000" + depends_on: + elasticsearch: + condition: service_healthy + + frontend: + build: + context: ./services/frontend + dockerfile: Dockerfile.dev + container_name: aptest-frontend + environment: + VITE_API_TARGET: http://api:8000 + networks: + - aptest + volumes: + - ./services/frontend:/app + - /app/node_modules + ports: + - "5173:5173" + depends_on: + - api + restart: unless-stopped + + worker-gpu: + build: + context: ./services/worker-gpu + dockerfile: Dockerfile + container_name: aptest-worker-gpu + <<: *app-env + command: celery -A app.celery_app worker -Q queue.gpu -c 1 -n gpu@%h --loglevel=info + volumes: + - ./services/worker-gpu:/app + - faiss_index:/data/index + + worker-indexer: + build: + context: ./services/worker-indexer + dockerfile: Dockerfile + container_name: aptest-worker-indexer + <<: *app-env + command: celery -A app.celery_app worker -Q queue.index -c 2 -n indexer@%h --loglevel=info + volumes: + - ./services/worker-indexer:/app + - ./scripts/parsers:/parsers:ro + depends_on: + elasticsearch: + condition: service_healthy + + worker-notifier: + build: + context: ./services/worker-notifier + dockerfile: Dockerfile + container_name: aptest-worker-notifier + <<: *app-env + command: celery -A app.celery_app worker -Q queue.notify -c 4 -n notifier@%h --loglevel=info + volumes: + - ./services/worker-notifier:/app + + worker-gost: + build: + context: ./services/worker-gost + dockerfile: Dockerfile + container_name: aptest-worker-gost + <<: *app-env + command: celery -A app.celery_app worker -Q queue.gost -c 2 -n gost@%h --loglevel=info + volumes: + - ./services/worker-gost:/app + + flower: + image: mher/flower:2.0 + container_name: aptest-flower + command: celery --broker=${RABBITMQ_URL} flower --port=5555 + environment: + CELERY_BROKER_URL: ${RABBITMQ_URL} + CELERY_RESULT_BACKEND: ${REDIS_URL} + networks: + - aptest + ports: + - "5555:5555" + restart: unless-stopped diff --git a/scripts/parsers/openalex.py b/scripts/parsers/openalex.py index 8110797..0afe4c8 100644 --- a/scripts/parsers/openalex.py +++ b/scripts/parsers/openalex.py @@ -43,7 +43,7 @@ class OpenAlexParser(BaseParser): lang: str | None = None, year_from: int | None = None, year_to: int | None = None, - type_filter: str = "journal-article", + type_filter: str = "article", ) -> list[dict[str, Any]]: """ Получить документы из OpenAlex. @@ -97,7 +97,9 @@ class OpenAlexParser(BaseParser): } # Фильтры - filters = [f"type:{type_filter}", "is_oa:true"] + # OpenAlex переименовал journal-article → article; is_oa:true сильно + # сужает выдачу, поэтому не навязываем открытый доступ по умолчанию. + filters = [f"type:{type_filter}"] if query: params["search"] = query diff --git a/services/api/alembic.ini b/services/api/alembic.ini index de6165d..c67b7b2 100644 --- a/services/api/alembic.ini +++ b/services/api/alembic.ini @@ -4,6 +4,9 @@ # Путь к директории с миграциями script_location = alembic +# Добавить корень проекта (.) в sys.path, чтобы импортировался пакет app +prepend_sys_path = . + # Формат имени файла миграции file_template = %%(rev)s_%%(slug)s diff --git a/services/api/alembic/env.py b/services/api/alembic/env.py index 8357e5a..7ecc219 100644 --- a/services/api/alembic/env.py +++ b/services/api/alembic/env.py @@ -30,7 +30,7 @@ def get_url() -> str: db = os.getenv("POSTGRES_DB", "antiplagiator") user = os.getenv("POSTGRES_USER", "antiplagiator") password = os.getenv("POSTGRES_PASSWORD", "changeme") - return f"postgresql+psycopg2://{user}:{password}@{host}:{port}/{db}" + return f"postgresql+asyncpg://{user}:{password}@{host}:{port}/{db}" def run_migrations_offline() -> None: diff --git a/services/api/alembic/versions/003_admin.py b/services/api/alembic/versions/003_admin.py new file mode 100644 index 0000000..e7347a4 --- /dev/null +++ b/services/api/alembic/versions/003_admin.py @@ -0,0 +1,88 @@ +"""Админ-панель: is_admin, источники парсинга, отстойник, сессии админа. + +Revision ID: 003 +Revises: 002 +Create Date: 2026-05-30 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "003" +down_revision = "002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # users.is_admin + op.add_column( + "users", + sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + + # parse_sources + op.create_table( + "parse_sources", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("source_type", sa.String(50), nullable=False), + sa.Column("name", sa.String(255), nullable=False), + sa.Column("query", sa.Text(), nullable=True), + sa.Column("lang", sa.String(10), nullable=True), + sa.Column("year_from", sa.Integer(), nullable=True), + sa.Column("year_to", sa.Integer(), nullable=True), + sa.Column("limit", sa.Integer(), nullable=False, server_default="1000"), + sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("last_status", sa.String(20), nullable=False, server_default="idle"), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("last_run_at", sa.DateTime(), nullable=True), + sa.Column("docs_added", sa.Integer(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index("ix_parse_sources_source_type", "parse_sources", ["source_type"]) + + # staged_works + op.create_table( + "staged_works", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=True), + sa.Column("task_id", sa.String(36), nullable=True), + sa.Column("filename", sa.Text(), nullable=True), + sa.Column("minio_key", sa.Text(), nullable=True), + sa.Column("text_key", sa.Text(), nullable=True), + sa.Column("title", sa.Text(), nullable=True), + sa.Column("authors", sa.JSON(), nullable=True), + sa.Column("year", sa.Integer(), nullable=True), + sa.Column("lang", sa.String(10), nullable=True), + sa.Column("word_count", sa.Integer(), nullable=True), + sa.Column("status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("document_id", sa.Integer(), nullable=True), + sa.Column("reviewed_by", sa.Integer(), nullable=True), + sa.Column("reviewed_at", sa.DateTime(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()), + ) + op.create_index("ix_staged_works_user_id", "staged_works", ["user_id"]) + op.create_index("ix_staged_works_task_id", "staged_works", ["task_id"]) + op.create_index("ix_staged_works_status", "staged_works", ["status"]) + op.create_index("ix_staged_works_created_at", "staged_works", ["created_at"]) + + # admin_sessions + op.create_table( + "admin_sessions", + sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True), + sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id"), nullable=False), + sa.Column("code", sa.String(64), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.now()), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("last_used_at", sa.DateTime(), nullable=True), + ) + op.create_index("ix_admin_sessions_user_id", "admin_sessions", ["user_id"]) + op.create_index("ix_admin_sessions_code", "admin_sessions", ["code"], unique=True) + + +def downgrade() -> None: + op.drop_table("admin_sessions") + op.drop_table("staged_works") + op.drop_index("ix_parse_sources_source_type", table_name="parse_sources") + op.drop_table("parse_sources") + op.drop_column("users", "is_admin") diff --git a/services/api/app/api/admin.py b/services/api/app/api/admin.py new file mode 100644 index 0000000..7497ce0 --- /dev/null +++ b/services/api/app/api/admin.py @@ -0,0 +1,571 @@ +"""Админ-панель: дашборд, клиенты, работы, документы, хранилище, источники, отстойник. + +Все эндпоинты требуют is_admin (get_admin_user). Эндпоинты под /admin/panel/{code}/* +дополнительно проверяют секретный код сессии (verify_admin_code). +""" + +import logging +from datetime import datetime, timezone + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import delete, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.core.admin import create_admin_session, get_admin_user +from app.core.celery_app import celery_app +from app.core.minio_client import get_minio +from app.core.redis_client import get_redis +from app.database import get_db +from app.models.admin import ParseSource, StagedWork +from app.models.document import Document, Fingerprint +from app.models.task import Task +from app.models.user import User +from app.schemas.admin import ( + AdminDocumentResponse, + AdminSessionResponse, + AdminStats, + AdminTaskResponse, + AdminUserResponse, + AdminUserUpdate, + ParseSourceCreate, + ParseSourceResponse, + ParseSourceUpdate, + ServiceHealth, + StagedWorkDetail, + StagedWorkResponse, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin", tags=["admin"], dependencies=[Depends(get_admin_user)]) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# СЕССИЯ ДОСТУПА +# ═══════════════════════════════════════════════════════════════════════════════ +@router.post("/session", response_model=AdminSessionResponse) +async def open_admin_session( + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +) -> AdminSessionResponse: + """Сгенерировать секретный код доступа и ссылку /admin/.""" + code = await create_admin_session(admin, db) + from app.models.admin import AdminSession + + result = await db.execute(select(AdminSession).where(AdminSession.code == code)) + sess = result.scalar_one() + return AdminSessionResponse( + code=code, + url=f"/admin/{code}", + expires_at=sess.expires_at, + ) + + +@router.get("/session/verify/{code}") +async def verify_session( + code: str, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +) -> dict: + """Проверить валидность кода (для фронта при загрузке /admin/).""" + from app.models.admin import AdminSession + + result = await db.execute(select(AdminSession).where(AdminSession.code == code)) + sess = result.scalar_one_or_none() + if sess is None or sess.user_id != admin.id: + return {"valid": False} + expires = sess.expires_at + if expires.tzinfo is not None: + expires = expires.replace(tzinfo=None) + return {"valid": expires >= datetime.utcnow()} + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ДАШБОРД / СЕРВИСЫ +# ═══════════════════════════════════════════════════════════════════════════════ +@router.get("/health", response_model=list[ServiceHealth]) +async def health(db: AsyncSession = Depends(get_db)) -> list[ServiceHealth]: + """Статус всех сервисов инфраструктуры.""" + checks: list[ServiceHealth] = [] + + # PostgreSQL + try: + await db.execute(select(func.now())) + checks.append(ServiceHealth(name="PostgreSQL", ok=True)) + except Exception as e: + checks.append(ServiceHealth(name="PostgreSQL", ok=False, detail=str(e)[:200])) + + # Redis + try: + r = get_redis() + await r.ping() + checks.append(ServiceHealth(name="Redis", ok=True)) + except Exception as e: + checks.append(ServiceHealth(name="Redis", ok=False, detail=str(e)[:200])) + + # MinIO + try: + get_minio().bucket_exists(settings.MINIO_BUCKET_DOCS) + checks.append(ServiceHealth(name="MinIO", ok=True)) + except Exception as e: + checks.append(ServiceHealth(name="MinIO", ok=False, detail=str(e)[:200])) + + # Elasticsearch + try: + async with httpx.AsyncClient(timeout=5) as c: + resp = await c.get(f"{settings.ELASTICSEARCH_URL}/_cluster/health") + ok = resp.status_code == 200 and resp.json().get("status") != "red" + checks.append(ServiceHealth(name="Elasticsearch", ok=ok, + detail=resp.json().get("status"))) + except Exception as e: + checks.append(ServiceHealth(name="Elasticsearch", ok=False, detail=str(e)[:200])) + + # Ollama + try: + async with httpx.AsyncClient(timeout=5) as c: + resp = await c.get(f"{settings.OLLAMA_URL}/api/version") + checks.append(ServiceHealth(name="Ollama", ok=resp.status_code == 200, + detail=resp.json().get("version"))) + except Exception as e: + checks.append(ServiceHealth(name="Ollama", ok=False, detail=str(e)[:200])) + + # RabbitMQ + воркеры через Celery ping + try: + insp = celery_app.control.inspect(timeout=3) + pong = insp.ping() or {} + workers = list(pong.keys()) + checks.append(ServiceHealth(name="RabbitMQ/Celery", ok=bool(workers), + detail=f"воркеров онлайн: {len(workers)}")) + except Exception as e: + checks.append(ServiceHealth(name="RabbitMQ/Celery", ok=False, detail=str(e)[:200])) + + return checks + + +@router.get("/stats", response_model=AdminStats) +async def stats(db: AsyncSession = Depends(get_db)) -> AdminStats: + """Сводные счётчики для дашборда.""" + users_total = (await db.execute(select(func.count()).select_from(User))).scalar_one() + + rows = (await db.execute(select(Task.status, func.count()).group_by(Task.status))).all() + tasks_by_status = {s: c for s, c in rows} + + docs_total = (await db.execute(select(func.count()).select_from(Document))).scalar_one() + rows = (await db.execute(select(Document.source, func.count()).group_by(Document.source))).all() + docs_by_source = {s: c for s, c in rows} + + staging_pending = ( + await db.execute( + select(func.count()).select_from(StagedWork).where(StagedWork.status == "pending") + ) + ).scalar_one() + + # Хранилище MinIO + storage: dict = {} + try: + client = get_minio() + for bucket in (settings.MINIO_BUCKET_DOCS, settings.MINIO_BUCKET_BACKUPS, "staging"): + try: + if not client.bucket_exists(bucket): + continue + total = 0 + count = 0 + for obj in client.list_objects(bucket, recursive=True): + total += obj.size or 0 + count += 1 + storage[bucket] = {"objects": count, "bytes": total} + except Exception: + continue + except Exception as e: + storage = {"error": str(e)[:200]} + + # Длины очередей через RabbitMQ management API (если доступно) + queues: dict = {} + try: + rmq_user = settings.RABBITMQ_URL.split("//")[1].split(":")[0] + rmq_pass = settings.RABBITMQ_URL.split(":")[2].split("@")[0] + rmq_host = settings.RABBITMQ_URL.split("@")[1].split(":")[0] + async with httpx.AsyncClient(timeout=5) as c: + resp = await c.get( + f"http://{rmq_host}:15672/api/queues", + auth=(rmq_user, rmq_pass), + ) + if resp.status_code == 200: + for q in resp.json(): + name = q.get("name", "") + if name.startswith("queue."): + queues[name] = q.get("messages", 0) + except Exception as e: + queues = {"error": str(e)[:120]} + + return AdminStats( + users_total=users_total, + tasks_by_status=tasks_by_status, + documents_total=docs_total, + documents_by_source=docs_by_source, + staging_pending=staging_pending, + storage=storage, + queues=queues, + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# КЛИЕНТЫ +# ═══════════════════════════════════════════════════════════════════════════════ +@router.get("/users", response_model=list[AdminUserResponse]) +async def list_users( + q: str | None = None, + limit: int = Query(50, le=200), + offset: int = 0, + db: AsyncSession = Depends(get_db), +) -> list[AdminUserResponse]: + stmt = select(User) + if q: + like = f"%{q.lower()}%" + stmt = stmt.where(func.lower(User.email).like(like) | func.lower(User.name).like(like)) + stmt = stmt.order_by(User.created_at.desc()).limit(limit).offset(offset) + users = (await db.execute(stmt)).scalars().all() + + result = [] + for u in users: + cnt = ( + await db.execute(select(func.count()).select_from(Task).where(Task.user_id == u.id)) + ).scalar_one() + item = AdminUserResponse.model_validate(u) + item.tasks_count = cnt + result.append(item) + return result + + +@router.patch("/users/{user_id}", response_model=AdminUserResponse) +async def update_user( + user_id: int, + data: AdminUserUpdate, + db: AsyncSession = Depends(get_db), +) -> AdminUserResponse: + user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none() + if user is None: + raise HTTPException(status_code=404, detail="Пользователь не найден") + if data.plan is not None: + user.plan = data.plan + if data.is_verified is not None: + user.is_verified = data.is_verified + if data.is_admin is not None: + user.is_admin = data.is_admin + await db.commit() + await db.refresh(user) + # Сбросить кэш пользователя + try: + await get_redis().delete(f"user:cache:{user_id}") + except Exception: + pass + return AdminUserResponse.model_validate(user) + + +@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user(user_id: int, db: AsyncSession = Depends(get_db)) -> None: + user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none() + if user is None: + raise HTTPException(status_code=404, detail="Пользователь не найден") + await db.execute(delete(Task).where(Task.user_id == user_id)) + await db.delete(user) + await db.commit() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# РАБОТЫ (задачи) +# ═══════════════════════════════════════════════════════════════════════════════ +@router.get("/tasks", response_model=list[AdminTaskResponse]) +async def list_tasks( + status_filter: str | None = Query(None, alias="status"), + type_filter: str | None = Query(None, alias="type"), + user_id: int | None = None, + limit: int = Query(50, le=200), + offset: int = 0, + db: AsyncSession = Depends(get_db), +) -> list[AdminTaskResponse]: + stmt = select(Task) + if status_filter: + stmt = stmt.where(Task.status == status_filter) + if type_filter: + stmt = stmt.where(Task.type == type_filter) + if user_id: + stmt = stmt.where(Task.user_id == user_id) + stmt = stmt.order_by(Task.created_at.desc()).limit(limit).offset(offset) + tasks = (await db.execute(stmt)).scalars().all() + return [AdminTaskResponse.model_validate(t) for t in tasks] + + +@router.get("/tasks/{public_id}") +async def get_task(public_id: str, db: AsyncSession = Depends(get_db)) -> dict: + task = ( + await db.execute(select(Task).where(Task.public_id == public_id)) + ).scalar_one_or_none() + if task is None: + raise HTTPException(status_code=404, detail="Задача не найдена") + return { + "public_id": task.public_id, + "user_id": task.user_id, + "type": task.type, + "status": task.status, + "input_data": task.input_data, + "result": task.result, + "error": task.error, + "created_at": task.created_at, + } + + +@router.post("/tasks/{public_id}/requeue") +async def requeue_task(public_id: str, db: AsyncSession = Depends(get_db)) -> dict: + task = ( + await db.execute(select(Task).where(Task.public_id == public_id)) + ).scalar_one_or_none() + if task is None: + raise HTTPException(status_code=404, detail="Задача не найдена") + if task.type != "plagiarism": + raise HTTPException(status_code=400, detail="Перезапуск поддержан только для проверок плагиата") + minio_key = (task.input_data or {}).get("minio_key") + filename = (task.input_data or {}).get("filename") + if not minio_key: + raise HTTPException(status_code=400, detail="Нет minio_key для перезапуска") + task.status = "queued" + task.error = None + task.result = None + await db.commit() + celery_app.send_task( + "index.extract_and_check", + args=[task.id, minio_key, filename], + queue="queue.index", + ) + return {"status": "requeued", "public_id": public_id} + + +@router.delete("/tasks/{public_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_task(public_id: str, db: AsyncSession = Depends(get_db)) -> None: + task = ( + await db.execute(select(Task).where(Task.public_id == public_id)) + ).scalar_one_or_none() + if task is None: + raise HTTPException(status_code=404, detail="Задача не найдена") + await db.delete(task) + await db.commit() + + +# ═══════════════════════════════════════════════════════════════════════════════ +# БАЗА ДОКУМЕНТОВ +# ═══════════════════════════════════════════════════════════════════════════════ +@router.get("/documents", response_model=list[AdminDocumentResponse]) +async def list_documents( + q: str | None = None, + source: str | None = None, + year: int | None = None, + limit: int = Query(50, le=200), + offset: int = 0, + db: AsyncSession = Depends(get_db), +) -> list[AdminDocumentResponse]: + stmt = select(Document) + if q: + stmt = stmt.where(func.lower(Document.title).like(f"%{q.lower()}%")) + if source: + stmt = stmt.where(Document.source == source) + if year: + stmt = stmt.where(Document.year == year) + stmt = stmt.order_by(Document.indexed_at.desc()).limit(limit).offset(offset) + docs = (await db.execute(stmt)).scalars().all() + return [AdminDocumentResponse.model_validate(d) for d in docs] + + +@router.delete("/documents/{doc_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_document(doc_id: int, db: AsyncSession = Depends(get_db)) -> None: + doc = (await db.execute(select(Document).where(Document.id == doc_id))).scalar_one_or_none() + if doc is None: + raise HTTPException(status_code=404, detail="Документ не найден") + await db.execute(delete(Fingerprint).where(Fingerprint.doc_id == doc_id)) + await db.delete(doc) + await db.commit() + # Удалить из Elasticsearch (best-effort) + try: + async with httpx.AsyncClient(timeout=5) as c: + await c.delete(f"{settings.ELASTICSEARCH_URL}/documents/_doc/{doc_id}") + except Exception: + pass + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ХРАНИЛИЩЕ +# ═══════════════════════════════════════════════════════════════════════════════ +@router.get("/storage") +async def storage_info() -> dict: + client = get_minio() + result: dict = {"buckets": []} + for bucket in (settings.MINIO_BUCKET_DOCS, settings.MINIO_BUCKET_BACKUPS, "staging"): + try: + if not client.bucket_exists(bucket): + continue + total = 0 + objs = [] + for obj in client.list_objects(bucket, recursive=True): + total += obj.size or 0 + if len(objs) < 100: + objs.append({"name": obj.object_name, "size": obj.size}) + result["buckets"].append({ + "name": bucket, + "objects_total": len(objs), + "bytes": total, + "sample": objs, + }) + except Exception as e: + result["buckets"].append({"name": bucket, "error": str(e)[:120]}) + return result + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ИСТОЧНИКИ ПАРСИНГА +# ═══════════════════════════════════════════════════════════════════════════════ +@router.get("/sources", response_model=list[ParseSourceResponse]) +async def list_sources(db: AsyncSession = Depends(get_db)) -> list[ParseSourceResponse]: + rows = (await db.execute(select(ParseSource).order_by(ParseSource.created_at.desc()))).scalars().all() + return [ParseSourceResponse.model_validate(s) for s in rows] + + +@router.post("/sources", response_model=ParseSourceResponse, status_code=201) +async def create_source(data: ParseSourceCreate, db: AsyncSession = Depends(get_db)) -> ParseSourceResponse: + if data.source_type not in ("openalex", "cyberleninka", "arxiv"): + raise HTTPException(status_code=400, detail="Недопустимый тип источника") + src = ParseSource(**data.model_dump()) + db.add(src) + await db.commit() + await db.refresh(src) + return ParseSourceResponse.model_validate(src) + + +@router.patch("/sources/{source_id}", response_model=ParseSourceResponse) +async def update_source( + source_id: int, data: ParseSourceUpdate, db: AsyncSession = Depends(get_db) +) -> ParseSourceResponse: + src = (await db.execute(select(ParseSource).where(ParseSource.id == source_id))).scalar_one_or_none() + if src is None: + raise HTTPException(status_code=404, detail="Источник не найден") + for k, v in data.model_dump(exclude_unset=True).items(): + setattr(src, k, v) + await db.commit() + await db.refresh(src) + return ParseSourceResponse.model_validate(src) + + +@router.delete("/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_source(source_id: int, db: AsyncSession = Depends(get_db)) -> None: + src = (await db.execute(select(ParseSource).where(ParseSource.id == source_id))).scalar_one_or_none() + if src is None: + raise HTTPException(status_code=404, detail="Источник не найден") + await db.delete(src) + await db.commit() + + +@router.post("/sources/{source_id}/run") +async def run_source(source_id: int, db: AsyncSession = Depends(get_db)) -> dict: + src = (await db.execute(select(ParseSource).where(ParseSource.id == source_id))).scalar_one_or_none() + if src is None: + raise HTTPException(status_code=404, detail="Источник не найден") + if src.last_status == "running": + raise HTTPException(status_code=409, detail="Источник уже парсится") + src.last_status = "running" + src.last_error = None + src.last_run_at = datetime.utcnow() + await db.commit() + celery_app.send_task("index.run_parser", args=[source_id], queue="queue.index") + return {"status": "started", "source_id": source_id} + + +# ═══════════════════════════════════════════════════════════════════════════════ +# ОТСТОЙНИК +# ═══════════════════════════════════════════════════════════════════════════════ +@router.get("/staging", response_model=list[StagedWorkResponse]) +async def list_staging( + status_filter: str = Query("pending", alias="status"), + limit: int = Query(50, le=200), + offset: int = 0, + db: AsyncSession = Depends(get_db), +) -> list[StagedWorkResponse]: + stmt = select(StagedWork) + if status_filter != "all": + stmt = stmt.where(StagedWork.status == status_filter) + stmt = stmt.order_by(StagedWork.created_at.desc()).limit(limit).offset(offset) + rows = (await db.execute(stmt)).scalars().all() + return [StagedWorkResponse.model_validate(s) for s in rows] + + +@router.get("/staging/{staged_id}", response_model=StagedWorkDetail) +async def get_staging(staged_id: int, db: AsyncSession = Depends(get_db)) -> StagedWorkDetail: + sw = (await db.execute(select(StagedWork).where(StagedWork.id == staged_id))).scalar_one_or_none() + if sw is None: + raise HTTPException(status_code=404, detail="Работа не найдена") + detail = StagedWorkDetail.model_validate(sw) + # Превью текста из MinIO + if sw.text_key: + try: + obj = get_minio().get_object("staging", sw.text_key) + text = obj.read().decode("utf-8", errors="replace") + detail.text_preview = text[:5000] + except Exception as e: + detail.text_preview = f"(не удалось прочитать текст: {e})" + return detail + + +@router.post("/staging/{staged_id}/approve") +async def approve_staging( + staged_id: int, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +) -> dict: + sw = (await db.execute(select(StagedWork).where(StagedWork.id == staged_id))).scalar_one_or_none() + if sw is None: + raise HTTPException(status_code=404, detail="Работа не найдена") + if sw.status != "pending": + raise HTTPException(status_code=409, detail=f"Работа уже {sw.status}") + + # Прочитать извлечённый текст + full_text = "" + if sw.text_key: + try: + obj = get_minio().get_object("staging", sw.text_key) + full_text = obj.read().decode("utf-8", errors="replace") + except Exception as e: + raise HTTPException(status_code=500, detail=f"Не удалось прочитать текст: {e}") + + # Добавить в базу документов через существующую задачу индексатора + doc_data = { + "source": "user_submission", + "ext_id": f"staged:{sw.id}", + "title": sw.title or sw.filename or f"Работа #{sw.id}", + "authors": sw.authors or [], + "year": sw.year, + "lang": sw.lang, + "abstract": full_text[:2000], + "full_text": full_text, + } + celery_app.send_task("index.add_document", args=[doc_data], queue="queue.index") + + sw.status = "approved" + sw.reviewed_by = admin.id + sw.reviewed_at = datetime.utcnow() + await db.commit() + return {"status": "approved", "id": staged_id} + + +@router.post("/staging/{staged_id}/reject") +async def reject_staging( + staged_id: int, + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +) -> dict: + sw = (await db.execute(select(StagedWork).where(StagedWork.id == staged_id))).scalar_one_or_none() + if sw is None: + raise HTTPException(status_code=404, detail="Работа не найдена") + sw.status = "rejected" + sw.reviewed_by = admin.id + sw.reviewed_at = datetime.utcnow() + await db.commit() + return {"status": "rejected", "id": staged_id} diff --git a/services/api/app/core/admin.py b/services/api/app/core/admin.py new file mode 100644 index 0000000..5b231f3 --- /dev/null +++ b/services/api/app/core/admin.py @@ -0,0 +1,73 @@ +"""Авторизация админ-панели: проверка роли is_admin и секретного кода сессии.""" + +import secrets +from datetime import datetime, timedelta, timezone + +from fastapi import Depends, HTTPException, Path, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import get_current_user +from app.database import get_db +from app.models.admin import AdminSession +from app.models.user import User + +# Срок жизни секретного кода доступа к админке +ADMIN_SESSION_TTL_HOURS = 8 + + +async def get_admin_user(current_user: User = Depends(get_current_user)) -> User: + """Dependency: пользователь должен быть аутентифицирован И иметь is_admin.""" + if not getattr(current_user, "is_admin", False): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Доступ только для администраторов", + ) + return current_user + + +async def create_admin_session(user: User, db: AsyncSession) -> str: + """Создать новый секретный код доступа к админке, вернуть код.""" + code = secrets.token_urlsafe(32) + # Колонки TIMESTAMP WITHOUT TIME ZONE — храним naive UTC + session = AdminSession( + user_id=user.id, + code=code, + expires_at=datetime.utcnow() + timedelta(hours=ADMIN_SESSION_TTL_HOURS), + ) + db.add(session) + await db.commit() + return code + + +async def verify_admin_code( + code: str = Path(..., description="Секретный код сессии из ссылки /admin/"), + admin: User = Depends(get_admin_user), + db: AsyncSession = Depends(get_db), +) -> User: + """Dependency: проверяет И роль администратора, И валидность кода из пути. + + Двухфакторная защита: ссылка /admin/ бесполезна без JWT админа, + а JWT админа без верного кода — тоже. + """ + result = await db.execute(select(AdminSession).where(AdminSession.code == code)) + session = result.scalar_one_or_none() + + if session is None or session.user_id != admin.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Неверный код доступа к админке", + ) + + expires = session.expires_at + if expires.tzinfo is not None: + expires = expires.replace(tzinfo=None) + if expires < datetime.utcnow(): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Код доступа истёк, сгенерируйте новый", + ) + + session.last_used_at = datetime.utcnow() + await db.commit() + return admin diff --git a/services/api/app/core/security.py b/services/api/app/core/security.py index 89491b7..cf9acd6 100644 --- a/services/api/app/core/security.py +++ b/services/api/app/core/security.py @@ -92,6 +92,7 @@ async def _load_user(user_id: int, db: AsyncSession): "name": user.name, "plan": user.plan, "is_verified": user.is_verified, + "is_admin": user.is_admin, } await r.setex(cache_key, _USER_CACHE_TTL, json.dumps(safe)) diff --git a/services/api/app/main.py b/services/api/app/main.py index 9fd76be..bf153ec 100644 --- a/services/api/app/main.py +++ b/services/api/app/main.py @@ -8,7 +8,7 @@ from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse -from app.api import auth, documents, reports, search, tasks +from app.api import admin, auth, documents, reports, search, tasks from app.config import settings from app.core.minio_client import ensure_bucket from app.core.redis_client import close_pool @@ -78,6 +78,7 @@ app.include_router(tasks.router, prefix="/api") app.include_router(search.router, prefix="/api") app.include_router(documents.router, prefix="/api") app.include_router(reports.router, prefix="/api") +app.include_router(admin.router, prefix="/api") # ─── WebSocket ──────────────────────────────────────────────────────────────── diff --git a/services/api/app/models/__init__.py b/services/api/app/models/__init__.py new file mode 100644 index 0000000..4e6674f --- /dev/null +++ b/services/api/app/models/__init__.py @@ -0,0 +1,19 @@ +"""Экспорт всех моделей для Alembic автоопределения.""" + +from app.models.admin import AdminSession, ParseSource, StagedWork +from app.models.document import Document, Fingerprint, UsageLog +from app.models.task import Task, TaskStatus, TaskType +from app.models.user import User + +__all__ = [ + "User", + "Task", + "TaskType", + "TaskStatus", + "Document", + "Fingerprint", + "UsageLog", + "ParseSource", + "StagedWork", + "AdminSession", +] diff --git a/services/api/app/models/admin.py b/services/api/app/models/admin.py new file mode 100644 index 0000000..0d0970b --- /dev/null +++ b/services/api/app/models/admin.py @@ -0,0 +1,92 @@ +"""Модели для админ-панели: источники парсинга, отстойник работ, сессии админа.""" + +from datetime import datetime + +from sqlalchemy import JSON, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.database import Base + + +class ParseSource(Base): + """Источник парсинга для наполнения базы документов. + + Админ задаёт тип источника и параметры запроса; задача index.run_parser + запускает парсинг в фоне и наполняет базу. + """ + + __tablename__ = "parse_sources" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + # Тип парсера: openalex / cyberleninka / arxiv + source_type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + # Человекочитаемое имя + name: Mapped[str] = mapped_column(String(255), nullable=False) + query: Mapped[str | None] = mapped_column(nullable=True) + lang: Mapped[str | None] = mapped_column(String(10), nullable=True) + year_from: Mapped[int | None] = mapped_column(nullable=True) + year_to: Mapped[int | None] = mapped_column(nullable=True) + limit: Mapped[int] = mapped_column(default=1000) + enabled: Mapped[bool] = mapped_column(default=True) + # Статус последнего запуска: idle / running / done / error + last_status: Mapped[str] = mapped_column(String(20), default="idle") + last_error: Mapped[str | None] = mapped_column(nullable=True) + last_run_at: Mapped[datetime | None] = mapped_column(nullable=True) + docs_added: Mapped[int] = mapped_column(default=0) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + + def __repr__(self) -> str: + return f"" + + +class StagedWork(Base): + """Отстойник: проверенная пользовательская работа, ожидающая решения админа. + + После одобрения работа добавляется в базу документов (index.add_document), + после отклонения — помечается rejected. + """ + + __tablename__ = "staged_works" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True) + # Внутренний UUID задачи проверки (Task.id) + task_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + filename: Mapped[str | None] = mapped_column(nullable=True) + # Ключ исходного файла и извлечённого текста в MinIO + minio_key: Mapped[str | None] = mapped_column(nullable=True) + text_key: Mapped[str | None] = mapped_column(nullable=True) + title: Mapped[str | None] = mapped_column(nullable=True) + authors: Mapped[list] = mapped_column(JSON, default=list) + year: Mapped[int | None] = mapped_column(nullable=True) + lang: Mapped[str | None] = mapped_column(String(10), nullable=True) + word_count: Mapped[int | None] = mapped_column(nullable=True) + # pending / approved / rejected + status: Mapped[str] = mapped_column(String(20), default="pending", index=True) + # ID документа в базе после одобрения + document_id: Mapped[int | None] = mapped_column(nullable=True) + reviewed_by: Mapped[int | None] = mapped_column(nullable=True) + reviewed_at: Mapped[datetime | None] = mapped_column(nullable=True) + created_at: Mapped[datetime] = mapped_column(server_default=func.now(), index=True) + + def __repr__(self) -> str: + return f"" + + +class AdminSession(Base): + """Секретный код доступа к админке (часть ссылки /admin/). + + Доступ проверяется по двум факторам: JWT админа (is_admin) И валидный код. + """ + + __tablename__ = "admin_sessions" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False, index=True) + code: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + expires_at: Mapped[datetime] = mapped_column(nullable=False) + last_used_at: Mapped[datetime | None] = mapped_column(nullable=True) + + def __repr__(self) -> str: + return f"" diff --git a/services/api/app/models/document.py b/services/api/app/models/document.py new file mode 100644 index 0000000..786ec99 --- /dev/null +++ b/services/api/app/models/document.py @@ -0,0 +1,82 @@ +"""Модели документов, fingerprints и логов использования.""" + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import JSON, BigInteger, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + +if TYPE_CHECKING: + from app.models.user import User + + +class Document(Base): + """Документ в базе знаний системы (статья, книга, веб-страница).""" + + __tablename__ = "documents" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + # Источник: openalex, cyberleninka, arxiv, wikipedia_ru, wikipedia_en + source: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + # Внешний ID (OpenAlex ID, arXiv ID и т.д.) — уникален в рамках источника + ext_id: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + doi: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True) + title: Mapped[str] = mapped_column(nullable=False) + authors: Mapped[list] = mapped_column(JSON, default=list) + year: Mapped[int | None] = mapped_column(nullable=True, index=True) + lang: Mapped[str | None] = mapped_column(String(10), nullable=True, index=True) + journal: Mapped[str | None] = mapped_column(nullable=True) + volume: Mapped[str | None] = mapped_column(String(50), nullable=True) + issue: Mapped[str | None] = mapped_column(String(50), nullable=True) + pages: Mapped[str | None] = mapped_column(String(50), nullable=True) + abstract: Mapped[str | None] = mapped_column(nullable=True) + url: Mapped[str | None] = mapped_column(nullable=True) + # Ключ объекта в MinIO (полный текст) + minio_key: Mapped[str | None] = mapped_column(nullable=True) + # ID вектора в FAISS индексе + faiss_id: Mapped[int | None] = mapped_column(nullable=True, index=True) + indexed_at: Mapped[datetime] = mapped_column(server_default=func.now()) + + # Отпечатки (Winnowing хэши) + fingerprints: Mapped[list["Fingerprint"]] = relationship( + back_populates="document", + lazy="select", + ) + + def __repr__(self) -> str: + return f"" + + +class Fingerprint(Base): + """Winnowing fingerprint (хэш n-граммы) для быстрого поиска плагиата.""" + + __tablename__ = "fingerprints" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + doc_id: Mapped[int] = mapped_column(ForeignKey("documents.id"), index=True) + hash_value: Mapped[int] = mapped_column(BigInteger, index=True) + position: Mapped[int] = mapped_column() # Позиция в документе + + document: Mapped["Document"] = relationship(back_populates="fingerprints") + + def __repr__(self) -> str: + return f"" + + +class UsageLog(Base): + """Лог использования для подсчёта лимитов по тарифу.""" + + __tablename__ = "usage_logs" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + # Действие: search, plagiarism, summarize, gost + action: Mapped[str] = mapped_column(String(50), nullable=False) + created_at: Mapped[datetime] = mapped_column(server_default=func.now(), index=True) + + user: Mapped["User"] = relationship(back_populates="usage") + + def __repr__(self) -> str: + return f"" diff --git a/services/api/app/models/task.py b/services/api/app/models/task.py new file mode 100644 index 0000000..5a1ab85 --- /dev/null +++ b/services/api/app/models/task.py @@ -0,0 +1,75 @@ +"""Модель задачи (асинхронная единица работы).""" + +import secrets +from datetime import datetime +from enum import Enum +from typing import TYPE_CHECKING +from uuid import uuid4 + +from sqlalchemy import JSON, ForeignKey, String, func +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + +if TYPE_CHECKING: + from app.models.user import User + + +class TaskType(str, Enum): + """Тип задачи.""" + + SEARCH = "search" + PLAGIARISM = "plagiarism" + SUMMARIZE = "summarize" + GOST = "gost" + + +class TaskStatus(str, Enum): + """Статус задачи.""" + + QUEUED = "queued" + PROCESSING = "processing" + DONE = "done" + FAILED = "failed" + + +class Task(Base): + """Задача пользователя, обрабатываемая воркерами Celery.""" + + __tablename__ = "tasks" + + # Внутренний UUID — не светится в API/URL + id: Mapped[str] = mapped_column( + String(36), + primary_key=True, + default=lambda: str(uuid4()), + ) + # Публичный ID для URL: /tasks/{public_id} + # token_urlsafe(16) → 22 символа base64url, не угадываем, не связан с internal UUID + public_id: Mapped[str] = mapped_column( + String(32), + unique=True, + index=True, + default=lambda: secrets.token_urlsafe(16), + ) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True) + type: Mapped[str] = mapped_column(String(20), nullable=False) + status: Mapped[str] = mapped_column(String(20), default=TaskStatus.QUEUED) + celery_task_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + # Входные данные: query, filename, doc_ids и т.д. + input_data: Mapped[dict] = mapped_column(JSON, default=dict) + # Результат работы воркера + result: Mapped[dict | None] = mapped_column(JSON, nullable=True) + error: Mapped[str | None] = mapped_column(nullable=True) + queue_position: Mapped[int | None] = mapped_column(nullable=True) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime | None] = mapped_column( + nullable=True, + onupdate=func.now(), + ) + + # Связи + user: Mapped["User"] = relationship(back_populates="tasks") + + def __repr__(self) -> str: + return f"" diff --git a/services/api/app/models/user.py b/services/api/app/models/user.py new file mode 100644 index 0000000..fffa553 --- /dev/null +++ b/services/api/app/models/user.py @@ -0,0 +1,42 @@ +"""Модель пользователя.""" + +from datetime import datetime +from typing import TYPE_CHECKING + +from sqlalchemy import func, String +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.database import Base + +if TYPE_CHECKING: + from app.models.task import Task + from app.models.document import UsageLog + + +class User(Base): + """Пользователь системы.""" + + __tablename__ = "users" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False) + hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + is_verified: Mapped[bool] = mapped_column(default=False) + # Администратор системы (доступ к админ-панели) + is_admin: Mapped[bool] = mapped_column(default=False) + # Тарифный план: free / student / premium / science + plan: Mapped[str] = mapped_column(String(20), default="free") + verification_token: Mapped[str | None] = mapped_column(String(255), nullable=True) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column( + server_default=func.now(), + onupdate=func.now(), + ) + + # Связи + tasks: Mapped[list["Task"]] = relationship(back_populates="user", lazy="select") + usage: Mapped[list["UsageLog"]] = relationship(back_populates="user", lazy="select") + + def __repr__(self) -> str: + return f"" diff --git a/services/api/app/schemas/admin.py b/services/api/app/schemas/admin.py new file mode 100644 index 0000000..3d5cf92 --- /dev/null +++ b/services/api/app/schemas/admin.py @@ -0,0 +1,141 @@ +"""Pydantic-схемы для админ-панели.""" + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, Field + + +# ─── Сессия доступа ─────────────────────────────────────────────────────────── +class AdminSessionResponse(BaseModel): + code: str + url: str + expires_at: datetime + + +# ─── Клиенты ────────────────────────────────────────────────────────────────── +class AdminUserResponse(BaseModel): + id: int + email: str + name: str + plan: str + is_verified: bool + is_admin: bool + created_at: datetime + tasks_count: int = 0 + + model_config = {"from_attributes": True} + + +class AdminUserUpdate(BaseModel): + plan: str | None = None + is_verified: bool | None = None + is_admin: bool | None = None + + +# ─── Работы ───────────────────────────────────────────────────────────────── +class AdminTaskResponse(BaseModel): + public_id: str + user_id: int + type: str + status: str + error: str | None = None + created_at: datetime + + model_config = {"from_attributes": True} + + +# ─── Документы базы ─────────────────────────────────────────────────────────── +class AdminDocumentResponse(BaseModel): + id: int + source: str + ext_id: str + title: str + authors: list = Field(default_factory=list) + year: int | None = None + lang: str | None = None + doi: str | None = None + url: str | None = None + indexed_at: datetime + + model_config = {"from_attributes": True} + + +# ─── Источники парсинга ─────────────────────────────────────────────────────── +class ParseSourceCreate(BaseModel): + source_type: str = Field(description="openalex / cyberleninka / arxiv") + name: str = Field(min_length=1, max_length=255) + query: str | None = None + lang: str | None = None + year_from: int | None = None + year_to: int | None = None + limit: int = Field(default=1000, ge=1, le=100000) + enabled: bool = True + + +class ParseSourceUpdate(BaseModel): + name: str | None = None + query: str | None = None + lang: str | None = None + year_from: int | None = None + year_to: int | None = None + limit: int | None = Field(default=None, ge=1, le=100000) + enabled: bool | None = None + + +class ParseSourceResponse(BaseModel): + id: int + source_type: str + name: str + query: str | None = None + lang: str | None = None + year_from: int | None = None + year_to: int | None = None + limit: int + enabled: bool + last_status: str + last_error: str | None = None + last_run_at: datetime | None = None + docs_added: int + created_at: datetime + + model_config = {"from_attributes": True} + + +# ─── Отстойник ──────────────────────────────────────────────────────────────── +class StagedWorkResponse(BaseModel): + id: int + user_id: int | None = None + task_id: str | None = None + filename: str | None = None + title: str | None = None + authors: list = Field(default_factory=list) + year: int | None = None + lang: str | None = None + word_count: int | None = None + status: str + document_id: int | None = None + created_at: datetime + + model_config = {"from_attributes": True} + + +class StagedWorkDetail(StagedWorkResponse): + text_preview: str | None = None + + +# ─── Дашборд ────────────────────────────────────────────────────────────────── +class ServiceHealth(BaseModel): + name: str + ok: bool + detail: str | None = None + + +class AdminStats(BaseModel): + users_total: int + tasks_by_status: dict[str, int] + documents_total: int + documents_by_source: dict[str, int] + staging_pending: int + storage: dict[str, Any] + queues: dict[str, Any] diff --git a/services/api/app/schemas/auth.py b/services/api/app/schemas/auth.py index e738297..a2a762f 100644 --- a/services/api/app/schemas/auth.py +++ b/services/api/app/schemas/auth.py @@ -26,6 +26,7 @@ class UserInToken(BaseModel): name: str plan: str is_verified: bool + is_admin: bool = False model_config = {"from_attributes": True} diff --git a/services/api/requirements.txt b/services/api/requirements.txt index e0a3b8d..5a44660 100644 --- a/services/api/requirements.txt +++ b/services/api/requirements.txt @@ -10,6 +10,7 @@ pydantic==2.7.1 pydantic-settings==2.2.1 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 +bcrypt==4.0.1 # passlib 1.7.4 несовместим с bcrypt>=4.1 (ошибка __about__ / 72-byte limit) python-multipart==0.0.9 minio==7.2.7 httpx==0.27.0 diff --git a/services/frontend/Dockerfile.dev b/services/frontend/Dockerfile.dev new file mode 100644 index 0000000..9890151 --- /dev/null +++ b/services/frontend/Dockerfile.dev @@ -0,0 +1,13 @@ +# Dev-образ фронтенда: Vite dev-server с hot reload +FROM node:20-slim + +WORKDIR /app + +# Сначала зависимости (кэш слоёв) +COPY package.json package-lock.json* ./ +RUN npm install + +COPY . . + +EXPOSE 5173 +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] diff --git a/services/frontend/package-lock.json b/services/frontend/package-lock.json new file mode 100644 index 0000000..daf7a62 --- /dev/null +++ b/services/frontend/package-lock.json @@ -0,0 +1,2968 @@ +{ + "name": "academic-helper-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "academic-helper-frontend", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.40.0", + "axios": "^1.7.2", + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "lucide-react": "^0.390.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "react-dropzone": "^14.4.1", + "react-hot-toast": "^2.4.1", + "react-router-dom": "^6.23.0", + "tailwind-merge": "^2.3.0", + "zustand": "^4.5.2" + }, + "devDependencies": { + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.0", + "autoprefixer": "^10.4.19", + "eslint": "^9.4.0", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5", + "vite": "^5.2.12" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.3", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.14", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.14", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.29", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "license": "MIT" + }, + "node_modules/attr-accept": { + "version": "2.2.5", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.16.1", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "license": "MIT" + }, + "node_modules/date-fns": { + "version": "3.6.0", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-selector": { + "version": "2.1.2", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/goober": { + "version": "2.1.19", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "1.21.7", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.390.0", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.46", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-dropzone": { + "version": "14.4.1", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.4.1.tgz", + "integrity": "sha512-QDuV76v3uKbHiH34SpwifZ+gOLi1+RdsCO1kl5vxMT4wW8R82+sthjvBw4th3NHF/XX6FBsqDYZVNN+pnhaw0g==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, + "node_modules/react-hot-toast": { + "version": "2.6.0", + "license": "MIT", + "dependencies": { + "csstype": "^3.1.3", + "goober": "^2.1.16" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "react": ">=16", + "react-dom": ">=16" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.4", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.4", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.3", + "react-router": "6.30.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "4.5.7", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + } + } +} diff --git a/services/frontend/package.json b/services/frontend/package.json index fb216ed..502d2dc 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -9,28 +9,28 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.40.0", + "axios": "^1.7.2", + "clsx": "^2.1.1", + "date-fns": "^3.6.0", + "lucide-react": "^0.390.0", "react": "^18.3.0", "react-dom": "^18.3.0", - "react-router-dom": "^6.23.0", - "@tanstack/react-query": "^5.40.0", - "zustand": "^4.5.2", - "axios": "^1.7.2", - "react-dropzone": "^14.2.3", + "react-dropzone": "^14.4.1", "react-hot-toast": "^2.4.1", - "lucide-react": "^0.390.0", - "clsx": "^2.1.1", + "react-router-dom": "^6.23.0", "tailwind-merge": "^2.3.0", - "date-fns": "^3.6.0" + "zustand": "^4.5.2" }, "devDependencies": { "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.0", - "typescript": "^5.4.5", - "vite": "^5.2.12", - "tailwindcss": "^3.4.4", - "postcss": "^8.4.38", "autoprefixer": "^10.4.19", - "eslint": "^9.4.0" + "eslint": "^9.4.0", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5", + "vite": "^5.2.12" } } diff --git a/services/frontend/src/api/client.ts b/services/frontend/src/api/client.ts index 595426c..33fa9d0 100644 --- a/services/frontend/src/api/client.ts +++ b/services/frontend/src/api/client.ts @@ -78,3 +78,42 @@ export const reportsApi = { get: (taskId: string) => api.get(`/reports/${taskId}`), }; + +// ─── Админка ──────────────────────────────────────────────────────────────── +export const adminApi = { + openSession: () => api.post('/admin/session'), + verifySession: (code: string) => api.get(`/admin/session/verify/${code}`), + + health: () => api.get('/admin/health'), + stats: () => api.get('/admin/stats'), + + users: (params?: { q?: string; limit?: number; offset?: number }) => + api.get('/admin/users', { params }), + updateUser: (id: number, data: { plan?: string; is_verified?: boolean; is_admin?: boolean }) => + api.patch(`/admin/users/${id}`, data), + deleteUser: (id: number) => api.delete(`/admin/users/${id}`), + + tasks: (params?: { status?: string; type?: string; user_id?: number; limit?: number; offset?: number }) => + api.get('/admin/tasks', { params }), + task: (publicId: string) => api.get(`/admin/tasks/${publicId}`), + requeueTask: (publicId: string) => api.post(`/admin/tasks/${publicId}/requeue`), + deleteTask: (publicId: string) => api.delete(`/admin/tasks/${publicId}`), + + documents: (params?: { q?: string; source?: string; year?: number; limit?: number; offset?: number }) => + api.get('/admin/documents', { params }), + deleteDocument: (id: number) => api.delete(`/admin/documents/${id}`), + + storage: () => api.get('/admin/storage'), + + sources: () => api.get('/admin/sources'), + createSource: (data: Record) => api.post('/admin/sources', data), + updateSource: (id: number, data: Record) => api.patch(`/admin/sources/${id}`, data), + deleteSource: (id: number) => api.delete(`/admin/sources/${id}`), + runSource: (id: number) => api.post(`/admin/sources/${id}/run`), + + staging: (params?: { status?: string; limit?: number; offset?: number }) => + api.get('/admin/staging', { params }), + stagingDetail: (id: number) => api.get(`/admin/staging/${id}`), + approveStaging: (id: number) => api.post(`/admin/staging/${id}/approve`), + rejectStaging: (id: number) => api.post(`/admin/staging/${id}/reject`), +}; diff --git a/services/frontend/src/components/TaskCard.tsx b/services/frontend/src/components/TaskCard.tsx index 9e95c98..d3854c3 100644 --- a/services/frontend/src/components/TaskCard.tsx +++ b/services/frontend/src/components/TaskCard.tsx @@ -71,7 +71,7 @@ export function TaskCard({ task }: TaskCardProps) { return (
diff --git a/services/frontend/src/main.tsx b/services/frontend/src/main.tsx index 46107eb..3d0de64 100644 --- a/services/frontend/src/main.tsx +++ b/services/frontend/src/main.tsx @@ -14,6 +14,14 @@ import { Task } from './pages/Task'; import { Pricing } from './pages/Pricing'; import { Login } from './pages/Login'; import { Register } from './pages/Register'; +import { AdminLayout } from './pages/admin/AdminLayout'; +import { Dashboard } from './pages/admin/Dashboard'; +import { Users as AdminUsers } from './pages/admin/Users'; +import { Works as AdminWorks } from './pages/admin/Works'; +import { Documents as AdminDocuments } from './pages/admin/Documents'; +import { Storage as AdminStorage } from './pages/admin/Storage'; +import { Sources as AdminSources } from './pages/admin/Sources'; +import { Staging as AdminStaging } from './pages/admin/Staging'; import './index.css'; @@ -26,23 +34,51 @@ const queryClient = new QueryClient({ }, }); +// Маршруты пользовательской части (обёрнуты в общий Layout) +function PublicApp() { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} + ReactDOM.createRoot(document.getElementById('root')!).render( - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - + + {/* Админка — собственный layout, защита по is_admin + коду сессии */} + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + } + /> + {/* Всё остальное — пользовательская часть */} + } /> + { + try { + const { data } = await adminApi.openSession(); + navigate(`${data.url}/dashboard`); + } catch { + toast.error('Не удалось открыть админ-панель'); + } + }; if (!isAuthenticated) { return ; @@ -49,9 +60,20 @@ export function Cabinet() {

{user?.email}

- +
+ {user?.is_admin && ( + + )} + +
@@ -116,7 +138,7 @@ export function Cabinet() { {tasks && tasks.length > 0 && (
{tasks.map((task) => ( - + ))}
)} diff --git a/services/frontend/src/pages/Check.tsx b/services/frontend/src/pages/Check.tsx index 205f80a..20dda9c 100644 --- a/services/frontend/src/pages/Check.tsx +++ b/services/frontend/src/pages/Check.tsx @@ -53,14 +53,14 @@ export function Check() {

Проверка запущена

- Файл {(submittedTask.input_data as any).filename} передан на проверку. + Файл {file?.name ?? 'документ'} передан на проверку.

Вы получите email когда проверка завершится. Обычно это занимает 1-3 минуты.

Следить за прогрессом diff --git a/services/frontend/src/pages/Search.tsx b/services/frontend/src/pages/Search.tsx index 26e2000..3c479b1 100644 --- a/services/frontend/src/pages/Search.tsx +++ b/services/frontend/src/pages/Search.tsx @@ -27,7 +27,7 @@ export function Search() { mutationFn: searchApi.create, onSuccess: (response) => { const task: Task = response.data; - setTaskId(task.id); + setTaskId(task.public_id); }, onError: (error: any) => { const msg = error.response?.data?.detail || 'Ошибка поиска'; diff --git a/services/frontend/src/pages/Task.tsx b/services/frontend/src/pages/Task.tsx index 6d6eacd..b11de26 100644 --- a/services/frontend/src/pages/Task.tsx +++ b/services/frontend/src/pages/Task.tsx @@ -60,7 +60,7 @@ export function Task() {
- {task.id} + {task.public_id}
@@ -91,8 +91,7 @@ export function Task() { {task.status === 'done' && task.type === 'search' && task.result && (

- Запрос: «{(task.input_data as any).query}» - {' · '}{(task.result as SearchResultData).total} источников + Найдено {(task.result as SearchResultData).total} источников

{(task.result as SearchResultData).sources.map((source) => ( @@ -104,7 +103,7 @@ export function Task() { {task.status === 'done' && task.type === 'plagiarism' && task.result && (

- Файл: {(task.input_data as any).filename} + Результат проверки плагиата

diff --git a/services/frontend/src/pages/admin/AdminLayout.tsx b/services/frontend/src/pages/admin/AdminLayout.tsx new file mode 100644 index 0000000..546444f --- /dev/null +++ b/services/frontend/src/pages/admin/AdminLayout.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { NavLink, useParams, useNavigate, Navigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { + LayoutDashboard, Users, FileText, Database, HardDrive, Download, Inbox, LogOut, ShieldAlert, +} from 'lucide-react'; +import { clsx } from 'clsx'; +import { adminApi } from '../../api/client'; +import { useAuthStore } from '../../store/auth'; + +const NAV = [ + { to: 'dashboard', label: 'Дашборд', icon: LayoutDashboard }, + { to: 'users', label: 'Клиенты', icon: Users }, + { to: 'works', label: 'Работы', icon: FileText }, + { to: 'documents', label: 'База', icon: Database }, + { to: 'storage', label: 'Хранилище', icon: HardDrive }, + { to: 'sources', label: 'Источники', icon: Download }, + { to: 'staging', label: 'Отстойник', icon: Inbox }, +]; + +interface AdminLayoutProps { + children: React.ReactNode; +} + +export function AdminLayout({ children }: AdminLayoutProps) { + const { code } = useParams<{ code: string }>(); + const { isAuthenticated, user, logout } = useAuthStore(); + const navigate = useNavigate(); + + // Доступ: только аутентифицированный админ + const { data: verify, isLoading } = useQuery({ + queryKey: ['admin-verify', code], + queryFn: () => adminApi.verifySession(code!).then((r) => r.data), + enabled: !!code && isAuthenticated && !!user?.is_admin, + retry: false, + }); + + if (!isAuthenticated || !user?.is_admin) { + return ; + } + + if (isLoading) { + return
Проверка доступа…
; + } + + if (!verify?.valid) { + return ( +
+ +

Код доступа недействителен или истёк.

+ +
+ ); + } + + return ( +
+ {/* Сайдбар */} + + + {/* Контент */} +
{children}
+
+ ); +} diff --git a/services/frontend/src/pages/admin/Dashboard.tsx b/services/frontend/src/pages/admin/Dashboard.tsx new file mode 100644 index 0000000..793a8bd --- /dev/null +++ b/services/frontend/src/pages/admin/Dashboard.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { CheckCircle2, XCircle, Users, FileText, Database, Inbox } from 'lucide-react'; +import { adminApi } from '../../api/client'; + +interface Health { name: string; ok: boolean; detail?: string } +interface Stats { + users_total: number; + tasks_by_status: Record; + documents_total: number; + documents_by_source: Record; + staging_pending: number; + storage: Record; + queues: Record; +} + +function fmtBytes(b: number): string { + if (b < 1024) return `${b} Б`; + if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} КБ`; + if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(1)} МБ`; + return `${(b / 1024 ** 3).toFixed(2)} ГБ`; +} + +export function Dashboard() { + const { data: health } = useQuery({ + queryKey: ['admin-health'], + queryFn: () => adminApi.health().then((r) => r.data as Health[]), + refetchInterval: 10000, + }); + const { data: stats } = useQuery({ + queryKey: ['admin-stats'], + queryFn: () => adminApi.stats().then((r) => r.data as Stats), + refetchInterval: 10000, + }); + + const totalStorage = Object.values(stats?.storage || {}).reduce( + (acc: number, v) => acc + (typeof v === 'object' && v && 'bytes' in v ? (v as { bytes: number }).bytes : 0), + 0 + ); + + return ( +
+

Дашборд

+ + {/* Счётчики */} +
+ + a + b, 0)} color="text-purple-600" /> + + +
+ +
+ {/* Здоровье сервисов */} +
+

Сервисы

+
+ {health?.map((h) => ( +
+
+ {h.ok ? : } + {h.name} +
+ {h.detail || (h.ok ? 'OK' : 'недоступен')} +
+ ))} + {!health &&
Загрузка…
} +
+
+ + {/* Работы по статусам + очереди */} +
+
+

Работы по статусам

+
+ {Object.entries(stats?.tasks_by_status || {}).map(([s, c]) => ( + {s}: {c} + ))} + {!Object.keys(stats?.tasks_by_status || {}).length && нет данных} +
+
+
+

Очереди

+
+ {Object.entries(stats?.queues || {}).map(([q, n]) => ( + {q}: {String(n)} + ))} +
+
+
+

Хранилище

+

Всего: {fmtBytes(totalStorage)}

+
+
+
+ + {/* База по источникам */} +
+

База документов по источникам

+
+ {Object.entries(stats?.documents_by_source || {}).map(([s, c]) => ( + {s}: {c} + ))} + {!Object.keys(stats?.documents_by_source || {}).length && база пуста} +
+
+
+ ); +} + +function StatCard({ icon: Icon, label, value, color }: { icon: React.ElementType; label: string; value: React.ReactNode; color: string }) { + return ( +
+ +
{value}
+
{label}
+
+ ); +} diff --git a/services/frontend/src/pages/admin/Documents.tsx b/services/frontend/src/pages/admin/Documents.tsx new file mode 100644 index 0000000..7aa1ef4 --- /dev/null +++ b/services/frontend/src/pages/admin/Documents.tsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Trash2, Search } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { adminApi } from '../../api/client'; + +interface Doc { + id: number; source: string; title: string; authors: Array<{ last_name?: string }>; + year: number | null; lang: string | null; url: string | null; indexed_at: string; +} + +export function Documents() { + const qc = useQueryClient(); + const [q, setQ] = useState(''); + const [source, setSource] = useState(''); + const { data: docs } = useQuery({ + queryKey: ['admin-docs', q, source], + queryFn: () => adminApi.documents({ q: q || undefined, source: source || undefined }).then((r) => r.data as Doc[]), + }); + const del = useMutation({ + mutationFn: (id: number) => adminApi.deleteDocument(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-docs'] }); toast.success('Удалён из базы'); }, + onError: () => toast.error('Ошибка'), + }); + + return ( +
+

База документов

+
+
+ + setQ(e.target.value)} placeholder="Поиск по названию" + className="w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg text-sm" /> +
+ +
+
+ + + + + + + + + {docs?.map((d) => ( + + + + + + + + ))} + +
НазваниеИсточникГодЯзык
{d.title}{d.source}{d.year || '—'}{d.lang || '—'} + +
+ {!docs?.length &&
База пуста
} +
+
+ ); +} diff --git a/services/frontend/src/pages/admin/Sources.tsx b/services/frontend/src/pages/admin/Sources.tsx new file mode 100644 index 0000000..39c1de0 --- /dev/null +++ b/services/frontend/src/pages/admin/Sources.tsx @@ -0,0 +1,114 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Play, Trash2, Plus } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { adminApi } from '../../api/client'; + +interface Source { + id: number; source_type: string; name: string; query: string | null; + lang: string | null; year_from: number | null; year_to: number | null; + limit: number; enabled: boolean; last_status: string; last_error: string | null; + last_run_at: string | null; docs_added: number; +} + +const STATUS_COLORS: Record = { + idle: 'bg-gray-100 text-gray-600', + running: 'bg-blue-100 text-blue-700', + done: 'bg-emerald-100 text-emerald-700', + error: 'bg-red-100 text-red-700', +}; + +export function Sources() { + const qc = useQueryClient(); + const [form, setForm] = useState({ source_type: 'openalex', name: '', query: '', lang: '', limit: 100 }); + const { data: sources } = useQuery({ + queryKey: ['admin-sources'], + queryFn: () => adminApi.sources().then((r) => r.data as Source[]), + refetchInterval: 5000, + }); + + const create = useMutation({ + mutationFn: (data: Record) => adminApi.createSource(data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-sources'] }); toast.success('Источник добавлен'); setForm({ source_type: 'openalex', name: '', query: '', lang: '', limit: 100 }); }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Ошибка'), + }); + const run = useMutation({ + mutationFn: (id: number) => adminApi.runSource(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-sources'] }); toast.success('Парсинг запущен'); }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Ошибка'), + }); + const del = useMutation({ + mutationFn: (id: number) => adminApi.deleteSource(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-sources'] }); toast.success('Удалён'); }, + }); + + return ( +
+

Источники парсинга

+ + {/* Форма добавления */} +
+

Добавить источник

+
+ + setForm({ ...form, name: e.target.value })} placeholder="Название" + className="border border-gray-200 rounded-lg px-3 py-2 text-sm" /> + setForm({ ...form, query: e.target.value })} placeholder="Запрос" + className="border border-gray-200 rounded-lg px-3 py-2 text-sm" /> + setForm({ ...form, lang: e.target.value })} placeholder="Язык (ru/en)" + className="border border-gray-200 rounded-lg px-3 py-2 text-sm" /> + setForm({ ...form, limit: Number(e.target.value) })} placeholder="Лимит" + className="border border-gray-200 rounded-lg px-3 py-2 text-sm" /> +
+ +
+ + {/* Список */} +
+ + + + + + + + + + {sources?.map((s) => ( + + + + + + + + + + ))} + +
НазваниеТипЗапросЛимитСтатусДобавлено
{s.name}{s.source_type}{s.query || '—'}{s.limit} + {s.last_status} + {s.docs_added} + + +
+ {!sources?.length &&
Нет источников
} +
+
+ ); +} diff --git a/services/frontend/src/pages/admin/Staging.tsx b/services/frontend/src/pages/admin/Staging.tsx new file mode 100644 index 0000000..744891f --- /dev/null +++ b/services/frontend/src/pages/admin/Staging.tsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Check, X, Eye } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { adminApi } from '../../api/client'; + +interface Staged { + id: number; user_id: number | null; filename: string | null; title: string | null; + word_count: number | null; status: string; document_id: number | null; created_at: string; +} +interface StagedDetail extends Staged { text_preview: string | null } + +const STATUS_COLORS: Record = { + pending: 'bg-amber-100 text-amber-700', + approved: 'bg-emerald-100 text-emerald-700', + rejected: 'bg-red-100 text-red-700', +}; + +export function Staging() { + const qc = useQueryClient(); + const [status, setStatus] = useState('pending'); + const [preview, setPreview] = useState(null); + + const { data: items } = useQuery({ + queryKey: ['admin-staging', status], + queryFn: () => adminApi.staging({ status }).then((r) => r.data as Staged[]), + refetchInterval: 8000, + }); + + const approve = useMutation({ + mutationFn: (id: number) => adminApi.approveStaging(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-staging'] }); setPreview(null); toast.success('Одобрено — добавляется в базу'); }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Ошибка'), + }); + const reject = useMutation({ + mutationFn: (id: number) => adminApi.rejectStaging(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-staging'] }); setPreview(null); toast.success('Отклонено'); }, + }); + + const openPreview = async (id: number) => { + const { data } = await adminApi.stagingDetail(id); + setPreview(data as StagedDetail); + }; + + return ( +
+

Отстойник проверенных работ

+

Работы, прошедшие проверку. Одобрение добавляет их в базу источников для будущих сравнений.

+ +
+ {['pending', 'approved', 'rejected', 'all'].map((s) => ( + + ))} +
+ +
+ + + + + + + + + + {items?.map((s) => ( + + + + + + + + + ))} + +
ФайлКлиентСловСтатусДата
{s.filename || s.title || `#${s.id}`}{s.user_id ? `#${s.user_id}` : '—'}{s.word_count ?? '—'}{s.status}{new Date(s.created_at).toLocaleString('ru')} + + {s.status === 'pending' && ( + <> + + + + )} +
+ {!items?.length &&
Пусто
} +
+ + {/* Превью */} + {preview && ( +
setPreview(null)}> +
e.stopPropagation()}> +
+

{preview.filename || preview.title}

+ +
+
+ {preview.text_preview || '(нет текста)'} +
+ {preview.status === 'pending' && ( +
+ + +
+ )} +
+
+ )} +
+ ); +} diff --git a/services/frontend/src/pages/admin/Storage.tsx b/services/frontend/src/pages/admin/Storage.tsx new file mode 100644 index 0000000..aef4ba3 --- /dev/null +++ b/services/frontend/src/pages/admin/Storage.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { HardDrive } from 'lucide-react'; +import { adminApi } from '../../api/client'; + +interface Bucket { + name: string; objects_total?: number; bytes?: number; + sample?: Array<{ name: string; size: number }>; error?: string; +} + +function fmtBytes(b: number): string { + if (b < 1024) return `${b} Б`; + if (b < 1024 ** 2) return `${(b / 1024).toFixed(1)} КБ`; + if (b < 1024 ** 3) return `${(b / 1024 ** 2).toFixed(1)} МБ`; + return `${(b / 1024 ** 3).toFixed(2)} ГБ`; +} + +export function Storage() { + const { data } = useQuery({ + queryKey: ['admin-storage'], + queryFn: () => adminApi.storage().then((r) => r.data as { buckets: Bucket[] }), + refetchInterval: 15000, + }); + + return ( +
+

Хранилище (MinIO)

+
+ {data?.buckets.map((b) => ( +
+
+ + {b.name} +
+ {b.error ? ( +

{b.error}

+ ) : ( + <> +

Объектов: {b.objects_total}

+

Объём: {fmtBytes(b.bytes || 0)}

+
+ {b.sample?.map((o) => ( +
+ {o.name} + {fmtBytes(o.size)} +
+ ))} +
+ + )} +
+ ))} + {!data?.buckets.length &&
Нет бакетов
} +
+
+ ); +} diff --git a/services/frontend/src/pages/admin/Users.tsx b/services/frontend/src/pages/admin/Users.tsx new file mode 100644 index 0000000..b7b797e --- /dev/null +++ b/services/frontend/src/pages/admin/Users.tsx @@ -0,0 +1,92 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Trash2, Search } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { adminApi } from '../../api/client'; + +interface AdminUser { + id: number; email: string; name: string; plan: string; + is_verified: boolean; is_admin: boolean; created_at: string; tasks_count: number; +} + +const PLANS = ['free', 'student', 'premium', 'science']; + +export function Users() { + const qc = useQueryClient(); + const [q, setQ] = useState(''); + const { data: users } = useQuery({ + queryKey: ['admin-users', q], + queryFn: () => adminApi.users({ q: q || undefined }).then((r) => r.data as AdminUser[]), + }); + + const update = useMutation({ + mutationFn: ({ id, data }: { id: number; data: Record }) => adminApi.updateUser(id, data), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-users'] }); toast.success('Сохранено'); }, + onError: () => toast.error('Ошибка'), + }); + const del = useMutation({ + mutationFn: (id: number) => adminApi.deleteUser(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-users'] }); toast.success('Удалён'); }, + onError: () => toast.error('Ошибка удаления'), + }); + + return ( +
+

Клиенты

+
+ + setQ(e.target.value)} placeholder="Поиск по email/имени" + className="w-full pl-9 pr-3 py-2 border border-gray-200 rounded-lg text-sm" + /> +
+
+ + + + + + + + + + + {users?.map((u) => ( + + + + + + + + + + ))} + +
EmailИмяТарифРаботVerifiedAdmin
{u.email}{u.name} + + {u.tasks_count} + update.mutate({ id: u.id, data: { is_verified: e.target.checked } })} /> + + update.mutate({ id: u.id, data: { is_admin: e.target.checked } })} /> + + +
+ {!users?.length &&
Нет клиентов
} +
+
+ ); +} diff --git a/services/frontend/src/pages/admin/Works.tsx b/services/frontend/src/pages/admin/Works.tsx new file mode 100644 index 0000000..9d7231b --- /dev/null +++ b/services/frontend/src/pages/admin/Works.tsx @@ -0,0 +1,81 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { RefreshCw, Trash2 } from 'lucide-react'; +import toast from 'react-hot-toast'; +import { adminApi } from '../../api/client'; + +interface AdminTask { + public_id: string; user_id: number; type: string; status: string; + error: string | null; created_at: string; +} + +const STATUS_COLORS: Record = { + queued: 'bg-gray-100 text-gray-600', + processing: 'bg-blue-100 text-blue-700', + done: 'bg-emerald-100 text-emerald-700', + failed: 'bg-red-100 text-red-700', +}; + +export function Works() { + const qc = useQueryClient(); + const [status, setStatus] = useState(''); + const { data: tasks } = useQuery({ + queryKey: ['admin-tasks', status], + queryFn: () => adminApi.tasks({ status: status || undefined }).then((r) => r.data as AdminTask[]), + refetchInterval: 8000, + }); + + const requeue = useMutation({ + mutationFn: (id: string) => adminApi.requeueTask(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-tasks'] }); toast.success('Перезапущено'); }, + onError: (e: any) => toast.error(e.response?.data?.detail || 'Ошибка'), + }); + const del = useMutation({ + mutationFn: (id: string) => adminApi.deleteTask(id), + onSuccess: () => { qc.invalidateQueries({ queryKey: ['admin-tasks'] }); toast.success('Удалено'); }, + onError: () => toast.error('Ошибка'), + }); + + return ( +
+

Работы

+
+ {['', 'queued', 'processing', 'done', 'failed'].map((s) => ( + + ))} +
+
+ + + + + + + + + + {tasks?.map((t) => ( + + + + + + + + + ))} + +
IDКлиентТипСтатусСоздано
{t.public_id.slice(0, 10)}…#{t.user_id}{t.type}{t.status}{new Date(t.created_at).toLocaleString('ru')} + + +
+ {!tasks?.length &&
Нет работ
} +
+
+ ); +} diff --git a/services/frontend/src/types/index.ts b/services/frontend/src/types/index.ts index 468208c..fbffb46 100644 --- a/services/frontend/src/types/index.ts +++ b/services/frontend/src/types/index.ts @@ -12,6 +12,7 @@ export interface User { name: string; plan: UserPlan; is_verified: boolean; + is_admin?: boolean; } // ─── Источник в результатах поиска ──────────────────────────────────────────── @@ -88,12 +89,12 @@ export interface GostResultData { export type TaskResult = SearchResultData | PlagiarismResultData | GostResultData | null; export interface Task { - id: string; + // Бэкенд отдаёт public_id (а не внутренний id) и не возвращает input_data + public_id: string; type: TaskType; status: TaskStatus; queue_position?: number; eta_seconds?: number; - input_data: Record; result?: TaskResult; error?: string; created_at: string; diff --git a/services/frontend/vite.config.ts b/services/frontend/vite.config.ts index a4dd028..7b8e615 100644 --- a/services/frontend/vite.config.ts +++ b/services/frontend/vite.config.ts @@ -1,17 +1,21 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +const apiTarget = process.env.VITE_API_TARGET || 'http://localhost:8000'; +const wsTarget = apiTarget.replace(/^http/, 'ws'); + export default defineConfig({ plugins: [react()], server: { port: 5173, + host: true, proxy: { '/api': { - target: 'http://localhost:8000', + target: apiTarget, changeOrigin: true, }, '/ws': { - target: 'ws://localhost:8000', + target: wsTarget, ws: true, }, }, diff --git a/services/worker-gost/app/models/__init__.py b/services/worker-gost/app/models/__init__.py new file mode 100644 index 0000000..218d64d --- /dev/null +++ b/services/worker-gost/app/models/__init__.py @@ -0,0 +1,39 @@ +"""Минимальные модели для gost-воркера.""" + +from datetime import datetime + +from sqlalchemy import JSON, Integer, String, Text, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class Task(Base): + __tablename__ = "tasks" + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[int] = mapped_column(Integer) + type: Mapped[str] = mapped_column(String(20)) + status: Mapped[str] = mapped_column(String(20)) + result: Mapped[dict | None] = mapped_column(JSON, nullable=True) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + + +class Document(Base): + __tablename__ = "documents" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + source: Mapped[str] = mapped_column(String(50)) + ext_id: Mapped[str] = mapped_column(String(255)) + doi: Mapped[str | None] = mapped_column(String(255), nullable=True) + title: Mapped[str] = mapped_column(Text) + authors: Mapped[list] = mapped_column(JSON, default=list) + year: Mapped[int | None] = mapped_column(nullable=True) + lang: Mapped[str | None] = mapped_column(String(10), nullable=True) + journal: Mapped[str | None] = mapped_column(Text, nullable=True) + volume: Mapped[str | None] = mapped_column(String(50), nullable=True) + issue: Mapped[str | None] = mapped_column(String(50), nullable=True) + pages: Mapped[str | None] = mapped_column(String(50), nullable=True) + abstract: Mapped[str | None] = mapped_column(Text, nullable=True) + url: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/services/worker-gpu/Dockerfile b/services/worker-gpu/Dockerfile index 04db235..99167d0 100644 --- a/services/worker-gpu/Dockerfile +++ b/services/worker-gpu/Dockerfile @@ -12,10 +12,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ curl \ && rm -rf /var/lib/apt/lists/* -# Сделать python3.11 дефолтным +# Сделать python3.11 дефолтным и поставить pip именно для 3.11 (на CUDA-базе +# python3-pip ставит pip для системного python3.10 — он не виден из python3.11) RUN update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.11 1 && \ - update-alternatives --install /usr/bin/python python python3.11 1 && \ - python3.11 -m pip install --upgrade pip + update-alternatives --install /usr/bin/python python /usr/bin/python3.11 1 && \ + curl -sS https://bootstrap.pypa.io/get-pip.py | python3.11 WORKDIR /app diff --git a/services/worker-gpu/app/config.py b/services/worker-gpu/app/config.py index 9d256ca..a5ee271 100644 --- a/services/worker-gpu/app/config.py +++ b/services/worker-gpu/app/config.py @@ -10,6 +10,7 @@ class Settings(BaseSettings): env_file=".env", env_file_encoding="utf-8", case_sensitive=False, + extra="ignore", # игнорировать поля общего .env, не относящиеся к воркеру ) # PostgreSQL diff --git a/services/worker-gpu/app/models/__init__.py b/services/worker-gpu/app/models/__init__.py new file mode 100644 index 0000000..f0c2bbd --- /dev/null +++ b/services/worker-gpu/app/models/__init__.py @@ -0,0 +1,3 @@ +from app.models.base import Base, Document, Task, User + +__all__ = ["Base", "User", "Task", "Document"] diff --git a/services/worker-gpu/app/models/base.py b/services/worker-gpu/app/models/base.py new file mode 100644 index 0000000..f54fe42 --- /dev/null +++ b/services/worker-gpu/app/models/base.py @@ -0,0 +1,67 @@ +"""Базовые модели данных для GPU воркера (минимальный набор для работы с БД).""" + +from datetime import datetime +from uuid import uuid4 + +from sqlalchemy import JSON, BigInteger, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +class Base(DeclarativeBase): + """Базовый класс SQLAlchemy моделей.""" + pass + + +class User(Base): + """Минимальная модель пользователя для GPU воркера.""" + + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + email: Mapped[str] = mapped_column(String(255)) + name: Mapped[str] = mapped_column(String(255)) + plan: Mapped[str] = mapped_column(String(20), default="free") + is_verified: Mapped[bool] = mapped_column(default=False) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + + +class Task(Base): + """Модель задачи.""" + + __tablename__ = "tasks" + + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + type: Mapped[str] = mapped_column(String(20)) + status: Mapped[str] = mapped_column(String(20), default="queued") + celery_task_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + input_data: Mapped[dict] = mapped_column(JSON, default=dict) + result: Mapped[dict | None] = mapped_column(JSON, nullable=True) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + queue_position: Mapped[int | None] = mapped_column(nullable=True) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime | None] = mapped_column(nullable=True) + + +class Document(Base): + """Модель документа в базе знаний.""" + + __tablename__ = "documents" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + source: Mapped[str] = mapped_column(String(50)) + ext_id: Mapped[str] = mapped_column(String(255)) + doi: Mapped[str | None] = mapped_column(String(255), nullable=True) + title: Mapped[str] = mapped_column(Text) + authors: Mapped[list] = mapped_column(JSON, default=list) + year: Mapped[int | None] = mapped_column(nullable=True) + lang: Mapped[str | None] = mapped_column(String(10), nullable=True) + journal: Mapped[str | None] = mapped_column(Text, nullable=True) + volume: Mapped[str | None] = mapped_column(String(50), nullable=True) + issue: Mapped[str | None] = mapped_column(String(50), nullable=True) + pages: Mapped[str | None] = mapped_column(String(50), nullable=True) + abstract: Mapped[str | None] = mapped_column(Text, nullable=True) + url: Mapped[str | None] = mapped_column(Text, nullable=True) + minio_key: Mapped[str | None] = mapped_column(Text, nullable=True) + faiss_id: Mapped[int | None] = mapped_column(nullable=True) + indexed_at: Mapped[datetime] = mapped_column(server_default=func.now()) diff --git a/services/worker-gpu/requirements.txt b/services/worker-gpu/requirements.txt index 7a4e7f8..37e8d70 100644 --- a/services/worker-gpu/requirements.txt +++ b/services/worker-gpu/requirements.txt @@ -3,7 +3,7 @@ redis==5.0.4 sqlalchemy==2.0.30 psycopg2-binary==2.9.9 sentence-transformers==3.0.0 -faiss-gpu==1.7.4 +faiss-cpu==1.8.0 # faiss-gpu нет в pip для py3.11; индексы в коде CPU-типа, GPU занят эмбеддингами (torch) и LLM (Ollama) torch==2.3.0 numpy==1.26.4 httpx==0.27.0 diff --git a/services/worker-indexer/app/algorithms/winnowing.py b/services/worker-indexer/app/algorithms/winnowing.py index 6130171..732fdec 100644 --- a/services/worker-indexer/app/algorithms/winnowing.py +++ b/services/worker-indexer/app/algorithms/winnowing.py @@ -31,9 +31,12 @@ def hash_ngram(ngram: str) -> int: ngram: n-грамма для хэширования Returns: - 64-битный хэш + Знаковый 64-битный хэш (умещается в PostgreSQL BIGINT) """ - return xxhash.xxh64(ngram.encode("utf-8")).intdigest() + # xxh64 даёт unsigned 64-bit (до 2^64-1), а PostgreSQL BIGINT — signed + # (макс 2^63-1). Приводим к диапазону signed int64. + unsigned = xxhash.xxh64(ngram.encode("utf-8")).intdigest() + return unsigned - (1 << 64) if unsigned >= (1 << 63) else unsigned def winnow(text: str, k: int = 5, window: int = 4) -> set[int]: diff --git a/services/worker-indexer/app/config.py b/services/worker-indexer/app/config.py index c80a8d5..d257f66 100644 --- a/services/worker-indexer/app/config.py +++ b/services/worker-indexer/app/config.py @@ -10,6 +10,7 @@ class Settings(BaseSettings): env_file=".env", env_file_encoding="utf-8", case_sensitive=False, + extra="ignore", ) # PostgreSQL @@ -30,6 +31,7 @@ class Settings(BaseSettings): MINIO_ACCESS_KEY: str = "minioadmin" MINIO_SECRET_KEY: str = "changeme" MINIO_BUCKET_DOCS: str = "documents" + MINIO_BUCKET_STAGING: str = "staging" # Elasticsearch ELASTICSEARCH_URL: str = "http://elasticsearch:9200" diff --git a/services/worker-indexer/app/models/__init__.py b/services/worker-indexer/app/models/__init__.py new file mode 100644 index 0000000..aa4d32f --- /dev/null +++ b/services/worker-indexer/app/models/__init__.py @@ -0,0 +1,103 @@ +"""Минимальные модели для индексер-воркера.""" + +from datetime import datetime + +from sqlalchemy import JSON, BigInteger, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + email: Mapped[str] = mapped_column(String(255)) + name: Mapped[str] = mapped_column(String(255)) + plan: Mapped[str] = mapped_column(String(20), default="free") + + +class Task(Base): + __tablename__ = "tasks" + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + type: Mapped[str] = mapped_column(String(20)) + status: Mapped[str] = mapped_column(String(20), default="queued") + celery_task_id: Mapped[str | None] = mapped_column(String(255), nullable=True) + input_data: Mapped[dict] = mapped_column(JSON, default=dict) + result: Mapped[dict | None] = mapped_column(JSON, nullable=True) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + queue_position: Mapped[int | None] = mapped_column(nullable=True) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + updated_at: Mapped[datetime | None] = mapped_column(nullable=True) + + +class Document(Base): + __tablename__ = "documents" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + source: Mapped[str] = mapped_column(String(50)) + ext_id: Mapped[str] = mapped_column(String(255), unique=True) + doi: Mapped[str | None] = mapped_column(String(255), nullable=True) + title: Mapped[str] = mapped_column(Text) + authors: Mapped[list] = mapped_column(JSON, default=list) + year: Mapped[int | None] = mapped_column(nullable=True) + lang: Mapped[str | None] = mapped_column(String(10), nullable=True) + journal: Mapped[str | None] = mapped_column(Text, nullable=True) + volume: Mapped[str | None] = mapped_column(String(50), nullable=True) + issue: Mapped[str | None] = mapped_column(String(50), nullable=True) + pages: Mapped[str | None] = mapped_column(String(50), nullable=True) + abstract: Mapped[str | None] = mapped_column(Text, nullable=True) + url: Mapped[str | None] = mapped_column(Text, nullable=True) + minio_key: Mapped[str | None] = mapped_column(Text, nullable=True) + faiss_id: Mapped[int | None] = mapped_column(nullable=True) + indexed_at: Mapped[datetime] = mapped_column(server_default=func.now()) + + +class Fingerprint(Base): + __tablename__ = "fingerprints" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + doc_id: Mapped[int] = mapped_column(ForeignKey("documents.id")) + hash_value: Mapped[int] = mapped_column(BigInteger) + position: Mapped[int] = mapped_column() + + +class ParseSource(Base): + __tablename__ = "parse_sources" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + source_type: Mapped[str] = mapped_column(String(50)) + name: Mapped[str] = mapped_column(String(255)) + query: Mapped[str | None] = mapped_column(Text, nullable=True) + lang: Mapped[str | None] = mapped_column(String(10), nullable=True) + year_from: Mapped[int | None] = mapped_column(nullable=True) + year_to: Mapped[int | None] = mapped_column(nullable=True) + limit: Mapped[int] = mapped_column(default=1000) + enabled: Mapped[bool] = mapped_column(default=True) + last_status: Mapped[str] = mapped_column(String(20), default="idle") + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) + last_run_at: Mapped[datetime | None] = mapped_column(nullable=True) + docs_added: Mapped[int] = mapped_column(default=0) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + + +class StagedWork(Base): + __tablename__ = "staged_works" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int | None] = mapped_column(nullable=True) + task_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + filename: Mapped[str | None] = mapped_column(Text, nullable=True) + minio_key: Mapped[str | None] = mapped_column(Text, nullable=True) + text_key: Mapped[str | None] = mapped_column(Text, nullable=True) + title: Mapped[str | None] = mapped_column(Text, nullable=True) + authors: Mapped[list] = mapped_column(JSON, default=list) + year: Mapped[int | None] = mapped_column(nullable=True) + lang: Mapped[str | None] = mapped_column(String(10), nullable=True) + word_count: Mapped[int | None] = mapped_column(nullable=True) + status: Mapped[str] = mapped_column(String(20), default="pending") + document_id: Mapped[int | None] = mapped_column(nullable=True) + reviewed_by: Mapped[int | None] = mapped_column(nullable=True) + reviewed_at: Mapped[datetime | None] = mapped_column(nullable=True) + created_at: Mapped[datetime] = mapped_column(server_default=func.now()) + + +__all__ = ["Base", "User", "Task", "Document", "Fingerprint", "ParseSource", "StagedWork"] diff --git a/services/worker-indexer/app/tasks/index.py b/services/worker-indexer/app/tasks/index.py index ce4d8ed..1f7e855 100644 --- a/services/worker-indexer/app/tasks/index.py +++ b/services/worker-indexer/app/tasks/index.py @@ -123,7 +123,13 @@ def extract_and_check( if not text.strip(): raise ValueError("Не удалось извлечь текст из документа") - logger.info(f"Текст извлечён: {len(text)} символов, {len(text.split())} слов") + word_count = len(text.split()) + logger.info(f"Текст извлечён: {len(text)} символов, {word_count} слов") + + # ──── Автосбор в отстойник (буфер для пополнения базы) ──────────────── + # Сохраняем извлечённый текст и метаданные для последующего ручного + # одобрения админом. Не дублируем в базу источников до решения. + _stage_work(task_id, minio_key, filename, text, word_count) # Разбить на фрагменты fragments = _split_into_fragments( @@ -327,3 +333,145 @@ def add_document(doc_data: dict[str, Any]) -> dict[str, Any]: ) return {"status": "indexed", "doc_id": doc_id} + + +def _stage_work( + task_id: str, + minio_key: str, + filename: str, + text: str, + word_count: int, +) -> None: + """Сохранить проверенную работу в отстойник (StagedWork) + текст в MinIO. + + Идемпотентно по task_id: повторный вызов (например при requeue) не дублирует. + Ошибки логируются, но не валят основную проверку плагиата. + """ + try: + from app.models import StagedWork + + # Сохранить извлечённый текст в bucket staging + text_key = f"text/{task_id}.txt" + minio = get_minio() + bucket = settings.MINIO_BUCKET_STAGING + if not minio.bucket_exists(bucket): + minio.make_bucket(bucket) + data = text.encode("utf-8") + minio.put_object( + bucket, text_key, io.BytesIO(data), length=len(data), + content_type="text/plain; charset=utf-8", + ) + + with db_session() as session: + existing = session.execute( + select(StagedWork).where(StagedWork.task_id == task_id) + ).scalar_one_or_none() + if existing: + return + + # Достать user_id из задачи + from app.models import Task + task = session.get(Task, task_id) + user_id = task.user_id if task else None + + session.add(StagedWork( + user_id=user_id, + task_id=task_id, + filename=filename, + minio_key=minio_key, + text_key=text_key, + title=Path(filename).stem if filename else None, + word_count=word_count, + status="pending", + )) + session.commit() + logger.info(f"Работа задачи {task_id!r} добавлена в отстойник") + except Exception as e: + logger.warning(f"Не удалось добавить работу {task_id!r} в отстойник: {e}") + + +@celery_app.task(name="index.run_parser") +def run_parser(source_id: int) -> dict[str, Any]: + """Запустить парсинг источника и наполнить базу документов. + + Переиспользует парсеры из scripts/parsers (BaseParser.run) и + задачу add_document для каждого полученного документа. + """ + import sys + from datetime import datetime, timezone + + from app.models import ParseSource + + # Парсеры лежат в /parsers (скопированы в образ) + if "/parsers" not in sys.path: + sys.path.insert(0, "/parsers") + + with db_session() as session: + src = session.get(ParseSource, source_id) + if src is None: + return {"status": "error", "reason": "источник не найден"} + cfg = { + "source_type": src.source_type, + "query": src.query, + "lang": src.lang, + "year_from": src.year_from, + "year_to": src.year_to, + "limit": src.limit, + } + src.last_status = "running" + session.commit() + + added = 0 + error_msg = None + try: + # Выбрать парсер по типу и собрать совместимые с его fetch() аргументы + stype = cfg["source_type"] + if stype == "openalex": + from openalex import OpenAlexParser as P + fetch_kwargs = { + "query": cfg.get("query") or "", + "limit": cfg["limit"], + "lang": cfg.get("lang"), + "year_from": cfg.get("year_from"), + "year_to": cfg.get("year_to"), + } + elif stype == "cyberleninka": + from cyberleninka import CyberLeninkaParser as P + fetch_kwargs = {"query": cfg.get("query") or "", "limit": cfg["limit"]} + elif stype == "arxiv": + from arxiv import ArxivParser as P + fetch_kwargs = { + "query": cfg.get("query") or "", + "limit": cfg["limit"], + "year_from": cfg.get("year_from"), + } + else: + raise ValueError(f"неизвестный тип источника: {stype}") + + parser = P() + # fetch+transform без записи в JSONL — работаем in-memory + raw_docs = parser.fetch(**fetch_kwargs) + for raw in raw_docs: + try: + doc = parser.transform(raw) + if not (doc and doc.get("title") and doc.get("ext_id")): + continue + result = add_document(doc) + if result.get("status") == "indexed": + added += 1 + except Exception as e: + logger.warning(f"run_parser: ошибка документа: {e}") + except Exception as e: + error_msg = str(e)[:500] + logger.error(f"run_parser source={source_id} ошибка: {e}", exc_info=True) + + with db_session() as session: + src = session.get(ParseSource, source_id) + if src: + src.last_status = "error" if error_msg else "done" + src.last_error = error_msg + src.docs_added = (src.docs_added or 0) + added + src.last_run_at = datetime.now(timezone.utc) + session.commit() + + return {"status": "error" if error_msg else "done", "added": added, "error": error_msg} diff --git a/services/worker-notifier/app/models/__init__.py b/services/worker-notifier/app/models/__init__.py new file mode 100644 index 0000000..bf916d4 --- /dev/null +++ b/services/worker-notifier/app/models/__init__.py @@ -0,0 +1,30 @@ +"""Минимальные модели для notifier-воркера.""" + +from datetime import datetime + +from sqlalchemy import JSON, ForeignKey, Integer, String, Text, func +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + email: Mapped[str] = mapped_column(String(255)) + name: Mapped[str] = mapped_column(String(255)) + plan: Mapped[str] = mapped_column(String(20), default="free") + is_verified: Mapped[bool] = mapped_column(default=False) + + +class Task(Base): + __tablename__ = "tasks" + id: Mapped[str] = mapped_column(String(36), primary_key=True) + user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) + type: Mapped[str] = mapped_column(String(20)) + status: Mapped[str] = mapped_column(String(20)) + result: Mapped[dict | None] = mapped_column(JSON, nullable=True) + error: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column(server_default=func.now())