feat: import Trading Economics HTML calendar — 993 events with forecasts
Adds a one-click upload+parse flow for the TE calendar page HTML. The TE page (tradingeconomics.com/calendar) contains ~1000 events with actuals, previous, and analyst consensus (forecast) values. - Add backend/services/te_html_parser.py: - parse_html(html): extracts events from <tr data-event> rows - Maps 14 countries to major currencies (all Eurozone → EUR) - Impact inferred from data-category (high/medium/low) - Times converted from Europe/Zurich (CET/CEST) → UTC via zoneinfo - Actual=td[5], Forecast=td[7] (analyst consensus), Previous=td[6] - Add POST /api/eco/te-html-upload (saves file to /tmp) - Add POST /api/eco/te-html-import + GET /status (background parse) - Add "Import TE HTML" upload button in CalendarPage ImportPanel Tested locally: 993 events parsed, 854 with forecast, 861 with actual, date range 2025-03-31 → 2026-06-17, timezone conversion verified. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -293,11 +293,27 @@ def upcoming_events() -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
# ── Forex Factory calendar ────────────────────────────────────────────────────
|
# ── Forex Factory calendar ────────────────────────────────────────────────────
|
||||||
|
|
||||||
_ff_import_status: Dict[str, Any] = {"running": False, "last_result": None}
|
_ff_import_status: Dict[str, Any] = {"running": False, "last_result": None}
|
||||||
_ff_sync_status: Dict[str, Any] = {"running": False, "last_result": None}
|
_ff_sync_status: Dict[str, Any] = {"running": False, "last_result": None}
|
||||||
_ff_scrape_status: Dict[str, Any] = {"running": False, "last_result": None}
|
_ff_scrape_status: Dict[str, Any] = {"running": False, "last_result": None}
|
||||||
_te_sync_status: Dict[str, Any] = {"running": False, "last_result": None}
|
_te_sync_status: Dict[str, Any] = {"running": False, "last_result": None}
|
||||||
_fxs_sync_status: Dict[str, Any] = {"running": False, "last_result": None}
|
_fxs_sync_status: Dict[str, Any] = {"running": False, "last_result": None}
|
||||||
|
_te_html_status: Dict[str, Any] = {"running": False, "last_result": None}
|
||||||
|
|
||||||
|
_TE_HTML_CANDIDATES = [
|
||||||
|
"/app/calendar_data.txt",
|
||||||
|
"/tmp/calendar_data.txt",
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "..", "calendar_data.txt"),
|
||||||
|
os.path.join(os.path.dirname(__file__), "..", "calendar_data.txt"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_te_html() -> str | None:
|
||||||
|
for p in _TE_HTML_CANDIDATES:
|
||||||
|
full = os.path.abspath(p)
|
||||||
|
if os.path.exists(full):
|
||||||
|
return full
|
||||||
|
return None
|
||||||
|
|
||||||
# CSV search order: Docker image (/app), uploaded to /tmp, local dev paths
|
# CSV search order: Docker image (/app), uploaded to /tmp, local dev paths
|
||||||
_FF_CSV_CANDIDATES = [
|
_FF_CSV_CANDIDATES = [
|
||||||
@@ -470,6 +486,57 @@ def fmp_sync_status_ep() -> Dict[str, Any]:
|
|||||||
return _te_sync_status
|
return _te_sync_status
|
||||||
|
|
||||||
|
|
||||||
|
# ── Trading Economics HTML import ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/te-html-upload")
|
||||||
|
async def te_html_upload(file: UploadFile = File(...)) -> Dict[str, Any]:
|
||||||
|
"""Upload calendar_data.txt (TE HTML page) from the browser."""
|
||||||
|
dest = "/tmp/calendar_data.txt"
|
||||||
|
try:
|
||||||
|
content = await file.read()
|
||||||
|
with open(dest, "wb") as f:
|
||||||
|
f.write(content)
|
||||||
|
size_mb = round(len(content) / 1_048_576, 1)
|
||||||
|
print(f"[TE HTML upload] Saved {size_mb} MB to {dest}", flush=True)
|
||||||
|
return {"status": "uploaded", "path": dest, "size_mb": size_mb}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Upload failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_te_html_import():
|
||||||
|
global _te_html_status
|
||||||
|
_te_html_status["running"] = True
|
||||||
|
try:
|
||||||
|
from services.te_html_parser import import_html_file
|
||||||
|
path = _find_te_html()
|
||||||
|
if not path:
|
||||||
|
_te_html_status["last_result"] = {"error": "calendar_data.txt not found — upload first"}
|
||||||
|
return
|
||||||
|
result = import_html_file(path)
|
||||||
|
_te_html_status["last_result"] = result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[eco/te-html-import] Failed: {e}")
|
||||||
|
_te_html_status["last_result"] = {"error": str(e)}
|
||||||
|
finally:
|
||||||
|
_te_html_status["running"] = False
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/te-html-import")
|
||||||
|
def te_html_import(background_tasks: BackgroundTasks) -> Dict[str, Any]:
|
||||||
|
"""Parse calendar_data.txt (TE HTML) and upsert into ff_calendar."""
|
||||||
|
if _te_html_status["running"]:
|
||||||
|
raise HTTPException(409, "TE HTML import already running")
|
||||||
|
if not _find_te_html():
|
||||||
|
raise HTTPException(404, "calendar_data.txt not found — upload via POST /api/eco/te-html-upload")
|
||||||
|
background_tasks.add_task(_run_te_html_import)
|
||||||
|
return {"status": "started"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/te-html-import/status")
|
||||||
|
def te_html_import_status() -> Dict[str, Any]:
|
||||||
|
return _te_html_status
|
||||||
|
|
||||||
|
|
||||||
# ── FXStreet calendar (no API key required) ───────────────────────────────────
|
# ── FXStreet calendar (no API key required) ───────────────────────────────────
|
||||||
|
|
||||||
def _run_fxs_sync(weeks_ahead: int):
|
def _run_fxs_sync(weeks_ahead: int):
|
||||||
|
|||||||
243
backend/services/te_html_parser.py
Normal file
243
backend/services/te_html_parser.py
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
"""
|
||||||
|
Parser for Trading Economics economic calendar HTML page.
|
||||||
|
Converts scraped TE HTML → ff_calendar rows (no API key required).
|
||||||
|
|
||||||
|
Column map in TE HTML table rows:
|
||||||
|
td[0] = time (AM/PM, Europe/Zurich timezone) + date as CSS class
|
||||||
|
td[3] = country code (JP, US, DE, EA, ...)
|
||||||
|
td[4] = event name <a> + period <span class="calendar-reference">
|
||||||
|
td[5] = actual value
|
||||||
|
td[6] = previous (+ revised if any)
|
||||||
|
td[7] = consensus (analyst forecast — this is what we store as forecast_value)
|
||||||
|
td[8] = TE model forecast (ignored)
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Countries → major currency (Eurozone countries all → EUR)
|
||||||
|
_COUNTRY_TO_CCY: Dict[str, str] = {
|
||||||
|
"united states": "USD",
|
||||||
|
"euro area": "EUR",
|
||||||
|
"germany": "EUR",
|
||||||
|
"france": "EUR",
|
||||||
|
"italy": "EUR",
|
||||||
|
"spain": "EUR",
|
||||||
|
"netherlands": "EUR",
|
||||||
|
"austria": "EUR",
|
||||||
|
"portugal": "EUR",
|
||||||
|
"belgium": "EUR",
|
||||||
|
"greece": "EUR",
|
||||||
|
"finland": "EUR",
|
||||||
|
"ireland": "EUR",
|
||||||
|
"slovakia": "EUR",
|
||||||
|
"united kingdom": "GBP",
|
||||||
|
"japan": "JPY",
|
||||||
|
"china": "CNY",
|
||||||
|
"australia": "AUD",
|
||||||
|
"canada": "CAD",
|
||||||
|
"new zealand": "NZD",
|
||||||
|
"switzerland": "CHF",
|
||||||
|
}
|
||||||
|
|
||||||
|
# High-impact categories
|
||||||
|
_HIGH_CATS = {
|
||||||
|
"interest rate", "non farm payrolls", "inflation rate",
|
||||||
|
"gdp growth rate", "gdp annual growth rate", "unemployment rate",
|
||||||
|
}
|
||||||
|
# Medium-impact categories
|
||||||
|
_MEDIUM_CATS = {
|
||||||
|
"manufacturing pmi", "non manufacturing pmi", "balance of trade",
|
||||||
|
"retail sales mom", "consumer confidence", "business confidence",
|
||||||
|
"core inflation rate", "core inflation rate mom",
|
||||||
|
"producer price inflation mom", "monthly gdp mom",
|
||||||
|
"job offers", "zew economic sentiment index",
|
||||||
|
}
|
||||||
|
|
||||||
|
_TZ_ZURICH = None
|
||||||
|
|
||||||
|
def _get_tz():
|
||||||
|
global _TZ_ZURICH
|
||||||
|
if _TZ_ZURICH is None:
|
||||||
|
try:
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
_TZ_ZURICH = ZoneInfo("Europe/Zurich")
|
||||||
|
except Exception:
|
||||||
|
import pytz
|
||||||
|
_TZ_ZURICH = pytz.timezone("Europe/Zurich")
|
||||||
|
return _TZ_ZURICH
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_time_utc(date_str: str, time_str: str) -> Tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Convert 'YYYY-MM-DD' + '02:30 PM' (Europe/Zurich) → ('YYYY-MM-DD', 'HH:MM') UTC.
|
||||||
|
Falls back to storing the raw time if parsing fails.
|
||||||
|
"""
|
||||||
|
if not time_str or not date_str:
|
||||||
|
return date_str, "00:00"
|
||||||
|
try:
|
||||||
|
tz = _get_tz()
|
||||||
|
dt_local = datetime.strptime(f"{date_str} {time_str}", "%Y-%m-%d %I:%M %p")
|
||||||
|
dt_local = dt_local.replace(tzinfo=tz)
|
||||||
|
dt_utc = dt_local.astimezone(timezone.utc)
|
||||||
|
return dt_utc.strftime("%Y-%m-%d"), dt_utc.strftime("%H:%M")
|
||||||
|
except Exception:
|
||||||
|
return date_str, "00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_value(v: Optional[str]) -> Optional[str]:
|
||||||
|
"""Normalize numeric string: strip whitespace/units, return None if empty."""
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
v = v.strip()
|
||||||
|
if not v or v in ("-", "—", "N/A", ""):
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _infer_impact(category: str) -> str:
|
||||||
|
cat = (category or "").lower().strip()
|
||||||
|
if cat in _HIGH_CATS:
|
||||||
|
return "high"
|
||||||
|
if cat in _MEDIUM_CATS:
|
||||||
|
return "medium"
|
||||||
|
return "low"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_html(html: str) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Parse TE calendar HTML, return list of event dicts ready to upsert.
|
||||||
|
Each dict: event_date, event_time, currency, impact, event_name,
|
||||||
|
actual, forecast, previous
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError("beautifulsoup4 not installed — run: pip install beautifulsoup4")
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
rows = soup.find_all("tr", attrs={"data-event": True})
|
||||||
|
logger.info(f"[TE HTML parser] {len(rows)} rows found")
|
||||||
|
|
||||||
|
events = []
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
country = (row.get("data-country") or "").lower().strip()
|
||||||
|
ccy = _COUNTRY_TO_CCY.get(country)
|
||||||
|
if not ccy:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
category = (row.get("data-category") or "").lower().strip()
|
||||||
|
impact = _infer_impact(category)
|
||||||
|
|
||||||
|
tds = row.find_all("td")
|
||||||
|
if len(tds) < 8:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Date from td[0] CSS class (e.g. "2025-04-01")
|
||||||
|
td0_classes = tds[0].get("class") or []
|
||||||
|
date_str = ""
|
||||||
|
for c in td0_classes:
|
||||||
|
if re.match(r"\d{4}-\d{2}-\d{2}", c):
|
||||||
|
date_str = c
|
||||||
|
break
|
||||||
|
if not date_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Time from td[0] text
|
||||||
|
time_raw = tds[0].get_text(separator=" ").strip()
|
||||||
|
time_match = re.search(r"\d{1,2}:\d{2}\s*[AP]M", time_raw, re.I)
|
||||||
|
time_str = time_match.group(0).strip() if time_match else ""
|
||||||
|
|
||||||
|
ev_date, ev_time = _parse_time_utc(date_str, time_str)
|
||||||
|
|
||||||
|
# Event name from td[4] <a class="calendar-event">
|
||||||
|
ev_link = tds[4].find("a", class_="calendar-event")
|
||||||
|
if ev_link:
|
||||||
|
event_name = ev_link.get_text(strip=True)
|
||||||
|
else:
|
||||||
|
event_name = tds[4].get_text(strip=True)
|
||||||
|
# Strip appended period reference (MAR, Q1, APR, etc.)
|
||||||
|
event_name = re.sub(r"\s+(JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC|Q[1-4])$", "", event_name, flags=re.I)
|
||||||
|
event_name = event_name.strip()
|
||||||
|
if not event_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Values
|
||||||
|
actual = _clean_value(tds[5].get_text(strip=True))
|
||||||
|
previous_span = tds[6].find("span", id="previous")
|
||||||
|
if previous_span:
|
||||||
|
previous = _clean_value(previous_span.get_text(strip=True))
|
||||||
|
else:
|
||||||
|
previous = _clean_value(tds[6].get_text(strip=True))
|
||||||
|
consensus = _clean_value(tds[7].get_text(strip=True))
|
||||||
|
|
||||||
|
events.append({
|
||||||
|
"event_date": ev_date,
|
||||||
|
"event_time": ev_time,
|
||||||
|
"currency": ccy,
|
||||||
|
"impact": impact,
|
||||||
|
"event_name": event_name,
|
||||||
|
"actual": actual,
|
||||||
|
"forecast": consensus, # consensus = analyst forecast
|
||||||
|
"previous": previous,
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"[TE HTML parser] {len(events)} valid events, {skipped} skipped")
|
||||||
|
return events
|
||||||
|
|
||||||
|
|
||||||
|
def import_html_file(file_path: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Parse TE calendar HTML file and upsert all events into ff_calendar.
|
||||||
|
Returns summary dict.
|
||||||
|
"""
|
||||||
|
from services.database import get_conn
|
||||||
|
from services.ff_calendar import _upsert_batch, FF_TO_FRED
|
||||||
|
import os
|
||||||
|
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
return {"error": f"File not found: {file_path}"}
|
||||||
|
|
||||||
|
print(f"[TE HTML] Reading {file_path} …", flush=True)
|
||||||
|
with open(file_path, encoding="utf-8", errors="replace") as f:
|
||||||
|
html = f.read()
|
||||||
|
print(f"[TE HTML] File size: {len(html):,} chars", flush=True)
|
||||||
|
|
||||||
|
events = parse_html(html)
|
||||||
|
if not events:
|
||||||
|
return {"error": "No events parsed — check file format"}
|
||||||
|
|
||||||
|
batch = []
|
||||||
|
for ev in events:
|
||||||
|
series_id = FF_TO_FRED.get(ev["event_name"])
|
||||||
|
batch.append((
|
||||||
|
ev["event_date"], ev["event_time"], ev["currency"],
|
||||||
|
ev["impact"], ev["event_name"],
|
||||||
|
ev["actual"], ev["forecast"], ev["previous"],
|
||||||
|
series_id, None, "te_html",
|
||||||
|
))
|
||||||
|
|
||||||
|
CHUNK = 500
|
||||||
|
conn = get_conn()
|
||||||
|
for i in range(0, len(batch), CHUNK):
|
||||||
|
_upsert_batch(conn, batch[i:i + CHUNK])
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
by_ccy: Dict[str, int] = {}
|
||||||
|
for ev in events:
|
||||||
|
by_ccy[ev["currency"]] = by_ccy.get(ev["currency"], 0) + 1
|
||||||
|
|
||||||
|
print(f"[TE HTML] {len(batch)} events upserted", flush=True)
|
||||||
|
return {
|
||||||
|
"total": len(batch),
|
||||||
|
"by_currency": by_ccy,
|
||||||
|
"date_from": min(ev["event_date"] for ev in events),
|
||||||
|
"date_to": max(ev["event_date"] for ev in events),
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback, useRef, KeyboardEvent } from 'react'
|
import { useState, useEffect, useCallback, useRef, KeyboardEvent, useId } from 'react'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
import { RefreshCw, ChevronDown, AlertTriangle, Check, Key } from 'lucide-react'
|
import { RefreshCw, ChevronDown, AlertTriangle, Check, Key, Upload } from 'lucide-react'
|
||||||
|
|
||||||
const API = ''
|
const API = ''
|
||||||
|
|
||||||
@@ -190,6 +190,38 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
|
|||||||
} catch { setFxsSyncing(false); setFxsMsg('FXStreet error') }
|
} catch { setFxsSyncing(false); setFxsMsg('FXStreet error') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TE HTML upload + import
|
||||||
|
const [teHtmlMsg, setTeHtmlMsg] = useState<string | null>(null)
|
||||||
|
const [teHtmlBusy, setTeHtmlBusy] = useState(false)
|
||||||
|
const teHtmlPollRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const fileInputId = useId()
|
||||||
|
|
||||||
|
const uploadAndImportTeHtml = async (file: File) => {
|
||||||
|
setTeHtmlBusy(true)
|
||||||
|
setTeHtmlMsg('uploading…')
|
||||||
|
try {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
const up = await fetch(`${API}/api/eco/te-html-upload`, { method: 'POST', body: form }).then(x => x.json())
|
||||||
|
if (up.error) { setTeHtmlMsg(`Upload error: ${up.error}`); setTeHtmlBusy(false); return }
|
||||||
|
setTeHtmlMsg(`uploaded (${up.size_mb} MB) — importing…`)
|
||||||
|
await fetch(`${API}/api/eco/te-html-import`, { method: 'POST' })
|
||||||
|
teHtmlPollRef.current = setInterval(async () => {
|
||||||
|
const s = await fetch(`${API}/api/eco/te-html-import/status`).then(x => x.json())
|
||||||
|
if (!s.running) {
|
||||||
|
clearInterval(teHtmlPollRef.current!)
|
||||||
|
setTeHtmlBusy(false)
|
||||||
|
const r = s.last_result || {}
|
||||||
|
if (r.error) { setTeHtmlMsg(`Error: ${r.error}`); return }
|
||||||
|
setTeHtmlMsg(`✓ TE HTML: ${r.total} events (${r.date_from} → ${r.date_to})`)
|
||||||
|
onImported()
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
} catch { setTeHtmlBusy(false); setTeHtmlMsg('error') }
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => () => { if (teHtmlPollRef.current) clearInterval(teHtmlPollRef.current) }, [])
|
||||||
|
|
||||||
const hasData = stats.total > 0
|
const hasData = stats.total > 0
|
||||||
const res = importStatus.last_result
|
const res = importStatus.last_result
|
||||||
|
|
||||||
@@ -228,6 +260,29 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
|
|||||||
Sync Upcoming (FXStreet)
|
Sync Upcoming (FXStreet)
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* TE HTML import — paste calendar_data.txt from tradingeconomics.com */}
|
||||||
|
<label
|
||||||
|
htmlFor={fileInputId}
|
||||||
|
className={clsx(
|
||||||
|
'px-3 py-1.5 rounded flex items-center gap-1.5 text-white cursor-pointer',
|
||||||
|
teHtmlBusy
|
||||||
|
? 'bg-amber-800 opacity-50 cursor-not-allowed'
|
||||||
|
: 'bg-amber-700 hover:bg-amber-600'
|
||||||
|
)}
|
||||||
|
title="Import Trading Economics HTML page (calendar_data.txt) — 1000 events with forecasts"
|
||||||
|
>
|
||||||
|
<Upload size={13} />
|
||||||
|
Import TE HTML
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={fileInputId}
|
||||||
|
type="file"
|
||||||
|
accept=".txt,.html"
|
||||||
|
className="hidden"
|
||||||
|
disabled={teHtmlBusy}
|
||||||
|
onChange={e => { const f = e.target.files?.[0]; if (f) uploadAndImportTeHtml(f); e.target.value = '' }}
|
||||||
|
/>
|
||||||
|
|
||||||
{res && !importStatus.running && (
|
{res && !importStatus.running && (
|
||||||
res.error
|
res.error
|
||||||
? <span className="text-red-400 text-xs">Import error: {res.error}</span>
|
? <span className="text-red-400 text-xs">Import error: {res.error}</span>
|
||||||
@@ -239,6 +294,11 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
|
|||||||
{fxsMsg}
|
{fxsMsg}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{teHtmlMsg && (
|
||||||
|
<span className={clsx('text-xs', teHtmlMsg.startsWith('✓') ? 'text-emerald-400' : teHtmlMsg.startsWith('Error') ? 'text-red-400' : 'text-yellow-400')}>
|
||||||
|
{teHtmlMsg}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user