diff --git a/.env.example b/.env.example index 902ed8f..c47a2a7 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# Telegram (CP workers) — copy from SocialParser/.env +# Telegram (CP workers) TELEGRAM_API_ID=12345678 TELEGRAM_API_HASH=your_api_hash_here TELEGRAM_SESSION_PATH=/data/telegram.session diff --git a/README.md b/README.md index bda1b24..0abede5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MapMil Platform (ЦП → ЦА → ПИ) -Единая платформа на базе MapMil (ЦА — аналитика) и SocialParser (ЦП — парсинг). +Единая платформа: ЦА (аналитика и карта) + ЦП (парсинг Telegram). ## Архитектура @@ -18,7 +18,7 @@ flowchart LR | Центр | Контейнеры | Назначение | |-------|------------|------------| | **ЦА** | `ca-db`, `ca-api`, `ca-frontend` | PostgreSQL, ingest API, карта, distribution API | -| **ЦП** | `cp-workers` | Парсинг Telegram (код из SocialParser) | +| **ЦП** | `cp-workers` | Парсинг Telegram (`centers/parsing/workers`) | | **Общее** | `redis` | Очередь заданий ЦА → ЦП | ## Структура monorepo @@ -32,24 +32,24 @@ MapMil/ │ └── parsing/ │ └── workers/ # CP workers (Telethon) ├── contracts/ # Shared schemas (ingest, jobs) -├── data/ # telegram.session (symlink → SocialParser) +├── data/ # telegram.session (локально, не в git) ├── docker-compose.yml -└── .env # TELEGRAM_* из SocialParser +└── .env # TELEGRAM_API_ID, TELEGRAM_API_HASH, … ``` ## Быстрый старт -1. Скопируйте `.env` из SocialParser (или создайте из `.env.example`): +1. Создайте `.env` из примера и укажите ключи Telegram: ```bash -cp ../SocialParser/.env .env +cp .env.example .env +# отредактируйте TELEGRAM_API_ID и TELEGRAM_API_HASH ``` -2. Убедитесь, что сессия Telegram доступна: +2. Положите сессию Telegram в `data/telegram.session` (файл Telethon SQLite). Если мигрируете со старого SocialParser: ```bash -ls -la data/telegram.session -# symlink → ../SocialParser/data/telegram.session +cp ../SocialParser/data/telegram.session data/ ``` 3. Запуск: @@ -66,7 +66,7 @@ docker compose up --build | Раздел | Путь | Описание | |--------|------|----------| -| **Карта** | `/` | Интерактивная карта с объектами, таймлайном и CRUD. Поддерживает `?eventId=` для перехода к событию | +| **Карта** | `/` | Интерактивная карта событий: навигация по датам (flatpickr), пресеты периода, фильтры региона/темы/источника, подложки Яндекс/OSM/Topo/ESRI, линейка, полноэкранный режим, центрирование по координатам и городам, поиск населённых пунктов (Nominatim). CRUD для ручных объектов (ПКМ). Поддерживает `?eventId=` | | **Парсеры** | `/parsers` | Создание заданий Telegram-парсинга, таблица статусов с автообновлением (5 с), повтор failed-заданий | | **События** | `/events` | Фильтрация, пагинация, просмотр деталей, ссылка «На карте» для событий с координатами | | **Аналитика** | `/analytics` | KPI-карточки, график динамики ingest за 30 дней, топ населённых пунктов и регионов | @@ -76,26 +76,34 @@ docker compose up --build **Важно:** существующий файл сессии **не удаляется и не пересоздаётся**. -- Оригинал: `SocialParser/data/telegram.session` -- В репозитории MapMil: `data/telegram.session` — симлинк для локальной разработки -- В Docker `cp-workers`: каталог `../SocialParser/data` монтируется как `/data` (read-write; Telethon обновляет SQLite-сессию) +- Локально: `data/telegram.session` (SQLite Telethon, в `.gitignore`) +- В Docker `cp-workers`: каталог `./data` монтируется как `/data` (read-write) - Путь в контейнере: `/data/telegram.session` - Переменные из `.env`: `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, `TELEGRAM_SESSION_PATH=/data/telegram.session` -> Симлинк не работает внутри Docker — compose монтирует каталог `SocialParser/data` целиком. - -Файл сессии и `.env` добавлены в `.gitignore` и не коммитятся. +Файл сессии и `.env` не коммитятся. ## API -### Карта (совместимость MapMil) +### Карта | Метод | Путь | Описание | |-------|------|----------| | GET | `/api/health` | Health check | -| GET | `/api/objects` | Объекты на карте (включая события с координатами) | +| GET | `/api/map/objects` | Объекты на карте с данными события; фильтры: `date_from`, `date_to`, `on_date`, `region`, `topic`, `source_type`, `search`, `event_id` | +| GET | `/api/map/filters` | Справочники для фильтров: `regions`, `topics`, `source_types`, `available_dates`, `date_bounds` | +| GET | `/api/objects` | Объекты (legacy, без фильтров события) | | POST | `/api/objects` | Создать объект вручную | +#### Инструменты карты (UI) + +- **Даты:** кнопки ❮❮/❮/❯/❯❯, календарь flatpickr, пресеты периода (1 неделя — 1 год / день) +- **Фильтры:** регион, тема, источник (из `/api/map/filters`) +- **Подложки:** Яндекс Карты / Спутник (ru_RU), OpenStreetMap, OpenTopoMap, ESRI Satellite +- **Инструменты:** линейка (PolylineMeasure), полноэкранный режим, координаты/города, поиск Nominatim +- **Точки:** маркеры из ingest + popup; ручные объекты — CRUD и drag +- **Обновление:** polling `/api/map/objects` каждые 30 с + ### ЦА Admin | Метод | Путь | Описание | diff --git a/centers/analytics/api/app/main.py b/centers/analytics/api/app/main.py index f031e21..3119f17 100644 --- a/centers/analytics/api/app/main.py +++ b/centers/analytics/api/app/main.py @@ -4,7 +4,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from .database import Base, engine, get_db -from .routers import admin, internal, objects, v1 +from .routers import admin, internal, map, objects, v1 from .seed import seed_objects, seed_test_consumer from .storage import ensure_upload_dir @@ -33,6 +33,7 @@ app.add_middleware( ) app.include_router(objects.router) +app.include_router(map.router) app.include_router(internal.router) app.include_router(admin.router) app.include_router(v1.router) diff --git a/centers/analytics/api/app/routers/map.py b/centers/analytics/api/app/routers/map.py new file mode 100644 index 0000000..7d7858b --- /dev/null +++ b/centers/analytics/api/app/routers/map.py @@ -0,0 +1,50 @@ +from datetime import datetime + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from ..database import get_db +from ..schemas import MapFiltersRead, MapObjectWithEvent +from ..services.map_query import ( + build_map_objects_query, + get_map_filters, + map_object_to_dict, + utc_day_end, + utc_day_start, +) + +router = APIRouter(prefix="/api/map", tags=["map"]) + + +@router.get("/objects", response_model=list[MapObjectWithEvent]) +def list_map_objects( + date_from: datetime | None = None, + date_to: datetime | None = None, + on_date: datetime | None = Query(default=None, description="Show events for a single day"), + region: str | None = None, + topic: str | None = None, + source_type: str | None = None, + search: str | None = None, + event_id: int | None = None, + db: Session = Depends(get_db), +): + if on_date is not None: + date_from = utc_day_start(on_date) + date_to = utc_day_end(on_date) + + query = build_map_objects_query( + db, + date_from=date_from, + date_to=date_to, + region=region, + topic=topic, + source_type=source_type, + search=search, + event_id=event_id, + ) + return [map_object_to_dict(obj) for obj in query.all()] + + +@router.get("/filters", response_model=MapFiltersRead) +def map_filters(db: Session = Depends(get_db)): + return get_map_filters(db) diff --git a/centers/analytics/api/app/schemas.py b/centers/analytics/api/app/schemas.py index da9d5ed..27468fd 100644 --- a/centers/analytics/api/app/schemas.py +++ b/centers/analytics/api/app/schemas.py @@ -28,6 +28,29 @@ class MapObjectRead(BaseModel): created_at: datetime +class MapObjectWithEvent(MapObjectRead): + event_date: datetime | None = None + locality: str | None = None + region: str | None = None + topic: str | None = None + source_type: str | None = None + source_url: str | None = None + title: str | None = None + + +class MapDateBounds(BaseModel): + min: str | None = None + max: str | None = None + + +class MapFiltersRead(BaseModel): + regions: list[str] + topics: list[str] + source_types: list[str] + available_dates: list[str] + date_bounds: MapDateBounds + + class MapObjectUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=255) description: str | None = None diff --git a/centers/analytics/api/app/seed.py b/centers/analytics/api/app/seed.py index 75ea34d..ce17ff1 100644 --- a/centers/analytics/api/app/seed.py +++ b/centers/analytics/api/app/seed.py @@ -1,55 +1,13 @@ import os -from datetime import datetime, timezone from sqlalchemy.orm import Session -from .models import Consumer, ConsumerFilter, MapObject +from .models import Consumer, ConsumerFilter from .services.filtering import hash_api_key -SEED_OBJECTS = [ - { - "name": "Красная площадь", - "description": "Главная площадь Москвы, исторический центр города.", - "type": "marker", - "latitude": 55.7539, - "longitude": 37.6208, - "created_at": datetime(2018, 5, 9, 12, 0, tzinfo=timezone.utc), - }, - { - "name": "ВДНХ", - "description": "Выставка достижений народного хозяйства — крупный выставочный комплекс.", - "type": "zone", - "latitude": 55.8298, - "longitude": 37.6361, - "created_at": datetime(2020, 8, 15, 10, 30, tzinfo=timezone.utc), - }, - { - "name": "МГУ", - "description": "Московский государственный университет имени М.В. Ломоносова.", - "type": "point", - "latitude": 55.7033, - "longitude": 37.5307, - "created_at": datetime(2022, 2, 12, 14, 0, tzinfo=timezone.utc), - }, - { - "name": "Парк Горького", - "description": "Центральный парк культуры и отдыха имени М. Горького.", - "type": "other", - "latitude": 55.7312, - "longitude": 37.6013, - "created_at": datetime(2024, 6, 1, 9, 0, tzinfo=timezone.utc), - }, -] - def seed_objects(db: Session) -> None: - if db.query(MapObject).count() > 0: - return - - for item in SEED_OBJECTS: - db.add(MapObject(**item)) - - db.commit() + """No demo map objects — map shows ingested events only.""" def seed_test_consumer(db: Session) -> None: diff --git a/centers/analytics/api/app/services/map_query.py b/centers/analytics/api/app/services/map_query.py new file mode 100644 index 0000000..c7282ed --- /dev/null +++ b/centers/analytics/api/app/services/map_query.py @@ -0,0 +1,168 @@ +from datetime import date, datetime, timezone + +from sqlalchemy import func, or_ +from sqlalchemy.orm import Query, Session, joinedload + +from ..models import Event, MapObject + + +def _effective_date_column(): + return func.coalesce(Event.event_date, Event.ingested_at, MapObject.created_at) + + +def build_map_objects_query( + db: Session, + *, + date_from: datetime | None = None, + date_to: datetime | None = None, + region: str | None = None, + topic: str | None = None, + source_type: str | None = None, + search: str | None = None, + event_id: int | None = None, +) -> Query: + query = ( + db.query(MapObject) + .outerjoin(Event, MapObject.event_id == Event.id) + .options(joinedload(MapObject.event)) + ) + + if event_id is not None: + query = query.filter(MapObject.event_id == event_id) + + effective_date = _effective_date_column() + + if date_from is not None: + query = query.filter(effective_date >= date_from) + if date_to is not None: + query = query.filter(effective_date <= date_to) + + if region: + query = query.filter(Event.region == region) + if topic: + query = query.filter(Event.topic == topic) + if source_type: + query = query.filter(Event.source_type == source_type) + + if search: + pattern = f"%{search}%" + query = query.filter( + or_( + MapObject.name.ilike(pattern), + MapObject.description.ilike(pattern), + Event.title.ilike(pattern), + Event.locality.ilike(pattern), + Event.description.ilike(pattern), + ) + ) + + return query.order_by(effective_date.desc(), MapObject.id.desc()) + + +def map_object_to_dict(obj: MapObject) -> dict: + event = obj.event + return { + "id": obj.id, + "name": obj.name, + "description": obj.description, + "type": obj.type, + "latitude": obj.latitude, + "longitude": obj.longitude, + "event_id": obj.event_id, + "created_at": obj.created_at, + "event_date": event.event_date if event else obj.created_at, + "locality": event.locality if event else None, + "region": event.region if event else None, + "topic": event.topic if event else None, + "source_type": event.source_type if event else None, + "source_url": event.source_url if event else None, + "title": event.title if event else obj.name, + } + + +def _date_to_iso(value: date | datetime | None) -> str | None: + if value is None: + return None + if isinstance(value, datetime): + return value.date().isoformat() + return value.isoformat() + + +def get_map_filters(db: Session) -> dict: + effective_date = _effective_date_column() + + date_bounds = ( + db.query( + func.min(effective_date).label("min_date"), + func.max(effective_date).label("max_date"), + ) + .select_from(MapObject) + .outerjoin(Event, MapObject.event_id == Event.id) + .first() + ) + + regions = [ + row[0] + for row in db.query(Event.region) + .filter(Event.region.isnot(None), Event.region != "") + .distinct() + .order_by(Event.region) + .all() + ] + + topics = [ + row[0] + for row in db.query(Event.topic) + .filter(Event.topic.isnot(None), Event.topic != "") + .distinct() + .order_by(Event.topic) + .all() + ] + + source_types = [ + row[0] + for row in db.query(Event.source_type) + .filter(Event.source_type.isnot(None), Event.source_type != "") + .distinct() + .order_by(Event.source_type) + .all() + ] + + available_dates = [ + _date_to_iso(row[0]) + for row in ( + db.query(func.date(effective_date)) + .select_from(MapObject) + .outerjoin(Event, MapObject.event_id == Event.id) + .filter(effective_date.isnot(None)) + .distinct() + .order_by(func.date(effective_date)) + .all() + ) + if row[0] is not None + ] + + min_date = date_bounds.min_date if date_bounds else None + max_date = date_bounds.max_date if date_bounds else None + + return { + "regions": regions, + "topics": topics, + "source_types": source_types, + "available_dates": available_dates, + "date_bounds": { + "min": min_date.isoformat() if min_date else None, + "max": max_date.isoformat() if max_date else None, + }, + } + + +def utc_day_start(dt: datetime) -> datetime: + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + + +def utc_day_end(dt: datetime) -> datetime: + start = utc_day_start(dt) + return start.replace(hour=23, minute=59, second=59, microsecond=999999) diff --git a/centers/analytics/frontend/index.html b/centers/analytics/frontend/index.html index 42b6d3c..df07cba 100644 --- a/centers/analytics/frontend/index.html +++ b/centers/analytics/frontend/index.html @@ -10,9 +10,12 @@ integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" /> + +
+