Add CP→CA→PI platform foundation on MapMil monorepo.

Unify parsing workers, analytics API with PostgreSQL, map UI, and PI distribution into centers/ with Docker Compose.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 11:43:27 +03:00
co-authored by Cursor
commit 9dfe713668
88 changed files with 6587 additions and 0 deletions
@@ -0,0 +1,109 @@
import hashlib
import secrets
from datetime import datetime
from sqlalchemy.orm import Session
from ..models import Consumer, ConsumerFilter, Event, MapObject
def hash_api_key(api_key: str) -> str:
return hashlib.sha256(api_key.encode()).hexdigest()
def generate_api_key() -> str:
return secrets.token_urlsafe(32)
def create_consumer(
db: Session,
name: str,
regions: list[str] | None = None,
topics: list[str] | None = None,
date_from: datetime | None = None,
) -> tuple[Consumer, str]:
api_key = generate_api_key()
consumer = Consumer(name=name, api_key_hash=hash_api_key(api_key), is_active=True)
db.add(consumer)
db.flush()
consumer_filter = ConsumerFilter(
consumer_id=consumer.id,
regions=regions,
topics=topics,
date_from=date_from,
)
db.add(consumer_filter)
db.commit()
db.refresh(consumer)
return consumer, api_key
def find_consumer_by_api_key(db: Session, api_key: str) -> Consumer | None:
key_hash = hash_api_key(api_key)
return (
db.query(Consumer)
.filter(Consumer.api_key_hash == key_hash, Consumer.is_active.is_(True))
.first()
)
def apply_consumer_filter(db: Session, consumer: Consumer) -> list[Event]:
query = db.query(Event).order_by(Event.ingested_at.desc())
consumer_filter = consumer.filter
if consumer_filter:
if consumer_filter.regions:
query = query.filter(Event.region.in_(consumer_filter.regions))
if consumer_filter.topics:
query = query.filter(Event.topic.in_(consumer_filter.topics))
if consumer_filter.date_from:
query = query.filter(Event.event_date >= consumer_filter.date_from)
return query.all()
def sync_event_to_map_object(db: Session, event: Event) -> MapObject | None:
if event.latitude is None or event.longitude is None:
return None
name = event.title or event.locality or f"Событие #{event.id}"
description = event.description or event.raw_text or ""
existing = (
db.query(MapObject)
.filter(MapObject.event_id == event.id)
.first()
)
if existing:
existing.name = name
existing.description = description
existing.latitude = event.latitude
existing.longitude = event.longitude
return existing
by_url = (
db.query(MapObject)
.join(Event, MapObject.event_id == Event.id)
.filter(Event.source_url == event.source_url)
.first()
)
if by_url:
by_url.event_id = event.id
by_url.name = name
by_url.description = description
by_url.latitude = event.latitude
by_url.longitude = event.longitude
return by_url
obj = MapObject(
name=name,
description=description,
type="marker",
latitude=event.latitude,
longitude=event.longitude,
event_id=event.id,
created_at=event.event_date or event.ingested_at,
)
db.add(obj)
return obj
@@ -0,0 +1,72 @@
from sqlalchemy.orm import Session
from ..models import Event, ParseJob
from ..schemas import IngestEventItem
from .filtering import sync_event_to_map_object
def ingest_events(
db: Session,
items: list[IngestEventItem],
job_id: int | None = None,
) -> tuple[int, int, int]:
ingested = 0
updated = 0
map_synced = 0
for item in items:
existing = (
db.query(Event)
.filter(Event.source_url == item.source_url)
.first()
)
if existing:
existing.source_type = item.source_type
existing.raw_text = item.raw_text
existing.title = item.title
existing.description = item.description
existing.locality = item.locality
existing.latitude = item.latitude
existing.longitude = item.longitude
existing.event_date = item.event_date
existing.region = item.region
existing.topic = item.topic
existing.tags = item.tags
existing.metadata_ = item.metadata
event = existing
updated += 1
else:
event = Event(
source_type=item.source_type,
source_url=item.source_url,
raw_text=item.raw_text,
title=item.title,
description=item.description,
locality=item.locality,
latitude=item.latitude,
longitude=item.longitude,
event_date=item.event_date,
region=item.region,
topic=item.topic,
tags=item.tags,
metadata_=item.metadata,
)
db.add(event)
ingested += 1
db.flush()
if sync_event_to_map_object(db, event):
map_synced += 1
if job_id is not None:
job = db.query(ParseJob).filter(ParseJob.id == job_id).first()
if job:
from datetime import datetime, timezone
job.status = "completed"
job.last_run_at = datetime.now(timezone.utc)
job.last_error = None
db.commit()
return ingested, updated, map_synced
@@ -0,0 +1,20 @@
import json
import os
import redis
REDIS_URL = os.getenv("REDIS_URL", "redis://redis:6379/0")
JOB_QUEUE_KEY = "cp:jobs"
def get_redis() -> redis.Redis:
return redis.from_url(REDIS_URL, decode_responses=True)
def enqueue_job(job_id: int, source_type: str, source_config: dict) -> None:
payload = {
"job_id": job_id,
"source_type": source_type,
"source_config": source_config,
}
get_redis().rpush(JOB_QUEUE_KEY, json.dumps(payload))