Compare commits
No commits in common. "main" and "428697cd24dc810ecd858c63f124c2adbedcfd15" have entirely different histories.
main
...
428697cd24
51
.drone.yml
51
.drone.yml
@ -1,51 +0,0 @@
|
|||||||
kind: pipeline
|
|
||||||
type: docker
|
|
||||||
name: deploy-polisplexity
|
|
||||||
|
|
||||||
clone:
|
|
||||||
depth: 1
|
|
||||||
submodules: false
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: install dependencies and run Django checks
|
|
||||||
image: python:3.10
|
|
||||||
environment:
|
|
||||||
DATABASE_URL:
|
|
||||||
from_secret: DATABASE_URL
|
|
||||||
SECRET_KEY:
|
|
||||||
from_secret: SECRET_KEY
|
|
||||||
DEBUG: "False"
|
|
||||||
NEO4J_URI:
|
|
||||||
from_secret: NEO4J_URI
|
|
||||||
NEO4J_USERNAME:
|
|
||||||
from_secret: NEO4J_USERNAME
|
|
||||||
NEO4J_PASSWORD:
|
|
||||||
from_secret: NEO4J_PASSWORD
|
|
||||||
OPENAI_API_KEY:
|
|
||||||
from_secret: OPENAI_API_KEY
|
|
||||||
PAGE_ACCESS_TOKEN:
|
|
||||||
from_secret: PAGE_ACCESS_TOKEN
|
|
||||||
VERIFY_TOKEN:
|
|
||||||
from_secret: VERIFY_TOKEN
|
|
||||||
commands:
|
|
||||||
- python -m pip install --upgrade pip
|
|
||||||
- pip install -r requirements.txt
|
|
||||||
- python manage.py check --deploy --fail-level ERROR
|
|
||||||
- echo "✅ Django deploy checks passed"
|
|
||||||
|
|
||||||
- name: deploy to production server
|
|
||||||
image: appleboy/drone-ssh
|
|
||||||
settings:
|
|
||||||
host: 191.101.233.39
|
|
||||||
username: drone
|
|
||||||
port: 22
|
|
||||||
key:
|
|
||||||
from_secret: PROD_SSH_KEY_B
|
|
||||||
script:
|
|
||||||
- cd /home/polisplexity/polisplexity
|
|
||||||
- git pull origin main
|
|
||||||
- docker compose down
|
|
||||||
- docker compose up -d --build
|
|
||||||
- docker compose exec web python manage.py migrate --noinput
|
|
||||||
- docker compose exec web python manage.py collectstatic --noinput
|
|
||||||
- echo "🚀 Production deployment complete"
|
|
30
.env
Normal file
30
.env
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# Database Configuration
|
||||||
|
POSTGRES_DB=polisplexity
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=mysecretpassword
|
||||||
|
|
||||||
|
# Django Environment Variables
|
||||||
|
DEBUG=True
|
||||||
|
SECRET_KEY=django-insecure-%*=%u3gv38cv*2iwy)m^)flo3p4w7ol*n5*-7lr*i4^u+(v=#q
|
||||||
|
ALLOWED_HOSTS=127.0.0.1,localhost,app.polisplexity.tech,191.101.233.39,srv566867.hstgr.cloud
|
||||||
|
|
||||||
|
# Database URL
|
||||||
|
DATABASE_URL=postgres://postgres:mysecretpassword@db:5432/polisplexity
|
||||||
|
|
||||||
|
# Static and Media Files
|
||||||
|
STATIC_URL=/static/
|
||||||
|
STATIC_ROOT=/app/static
|
||||||
|
MEDIA_URL=/media/
|
||||||
|
MEDIA_ROOT=/app/media
|
||||||
|
|
||||||
|
# Neo4j Database Configuration
|
||||||
|
NEO4J_URI=neo4j+s://74d433fb.databases.neo4j.io
|
||||||
|
NEO4J_USERNAME=neo4j
|
||||||
|
NEO4J_PASSWORD=4Y5-ppefHkgEiLr-l0qzbf8wNJw0zkOmRmk7cSkSrTg
|
||||||
|
|
||||||
|
# OpenAI API Key
|
||||||
|
OPENAI_API_KEY=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
|
||||||
|
|
||||||
|
# Facebook API Tokens
|
||||||
|
PAGE_ACCESS_TOKEN=EAAIq9z4rVPIBOxJxRnmbjIUsqJ9ZB5hZC9MF4qN64VNpxUCYguMCqUNKSsAjQZAcD9hlhZCv2RcV4GOIFC3Ni6VGoMp3rTFlLwtXxFIklj0FqZAVqSh7i0QT3Kwt9SCx9V9iioSsyFhUQrnpTXZCoDPJy0i2kMkzkY5ZA58hieSeQZBZARz3ZC7XeZCi5uSZBXYCeatGuAZDZD
|
||||||
|
VERIFY_TOKEN=YzQ2VWcODWO922j30HZ9AV113kAisTjcacc3wzURPvFjHCOWjcYP39ThgCWlPQ1w
|
39
.gitignore
vendored
39
.gitignore
vendored
@ -1,39 +0,0 @@
|
|||||||
# Python
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*.sqlite3
|
|
||||||
|
|
||||||
# Environment
|
|
||||||
.env
|
|
||||||
*.env
|
|
||||||
*.env.*
|
|
||||||
|
|
||||||
# Django
|
|
||||||
db.sqlite3
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# VSCode
|
|
||||||
.vscode/
|
|
||||||
*.code-workspace
|
|
||||||
|
|
||||||
# Media/static/runtime
|
|
||||||
/media
|
|
||||||
/static
|
|
||||||
/cache
|
|
||||||
*.dump.sql
|
|
||||||
*.load
|
|
||||||
*.sql
|
|
||||||
|
|
||||||
# System
|
|
||||||
.DS_Store
|
|
||||||
pxy_city_digital_twins/__backup__/
|
|
||||||
Dockerfile.dev
|
|
||||||
docker-compose.override.yml
|
|
||||||
docker-compose.override.yml
|
|
||||||
pxy_meta_pages.zip
|
|
||||||
pxy_openai.zip
|
|
||||||
pxy_bots.zip
|
|
||||||
pxy_bots (2).zip
|
|
||||||
pxy_bots.zip
|
|
||||||
pxy_bots.zip
|
|
||||||
pxy_bots.zip
|
|
39
Dockerfile
39
Dockerfile
@ -1,34 +1,17 @@
|
|||||||
# Dockerfile (prod)
|
# Use official Python image
|
||||||
FROM python:3.10-slim
|
FROM python:3.10
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
||||||
PYTHONUNBUFFERED=1 \
|
|
||||||
PIP_NO_CACHE_DIR=1
|
|
||||||
|
|
||||||
# System deps needed by geopandas/shapely/pyproj, mysqlclient, etc.
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
build-essential \
|
|
||||||
curl \
|
|
||||||
libgeos-dev \
|
|
||||||
libspatialindex-dev \
|
|
||||||
libproj-dev proj-data proj-bin \
|
|
||||||
gdal-bin libgdal-dev \
|
|
||||||
python3-dev pkg-config \
|
|
||||||
default-libmysqlclient-dev \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install Python deps first (layer cache friendly)
|
# Copy application code
|
||||||
COPY requirements.txt .
|
|
||||||
RUN python -m pip install --upgrade pip \
|
|
||||||
&& pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy project
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose prod port (compose overrides CMD/port, but this documents intent)
|
# Install dependencies
|
||||||
EXPOSE 8002
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Default CMD (compose will override with your shell that migrates, collectstatic, and runs gunicorn:8002)
|
# Expose the application port
|
||||||
CMD ["gunicorn", "polisplexity.wsgi:application", "--bind", "0.0.0.0:8002", "--workers=4", "--timeout=180"]
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Run Gunicorn server
|
||||||
|
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "polisplexity.wsgi:application"]
|
||||||
|
1
cache/6c4edc84e061c770f138cce1d051028ec44885c3.json
vendored
Normal file
1
cache/6c4edc84e061c770f138cce1d051028ec44885c3.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cache/771b2511416e7ff4c83fc38f292274f35e855074.json
vendored
Normal file
1
cache/771b2511416e7ff4c83fc38f292274f35e855074.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cache/9e0a6000da8bf7662e1c0bca98b9f70539ecf034.json
vendored
Normal file
1
cache/9e0a6000da8bf7662e1c0bca98b9f70539ecf034.json
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"version": 0.6, "generator": "Overpass API 0.7.62.5 1bd436f1", "osm3s": {"timestamp_osm_base": "2025-05-12T23:01:58Z", "copyright": "The data included in this document is from www.openstreetmap.org. The data is made available under ODbL."}, "elements": []}
|
1
cache/b3344dd7b6b8381e28a1ecadb3139da9d2b34eeb.json
vendored
Normal file
1
cache/b3344dd7b6b8381e28a1ecadb3139da9d2b34eeb.json
vendored
Normal file
File diff suppressed because one or more lines are too long
1
cache/ce8e8fa8d1ee4e1b554c330158ac018978fdd4af.json
vendored
Normal file
1
cache/ce8e8fa8d1ee4e1b554c330158ac018978fdd4af.json
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
core/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
core/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/admin.cpython-310.pyc
Normal file
BIN
core/__pycache__/admin.cpython-310.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/apps.cpython-310.pyc
Normal file
BIN
core/__pycache__/apps.cpython-310.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/models.cpython-310.pyc
Normal file
BIN
core/__pycache__/models.cpython-310.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/urls.cpython-310.pyc
Normal file
BIN
core/__pycache__/urls.cpython-310.pyc
Normal file
Binary file not shown.
BIN
core/__pycache__/views.cpython-310.pyc
Normal file
BIN
core/__pycache__/views.cpython-310.pyc
Normal file
Binary file not shown.
BIN
core/migrations/__pycache__/0001_initial.cpython-310.pyc
Normal file
BIN
core/migrations/__pycache__/0001_initial.cpython-310.pyc
Normal file
Binary file not shown.
BIN
core/migrations/__pycache__/0002_usermenu.cpython-310.pyc
Normal file
BIN
core/migrations/__pycache__/0002_usermenu.cpython-310.pyc
Normal file
Binary file not shown.
BIN
core/migrations/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
core/migrations/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
@ -1,48 +1,52 @@
|
|||||||
{% extends 'pxy_dashboard/partials/base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}Polisplexity Portal{% endblock title %}
|
{% block title %}Polisplexity Portal{% endblock title %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<style>
|
||||||
|
.card-img-top {
|
||||||
|
height: 200px; /* Fixed height for consistency */
|
||||||
|
object-fit: cover; /* Ensures image covers the area nicely */
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
transform: scale(1.05); /* Slight zoom effect on hover for interactivity */
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock extra_css %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid">
|
<div class="container mt-4">
|
||||||
<div class="row justify-content-center">
|
<h2 class="text-center mb-4">Welcome to Polisplexity</h2>
|
||||||
<div class="col-12 text-center mb-4">
|
<p class="text-center mb-4">Select one of the options Bellow</p>
|
||||||
<h2 class="mt-3">Welcome to Polisplexity</h2>
|
|
||||||
<p class="text-muted">Select one of the available options</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% for category, items in grouped_menu_items.items %}
|
{% for category, items in grouped_menu_items.items %}
|
||||||
{% if not forloop.first %}
|
{% if not forloop.first %}
|
||||||
<div class="row mt-4">
|
<hr> <!-- Horizontal separator for every new category except the first one -->
|
||||||
<div class="col-12">
|
|
||||||
<hr class="border-secondary">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="row mb-4">
|
|
||||||
<div class="col-12">
|
|
||||||
<h4 class="text-primary">{{ category.name }}</h4>
|
|
||||||
<p class="text-muted">{{ category.description }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
<div class="col-12 col-md-3 mb-4">
|
||||||
|
<h3> {{ category.name }}</h3>
|
||||||
|
<small> {{ category.description }}</small>
|
||||||
|
</div>
|
||||||
{% for menu_item in items %}
|
{% for menu_item in items %}
|
||||||
<div class="col-sm-6 col-lg-3">
|
<div class="col-12 col-md-3 mb-4">
|
||||||
<div class="card d-block">
|
<div class="card h-100">
|
||||||
{% if menu_item.image %}
|
{% if menu_item.image %}
|
||||||
<img class="card-img-top" src="{{ menu_item.image.url }}" alt="{{ menu_item.title }}">
|
<!-- Display Image if available -->
|
||||||
|
<img src="{{ menu_item.image.url }}" class="card-img-top" alt="{{ menu_item.title }}">
|
||||||
{% elif menu_item.icon %}
|
{% elif menu_item.icon %}
|
||||||
<div class="card-header text-center bg-light-subtle">
|
<!-- Display Icon if image is not available but icon is -->
|
||||||
<span class="material-symbols-rounded display-4 text-primary">{{ menu_item.icon }}</span>
|
<div class="card-header text-center">
|
||||||
|
<span class="material-symbols-rounded md-48">{{ menu_item.icon }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h5 class="card-title">{{ menu_item.title }}</h5>
|
<h5 class="card-title">{{ menu_item.title }}</h5>
|
||||||
<p class="card-text">{{ menu_item.description }}</p>
|
<p class="card-text">{{ menu_item.description }}</p>
|
||||||
<a href="{{ menu_item.url }}" class="btn btn-sm btn-primary stretched-link">{{ menu_item.url_text }}</a>
|
</div>
|
||||||
|
<div class="card-footer bg-white">
|
||||||
|
<a href="{{ menu_item.url }}" class="btn btn-primary">{{ menu_item.url_text }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -51,3 +55,12 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
$(function () {
|
||||||
|
$('[data-bs-toggle="tooltip"]').tooltip() // Initialize Bootstrap tooltips
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
{% endblock extra_js %}
|
||||||
|
@ -1,28 +0,0 @@
|
|||||||
# polisplexity/core/urlbuild.py
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
def public_base(request=None) -> str:
|
|
||||||
"""
|
|
||||||
1) settings.PUBLIC_BASE_URL (recommended in prod)
|
|
||||||
2) request scheme/host (proxy aware)
|
|
||||||
3) http://localhost:8000 fallback
|
|
||||||
"""
|
|
||||||
base = getattr(settings, "PUBLIC_BASE_URL", "").rstrip("/")
|
|
||||||
if base:
|
|
||||||
return base
|
|
||||||
if request is not None:
|
|
||||||
proto = (request.META.get("HTTP_X_FORWARDED_PROTO")
|
|
||||||
or ("https" if request.is_secure() else "http"))
|
|
||||||
host = request.get_host()
|
|
||||||
return f"{proto.split(',')[0].strip()}://{host}"
|
|
||||||
return "http://localhost:8000"
|
|
||||||
|
|
||||||
def public_url(path_or_abs: str, request=None) -> str:
|
|
||||||
"""Join public base to a path; pass through absolute URLs untouched."""
|
|
||||||
if not path_or_abs:
|
|
||||||
return path_or_abs
|
|
||||||
if "://" in path_or_abs:
|
|
||||||
return path_or_abs
|
|
||||||
if not path_or_abs.startswith("/"):
|
|
||||||
path_or_abs = "/" + path_or_abs
|
|
||||||
return public_base(request) + path_or_abs
|
|
@ -1,6 +0,0 @@
|
|||||||
name,lat,lon,category
|
|
||||||
Cafe Centro,19.4335,-99.1342,cafe
|
|
||||||
Cafe Alameda,19.4350,-99.1410,cafe
|
|
||||||
Cafe Madero,19.4321,-99.1358,cafe
|
|
||||||
Cafe Zocalo,19.4329,-99.1320,cafe
|
|
||||||
Cafe Bellas Artes,19.4365,-99.1415,cafe
|
|
|
@ -1,9 +0,0 @@
|
|||||||
cell_id,lat,lon,pop
|
|
||||||
ZC_01,19.4334,-99.1322,1200
|
|
||||||
ZC_02,19.4318,-99.1339,950
|
|
||||||
ZC_03,19.4347,-99.1351,800
|
|
||||||
ZC_04,19.4309,-99.1311,700
|
|
||||||
ZC_05,19.4360,-99.1405,1100
|
|
||||||
ZC_06,19.4298,-99.1368,600
|
|
||||||
ZC_07,19.4382,-99.1450,900
|
|
||||||
ZC_08,19.4355,-99.1289,750
|
|
|
@ -1,4 +0,0 @@
|
|||||||
city,value
|
|
||||||
CDMX,100
|
|
||||||
GDL,55
|
|
||||||
MTY,60
|
|
|
@ -1,4 +0,0 @@
|
|||||||
city,N
|
|
||||||
CDMX,9209944
|
|
||||||
GDL,5269191
|
|
||||||
MTY,5341174
|
|
|
BIN
db.sqlite3
Normal file
BIN
db.sqlite3
Normal file
Binary file not shown.
@ -5,12 +5,12 @@ services:
|
|||||||
image: postgres:15
|
image: postgres:15
|
||||||
container_name: polisplexity_postgres
|
container_name: polisplexity_postgres
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
|
||||||
- "5434:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
ports:
|
||||||
|
- "5434:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@ -24,22 +24,16 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- .:/app # Ensure correct project structure
|
||||||
ports:
|
ports:
|
||||||
- "8010:8002"
|
- "8010:8001"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
command: >
|
command: >
|
||||||
sh -c "python manage.py migrate &&
|
sh -c "python manage.py migrate &&
|
||||||
python manage.py collectstatic --noinput &&
|
python manage.py collectstatic --noinput &&
|
||||||
gunicorn polisplexity.wsgi:application --bind 0.0.0.0:8002 --workers=4 --timeout=180"
|
exec gunicorn --bind 0.0.0.0:8001 polisplexity.wsgi:application"
|
||||||
volumes:
|
|
||||||
- static_data:/app/static
|
|
||||||
- media_data:/app/media
|
|
||||||
- ./staticfiles:/app/staticfiles
|
|
||||||
- ./data:/app/polisplexity/data:ro
|
|
||||||
# - .:/app # ←❌ No lo uses en producción: desactiva para evitar sobrescribir
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
static_data:
|
|
||||||
media_data:
|
|
||||||
|
BIN
media/images/logos/logo_U4MX.png
Normal file
BIN
media/images/logos/logo_U4MX.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 44 KiB |
BIN
media/media/images/menu/01_Forms_image.webp
Normal file
BIN
media/media/images/menu/01_Forms_image.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 708 KiB |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
BIN
polisplexity/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
polisplexity/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
polisplexity/__pycache__/settings.cpython-310.pyc
Normal file
BIN
polisplexity/__pycache__/settings.cpython-310.pyc
Normal file
Binary file not shown.
BIN
polisplexity/__pycache__/urls.cpython-310.pyc
Normal file
BIN
polisplexity/__pycache__/urls.cpython-310.pyc
Normal file
Binary file not shown.
BIN
polisplexity/__pycache__/wsgi.cpython-310.pyc
Normal file
BIN
polisplexity/__pycache__/wsgi.cpython-310.pyc
Normal file
Binary file not shown.
@ -1,48 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Django settings for polisplexity project.
|
||||||
|
|
||||||
|
Generated by 'django-admin startproject' using Django 5.0.3.
|
||||||
|
|
||||||
|
For more information on this file, see
|
||||||
|
https://docs.djangoproject.com/en/5.0/topics/settings/
|
||||||
|
|
||||||
|
For the full list of settings and their values, see
|
||||||
|
https://docs.djangoproject.com/en/5.0/ref/settings/
|
||||||
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
import dj_database_url
|
import dj_database_url
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load environment variables from .env file
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
BASE_URL = "https://app.polisplexity.tech"
|
BASE_URL = "https://app.polisplexity.tech"
|
||||||
|
|
||||||
import sys
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
sys.path.append(str(BASE_DIR))
|
|
||||||
|
|
||||||
|
|
||||||
# Core security settings
|
|
||||||
SECRET_KEY = os.getenv("SECRET_KEY")
|
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||||
DEBUG = os.getenv("DEBUG", "False") == "True"
|
|
||||||
import os
|
|
||||||
|
|
||||||
_raw = os.getenv("ALLOWED_HOSTS", "")
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
ALLOWED_HOSTS = [h.strip() for h in _raw.split(",") if h.strip()] # from .env if present
|
DEBUG = os.getenv("DEBUG") == "True"
|
||||||
|
|
||||||
# Hotfix: always allow local calls from inside container & host mapping
|
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")
|
||||||
for h in ("127.0.0.1", "localhost"):
|
|
||||||
if h not in ALLOWED_HOSTS:
|
|
||||||
ALLOWED_HOSTS.append(h)
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
# Django built-in apps
|
|
||||||
"django.contrib.admin",
|
"django.contrib.admin",
|
||||||
"django.contrib.auth",
|
"django.contrib.auth",
|
||||||
"django.contrib.contenttypes",
|
"django.contrib.contenttypes",
|
||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
"django.contrib.sites", # Required for allauth
|
|
||||||
|
|
||||||
# Allauth
|
|
||||||
"allauth",
|
|
||||||
"allauth.account",
|
|
||||||
"allauth.socialaccount",
|
|
||||||
"allauth.socialaccount.providers.github", # GitHub login only
|
|
||||||
|
|
||||||
# Your custom apps
|
|
||||||
"core",
|
"core",
|
||||||
"pxy_de",
|
"pxy_de",
|
||||||
"pxy_cr",
|
"pxy_cr",
|
||||||
@ -53,51 +48,14 @@ INSTALLED_APPS = [
|
|||||||
"pxy_meta_pages",
|
"pxy_meta_pages",
|
||||||
"pxy_langchain",
|
"pxy_langchain",
|
||||||
"pxy_neo4j",
|
"pxy_neo4j",
|
||||||
"pxy_dashboard",
|
|
||||||
"pxy_dashboard.custom",
|
|
||||||
"pxy_dashboard.apps",
|
|
||||||
"pxy_dashboard.components",
|
|
||||||
"pxy_dashboard.layouts",
|
|
||||||
"pxy_building_digital_twins",
|
|
||||||
"pxy_messenger",
|
|
||||||
'pxy_contracts',
|
|
||||||
'pxy_sami',
|
|
||||||
'pxy_routing',
|
|
||||||
'pxy_sites',
|
|
||||||
|
|
||||||
"rest_framework",
|
|
||||||
"pxy_api",
|
|
||||||
'pxy_agents_coral',
|
|
||||||
|
|
||||||
|
|
||||||
# Third-party apps
|
|
||||||
"crispy_forms",
|
|
||||||
"crispy_bootstrap5",
|
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
|
|
||||||
CRISPY_TEMPLATE_PACK = "bootstrap5"
|
|
||||||
|
|
||||||
SITE_ID = int(os.getenv("SITE_ID", 1))
|
|
||||||
|
|
||||||
AUTHENTICATION_BACKENDS = [
|
|
||||||
"django.contrib.auth.backends.ModelBackend", # default
|
|
||||||
"allauth.account.auth_backends.AuthenticationBackend", # allauth support
|
|
||||||
]
|
|
||||||
|
|
||||||
LOGIN_REDIRECT_URL = "/"
|
|
||||||
ACCOUNT_LOGOUT_REDIRECT_URL = "/accounts/login/"
|
|
||||||
|
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
"allauth.account.middleware.AccountMiddleware",
|
|
||||||
"pxy_dashboard.middleware.LoginRequiredMiddleware",
|
|
||||||
"django.contrib.messages.middleware.MessageMiddleware",
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
]
|
]
|
||||||
@ -107,10 +65,7 @@ ROOT_URLCONF = "polisplexity.urls"
|
|||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
"DIRS": [
|
"DIRS": [os.path.join(BASE_DIR, "templates")],
|
||||||
os.path.join(BASE_DIR, "templates"),
|
|
||||||
os.path.join(BASE_DIR, "pxy_dashboard", "templates"),
|
|
||||||
],
|
|
||||||
"APP_DIRS": True,
|
"APP_DIRS": True,
|
||||||
"OPTIONS": {
|
"OPTIONS": {
|
||||||
"context_processors": [
|
"context_processors": [
|
||||||
@ -118,17 +73,14 @@ TEMPLATES = [
|
|||||||
"django.template.context_processors.request",
|
"django.template.context_processors.request",
|
||||||
"django.contrib.auth.context_processors.auth",
|
"django.contrib.auth.context_processors.auth",
|
||||||
"django.contrib.messages.context_processors.messages",
|
"django.contrib.messages.context_processors.messages",
|
||||||
"pxy_dashboard.context_processors.sidebar_context",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
WSGI_APPLICATION = "polisplexity.wsgi.application"
|
WSGI_APPLICATION = "polisplexity.wsgi.application"
|
||||||
|
|
||||||
# Database
|
# Database Configuration
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": dj_database_url.config(default=os.getenv("DATABASE_URL"))
|
"default": dj_database_url.config(default=os.getenv("DATABASE_URL"))
|
||||||
}
|
}
|
||||||
@ -147,113 +99,29 @@ TIME_ZONE = "UTC"
|
|||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
# Static & Media Files
|
|
||||||
STATIC_URL = "/static/"
|
STATIC_URL = "/static/"
|
||||||
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
|
STATIC_ROOT = os.path.join(BASE_DIR, "static") # Ensure this line is correct
|
||||||
|
|
||||||
STATICFILES_DIRS = [
|
# Add this if missing
|
||||||
os.path.join(BASE_DIR, "polisplexity/pxy_dashboard/static"), # Jidox assets
|
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # Fixes mixed content issues
|
||||||
]
|
CSRF_TRUSTED_ORIGINS = ['https://app.polisplexity.tech'] # Allow CSRF over HTTPS
|
||||||
|
|
||||||
MEDIA_URL = "/media/"
|
MEDIA_URL = "/media/"
|
||||||
MEDIA_ROOT = BASE_DIR / "media"
|
MEDIA_ROOT = os.getenv("MEDIA_ROOT", os.path.join(BASE_DIR, "media"))
|
||||||
|
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
# External services
|
# Facebook API Tokens
|
||||||
PAGE_ACCESS_TOKEN = os.getenv("PAGE_ACCESS_TOKEN")
|
PAGE_ACCESS_TOKEN = os.getenv("PAGE_ACCESS_TOKEN")
|
||||||
VERIFY_TOKEN = os.getenv("VERIFY_TOKEN")
|
VERIFY_TOKEN = os.getenv("VERIFY_TOKEN")
|
||||||
|
|
||||||
# Async-safe for Neo4j or Celery
|
|
||||||
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
|
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
|
||||||
# ...pero silenciamos la comprobación que falla en producción:
|
|
||||||
SILENCED_SYSTEM_CHECKS = ["async.E001"]
|
|
||||||
|
|
||||||
# Neo4j
|
# Neo4j Database Configuration
|
||||||
NEO4J_URI = os.getenv("NEO4J_URI")
|
NEO4J_URI = os.getenv("NEO4J_URI")
|
||||||
NEO4J_USERNAME = os.getenv("NEO4J_USERNAME")
|
NEO4J_USERNAME = os.getenv("NEO4J_USERNAME")
|
||||||
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD")
|
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD")
|
||||||
|
|
||||||
# OpenAI
|
# OpenAI API Key
|
||||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
||||||
|
|
||||||
# CSRF protection for production
|
|
||||||
CSRF_TRUSTED_ORIGINS = [
|
|
||||||
"https://app.polisplexity.tech",
|
|
||||||
]
|
|
||||||
|
|
||||||
# Support for secure reverse proxy (e.g., Nginx or Hostinger HTTPS proxy)
|
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
|
||||||
|
|
||||||
|
|
||||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
|
||||||
EMAIL_HOST = "smtp.hostinger.com"
|
|
||||||
EMAIL_PORT = 465
|
|
||||||
EMAIL_USE_SSL = True
|
|
||||||
EMAIL_HOST_USER = "noreply@polisplexity.tech" # Cambia esto por tu correo real
|
|
||||||
EMAIL_HOST_PASSWORD = os.getenv("EMAIL_HOST_PASSWORD") # Mejor usar .env
|
|
||||||
DEFAULT_FROM_EMAIL = "Polisplexity <noreply@polisplexity.tech>"
|
|
||||||
|
|
||||||
MESSENGER_VERIFY_TOKEN = os.getenv("MESSENGER_VERIFY_TOKEN", "dev-change-me")
|
|
||||||
|
|
||||||
FACEBOOK_APP_SECRET = os.getenv("FACEBOOK_APP_SECRET", "") # set this in .env for prod
|
|
||||||
|
|
||||||
REST_FRAMEWORK = {
|
|
||||||
# Deshabilitamos auth por ahora para evitar CSRF en curl
|
|
||||||
"DEFAULT_AUTHENTICATION_CLASSES": [],
|
|
||||||
|
|
||||||
# Throttling global + por-scope
|
|
||||||
"DEFAULT_THROTTLE_CLASSES": [
|
|
||||||
"rest_framework.throttling.AnonRateThrottle",
|
|
||||||
"rest_framework.throttling.UserRateThrottle",
|
|
||||||
"rest_framework.throttling.ScopedRateThrottle",
|
|
||||||
],
|
|
||||||
"DEFAULT_THROTTLE_RATES": {
|
|
||||||
"anon": "100/hour",
|
|
||||||
"user": "1000/hour",
|
|
||||||
"sami_run": "30/minute",
|
|
||||||
"sites_search": "15/minute",
|
|
||||||
"routing_isochrone": "60/minute",
|
|
||||||
"routing_health": "120/minute",
|
|
||||||
"sami_health": "120/minute",
|
|
||||||
"sites_health": "120/minute",
|
|
||||||
},
|
|
||||||
|
|
||||||
# Manejo de errores uniforme
|
|
||||||
"EXCEPTION_HANDLER": "pxy_api.exceptions.envelope_exception_handler",
|
|
||||||
}
|
|
||||||
|
|
||||||
AGENTS_INTERNAL_BASE = os.getenv("AGENTS_INTERNAL_BASE", "")
|
|
||||||
|
|
||||||
LOGGING = {
|
|
||||||
# ... keep your existing handlers/formatters ...
|
|
||||||
"version": 1,
|
|
||||||
"disable_existing_loggers": False,
|
|
||||||
"handlers": {
|
|
||||||
"console": {
|
|
||||||
"class": "logging.StreamHandler",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"loggers": {
|
|
||||||
"pxy_bots": { # package logger
|
|
||||||
"handlers": ["console"],
|
|
||||||
"level": "INFO", # or "DEBUG"
|
|
||||||
"propagate": False,
|
|
||||||
},
|
|
||||||
# make sure __name__ in router resolves here; if your module path is different, use that
|
|
||||||
"pxy_bots.router": {
|
|
||||||
"handlers": ["console"],
|
|
||||||
"level": "INFO",
|
|
||||||
"propagate": False,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# honor reverse proxy + allow “Host” from proxy
|
|
||||||
USE_X_FORWARDED_HOST = True
|
|
||||||
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
|
|
||||||
|
|
||||||
# optional hard override (recommended in prod)
|
|
||||||
PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "").rstrip("/")
|
|
||||||
|
@ -25,42 +25,12 @@ admin.site.index_title = "Welcome to Polisplexity City Technologies Portal"
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("accounts/", include("allauth.urls")), # ← Add this line
|
path('', include('core.urls')),
|
||||||
path('', include('pxy_dashboard.urls')),
|
|
||||||
path('core', include('core.urls')),
|
|
||||||
path('', include('pxy_city_digital_twins.urls')),
|
path('', include('pxy_city_digital_twins.urls')),
|
||||||
path('pxy_whatsapp/', include('pxy_whatsapp.urls')),
|
path('pxy_whatsapp/', include('pxy_whatsapp.urls')),
|
||||||
path('bots/', include('pxy_bots.urls')),
|
path('bots/', include('pxy_bots.urls')), # Webhook URL: /bots/webhook/<bot_name>/
|
||||||
path('pxy_meta_pages/', include('pxy_meta_pages.urls', namespace='pxy_meta_pages')),
|
path('pxy_meta_pages/', include('pxy_meta_pages.urls', namespace='pxy_meta_pages')),
|
||||||
path(
|
|
||||||
"building/",
|
|
||||||
include(("pxy_building_digital_twins.urls", "pxy_building_digital_twins"),
|
|
||||||
namespace="pxy_building_digital_twins"),
|
|
||||||
),
|
|
||||||
path("messenger/", include("pxy_messenger.urls")),
|
|
||||||
path("", include("pxy_sami.api.urls")),
|
|
||||||
path("", include("pxy_routing.api.urls")),
|
|
||||||
path("", include("pxy_sites.api.urls")),
|
|
||||||
path("", include("pxy_de.urls")),
|
|
||||||
|
|
||||||
path("share/", include("pxy_dashboard.share_urls")), # ← NEW
|
|
||||||
|
|
||||||
path("api/", include("pxy_bots.api.urls")),
|
|
||||||
path("api/langchain/", include("pxy_langchain.api.urls")),
|
|
||||||
|
|
||||||
path("", include("pxy_openai.urls")),
|
|
||||||
|
|
||||||
path("", include("pxy_contracts.urls")),
|
|
||||||
|
|
||||||
path('', include('pxy_agents_coral.urls')),
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
BIN
preview.png
BIN
preview.png
Binary file not shown.
Before Width: | Height: | Size: 110 KiB |
@ -1,3 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
|
@ -1,6 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class PxyAgentsCoralConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'pxy_agents_coral'
|
|
@ -1,117 +0,0 @@
|
|||||||
from __future__ import annotations #
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
import requests
|
|
||||||
from typing import Any, Dict, Tuple
|
|
||||||
from django.http import JsonResponse, HttpRequest
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from django.views.decorators.http import require_http_methods
|
|
||||||
|
|
||||||
# ---------- helpers ----------
|
|
||||||
def _base(request: HttpRequest) -> str:
|
|
||||||
return request.build_absolute_uri("/")[:-1]
|
|
||||||
|
|
||||||
def _load_body(request: HttpRequest) -> Dict[str, Any]:
|
|
||||||
try:
|
|
||||||
raw = (request.body or b"").decode("utf-8")
|
|
||||||
return json.loads(raw or "{}")
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _extract_payload(body: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
|
|
||||||
"""
|
|
||||||
Returns (payload, src) where src is 'payload', 'args_raw', or 'empty'.
|
|
||||||
Accepts:
|
|
||||||
1) {"payload": {...}}
|
|
||||||
2) full canonical envelope with .input.args_raw="/sami {...}"
|
|
||||||
"""
|
|
||||||
# 1) direct payload
|
|
||||||
if isinstance(body.get("payload"), dict):
|
|
||||||
return body["payload"], "payload"
|
|
||||||
|
|
||||||
# 2) canonical envelope: parse JSON after the command in args_raw
|
|
||||||
args_raw = (body.get("input", {}) or {}).get("args_raw") or ""
|
|
||||||
# strip leading "/sami " or "/sites "
|
|
||||||
cleaned = re.sub(r"^/\w+\s*", "", args_raw).strip()
|
|
||||||
if cleaned:
|
|
||||||
try:
|
|
||||||
return json.loads(cleaned), "args_raw"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {}, "empty"
|
|
||||||
|
|
||||||
def _post_execute(request: HttpRequest, agent: str, payload: Dict[str, Any], timeout: float = 30.0):
|
|
||||||
url = f"{_base(request)}/api/agents/execute"
|
|
||||||
try:
|
|
||||||
r = requests.post(url, json={"agent": agent, "payload": payload}, timeout=timeout)
|
|
||||||
# try parse json regardless of status
|
|
||||||
try:
|
|
||||||
data = r.json()
|
|
||||||
except Exception:
|
|
||||||
data = {"code": "NON_JSON", "message": r.text[:2000]}
|
|
||||||
return r.status_code, data
|
|
||||||
except requests.Timeout:
|
|
||||||
return 504, {"code": "UPSTREAM_TIMEOUT", "message": "agent upstream timed out"}
|
|
||||||
except Exception as e:
|
|
||||||
return 500, {"code": "EXEC_ERROR", "message": str(e)}
|
|
||||||
|
|
||||||
# ---------- text builders ----------
|
|
||||||
def _text_sami(data: Dict[str, Any]) -> str:
|
|
||||||
if "beta" in data and "r2" in data:
|
|
||||||
lines = [f"SAMI run: β={data['beta']:.3f}, R²={data['r2']:.3f}"]
|
|
||||||
resid = data.get("residuals") or []
|
|
||||||
top = sorted(resid, key=lambda x: x.get("rank", 1e9))[:3]
|
|
||||||
for c in top:
|
|
||||||
lines.append(f"{c.get('rank')}. {c.get('city')}: {c.get('sami',0):+0.2f}")
|
|
||||||
if data.get("share_url"):
|
|
||||||
lines += ["", data["share_url"]]
|
|
||||||
return "\n".join(lines)
|
|
||||||
if data.get("code"):
|
|
||||||
return f"⚠️ {data.get('code')}: {data.get('message','')}"
|
|
||||||
return "SAMI results ready."
|
|
||||||
|
|
||||||
def _text_sites(data: Dict[str, Any]) -> str:
|
|
||||||
if isinstance(data.get("candidates"), list):
|
|
||||||
city = data.get("city", "?")
|
|
||||||
business = data.get("business", "?")
|
|
||||||
lines = [f"Top sites for {business} in {city}:"]
|
|
||||||
for i, c in enumerate(data["candidates"][:3], 1):
|
|
||||||
lat = c.get("lat", 0); lon = c.get("lon", 0); sc = c.get("score", 0)
|
|
||||||
lines.append(f"{i}. score={sc:.2f} @ ({lat:.5f},{lon:.5f})")
|
|
||||||
for k in ("share_url", "isochrones_geojson_url", "candidates_geojson_url"):
|
|
||||||
if data.get(k): lines.append(data[k])
|
|
||||||
return "\n".join(lines)
|
|
||||||
if data.get("code"):
|
|
||||||
return f"⚠️ {data.get('code')}: {data.get('message','')}"
|
|
||||||
return "Site scoring ready."
|
|
||||||
|
|
||||||
# ---------- views ----------
|
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def format_sami(request: HttpRequest):
|
|
||||||
body = _load_body(request)
|
|
||||||
payload, src = _extract_payload(body)
|
|
||||||
status, data = _post_execute(request, "sami", payload, timeout=30.0)
|
|
||||||
# add echo + text
|
|
||||||
data = data if isinstance(data, dict) else {"result": data}
|
|
||||||
data.setdefault("_echo", {"src": src, "payload_keys": list(payload.keys())})
|
|
||||||
try:
|
|
||||||
data["text"] = _text_sami(data)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return JsonResponse(data, status=status, safe=False)
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def format_sites(request: HttpRequest):
|
|
||||||
body = _load_body(request)
|
|
||||||
payload, src = _extract_payload(body)
|
|
||||||
status, data = _post_execute(request, "sites", payload, timeout=30.0)
|
|
||||||
data = data if isinstance(data, dict) else {"result": data}
|
|
||||||
data.setdefault("_echo", {"src": src, "payload_keys": list(payload.keys())})
|
|
||||||
try:
|
|
||||||
data["text"] = _text_sites(data)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return JsonResponse(data, status=status, safe=False)
|
|
@ -1,3 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
|
|
||||||
# Create your models here.
|
|
@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
@ -1,10 +0,0 @@
|
|||||||
from django.urls import path
|
|
||||||
from .formatters import format_sami, format_sites
|
|
||||||
from .views import agents_list, agents_execute
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("api/agents/list", agents_list, name="agents_list"),
|
|
||||||
path("api/agents/execute", agents_execute, name="agents_execute"),
|
|
||||||
path("api/agents/format/sami", format_sami, name="agents_format_sami"),
|
|
||||||
path("api/agents/format/sites", format_sites, name="agents_format_sites"),
|
|
||||||
]
|
|
@ -1,233 +0,0 @@
|
|||||||
# polisplexity/pxy_agents_coral/views.py
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from typing import Any, Dict, Tuple
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from django.conf import settings
|
|
||||||
from django.http import JsonResponse, HttpRequest
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from django.views.decorators.http import require_http_methods, require_POST
|
|
||||||
|
|
||||||
# build absolute public URLs from a request + path
|
|
||||||
from core.urlbuild import public_url
|
|
||||||
|
|
||||||
# ----- contracts version (best-effort) -----
|
|
||||||
try:
|
|
||||||
from pxy_contracts.version import SPEC_VERSION
|
|
||||||
except Exception:
|
|
||||||
SPEC_VERSION = "0.1.0"
|
|
||||||
|
|
||||||
# ----- INTERNAL CALL BASES -----
|
|
||||||
# For the generic /api/agents/execute proxy (kept for compatibility)
|
|
||||||
AGENTS_INTERNAL_BASE = getattr(settings, "AGENTS_INTERNAL_BASE", "")
|
|
||||||
|
|
||||||
# For the formatter endpoints we *force* an internal base and never guess from Host.
|
|
||||||
# Set in .env: AGENTS_INTERNAL_BASE=http://127.0.0.1:8002
|
|
||||||
# Fallback keeps you safe even if env is missing/misread.
|
|
||||||
FORMAT_INTERNAL_BASE = (AGENTS_INTERNAL_BASE or "http://127.0.0.1:8002").rstrip("/")
|
|
||||||
|
|
||||||
# ===== helpers =====
|
|
||||||
def _load_body(request: HttpRequest) -> Dict[str, Any]:
|
|
||||||
try:
|
|
||||||
raw = (request.body or b"").decode("utf-8")
|
|
||||||
return json.loads(raw or "{}")
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _extract_payload(body: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
|
|
||||||
"""
|
|
||||||
Returns (payload, src) where src is 'payload', 'args_raw', or 'empty'.
|
|
||||||
Accepts:
|
|
||||||
1) {"payload": {...}}
|
|
||||||
2) Canonical tg envelope with .input.args_raw like "/sami {...}"
|
|
||||||
"""
|
|
||||||
if isinstance(body.get("payload"), dict):
|
|
||||||
return body["payload"], "payload"
|
|
||||||
args_raw = (body.get("input", {}) or {}).get("args_raw") or ""
|
|
||||||
cleaned = re.sub(r"^/\w+\s*", "", args_raw).strip()
|
|
||||||
if cleaned:
|
|
||||||
try:
|
|
||||||
return json.loads(cleaned), "args_raw"
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {}, "empty"
|
|
||||||
|
|
||||||
def _post_underlying(agent: str, payload: Dict[str, Any], timeout: float = 60.0):
|
|
||||||
"""
|
|
||||||
Call the *real* internal APIs via a fixed base (no build_absolute_uri):
|
|
||||||
sami -> /api/sami/run
|
|
||||||
sites -> /api/sites/search
|
|
||||||
"""
|
|
||||||
path = "/api/sami/run" if agent == "sami" else "/api/sites/search"
|
|
||||||
url = f"{FORMAT_INTERNAL_BASE}{path}"
|
|
||||||
try:
|
|
||||||
r = requests.post(url, json=payload, timeout=timeout)
|
|
||||||
try:
|
|
||||||
data = r.json()
|
|
||||||
except Exception:
|
|
||||||
data = {"code": "NON_JSON", "message": r.text[:2000]}
|
|
||||||
return r.status_code, data
|
|
||||||
except requests.Timeout:
|
|
||||||
return 504, {"code": "UPSTREAM_TIMEOUT", "message": "agent upstream timed out", "_debug_url": url}
|
|
||||||
except Exception as e:
|
|
||||||
return 500, {"code": "EXEC_ERROR", "message": str(e), "_debug_url": url}
|
|
||||||
|
|
||||||
def _normalize_urls_to_public(data: dict, request: HttpRequest) -> None:
|
|
||||||
"""
|
|
||||||
Convert any absolute URLs that may point to 127.0.0.1:8002 into public URLs
|
|
||||||
using the current request host while preserving the path.
|
|
||||||
"""
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return
|
|
||||||
|
|
||||||
url_keys = {
|
|
||||||
# common keys from sami + sites
|
|
||||||
"share_url", "map_url", "demand_map_url", "competition_map_url",
|
|
||||||
"main_download_url", "demand_download_url", "competition_download_url",
|
|
||||||
"main_preview_url", "demand_preview_url", "competition_preview_url",
|
|
||||||
"isochrones_geojson_url", "candidates_geojson_url",
|
|
||||||
"pois_competition_geojson_url", "popgrid_geojson_url",
|
|
||||||
"chart_url",
|
|
||||||
}
|
|
||||||
|
|
||||||
for k in list(url_keys):
|
|
||||||
v = data.get(k)
|
|
||||||
if not isinstance(v, str) or not v:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
p = urlparse(v)
|
|
||||||
# only rewrite absolute http(s) URLs; keep relative ones
|
|
||||||
if p.scheme in ("http", "https") and p.path:
|
|
||||||
data[k] = public_url(request, p.path)
|
|
||||||
except Exception:
|
|
||||||
# never fail formatting due to a bad URL
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Tiny text builders for bot replies
|
|
||||||
def _text_sami(data: Dict[str, Any]) -> str:
|
|
||||||
if "beta" in data and "r2" in data:
|
|
||||||
lines = [f"SAMI run: β={data['beta']:.3f}, R²={data['r2']:.3f}"]
|
|
||||||
for c in sorted(data.get("residuals", []), key=lambda x: x.get("rank", 1e9))[:3]:
|
|
||||||
lines.append(f"{c.get('rank')}. {c.get('city')}: {c.get('sami',0):+0.2f}")
|
|
||||||
if data.get("share_url"):
|
|
||||||
lines += ["", data["share_url"]]
|
|
||||||
return "\n".join(lines)
|
|
||||||
if data.get("code"):
|
|
||||||
return f"⚠️ {data.get('code')}: {data.get('message','')}"
|
|
||||||
return "SAMI results ready."
|
|
||||||
|
|
||||||
def _text_sites(data: Dict[str, Any]) -> str:
|
|
||||||
if isinstance(data.get("candidates"), list):
|
|
||||||
city = data.get("city", "?"); biz = data.get("business", "?")
|
|
||||||
lines = [f"Top sites for {biz} in {city}:"]
|
|
||||||
for i, c in enumerate(data["candidates"][:3], 1):
|
|
||||||
lines.append(f"{i}. score={c.get('score',0):.2f} @ ({c.get('lat',0):.5f},{c.get('lon',0):.5f})")
|
|
||||||
for k in ("share_url", "isochrones_geojson_url", "candidates_geojson_url"):
|
|
||||||
if data.get(k): lines.append(data[k])
|
|
||||||
return "\n".join(lines)
|
|
||||||
if data.get("code"):
|
|
||||||
return f"⚠️ {data.get('code')}: {data.get('message','')}"
|
|
||||||
return "Site scoring ready."
|
|
||||||
|
|
||||||
# ===== public endpoints =====
|
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def agents_list(request: HttpRequest):
|
|
||||||
# use request host only for *outward* links (safe)
|
|
||||||
base = request.build_absolute_uri("/")[:-1]
|
|
||||||
agents = [
|
|
||||||
{
|
|
||||||
"agent": "sami",
|
|
||||||
"name": "SAMI-Agent",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"spec_version": SPEC_VERSION,
|
|
||||||
"contracts_url": f"{base}/api/contracts/sami.json",
|
|
||||||
"execute_url": f"{base}/api/agents/execute",
|
|
||||||
"description": "Urban scaling (β, R²) + SAMI residuals + chart",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"agent": "sites",
|
|
||||||
"name": "Sites-Agent",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"spec_version": SPEC_VERSION,
|
|
||||||
"contracts_url": f"{base}/api/contracts/sites.json",
|
|
||||||
"execute_url": f"{base}/api/agents/execute",
|
|
||||||
"description": "Site scoring (access, demand, competition) with maps",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
lines = ["Available agents:"]
|
|
||||||
for a in agents:
|
|
||||||
lines.append(f"- {a['agent']}: {a['description']}")
|
|
||||||
lines += [
|
|
||||||
"",
|
|
||||||
"Try:",
|
|
||||||
'/sami {"indicator":"imss_wages_2023","cities":["CDMX","GDL","MTY"]}',
|
|
||||||
'/sites {"city":"CDMX","business":"cafe","time_bands":[10,20]}',
|
|
||||||
]
|
|
||||||
return JsonResponse({"agents": agents, "text": "\n".join(lines)})
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_POST
|
|
||||||
def agents_execute(request: HttpRequest):
|
|
||||||
"""
|
|
||||||
Body: { "agent": "sami"|"sites", "payload": {...} }
|
|
||||||
Proxies to the *internal* API using AGENTS_INTERNAL_BASE (or same-host fallback).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
body = json.loads(request.body.decode("utf-8") or "{}")
|
|
||||||
agent = (body.get("agent") or "").strip().lower()
|
|
||||||
payload = body.get("payload")
|
|
||||||
|
|
||||||
if agent not in {"sami", "sites"}:
|
|
||||||
return JsonResponse({"code": "AGENT_NOT_FOUND", "message": f"unknown agent '{agent}'"}, status=404)
|
|
||||||
if payload is None:
|
|
||||||
return JsonResponse({"code": "BAD_REQUEST", "message": "missing 'payload'"}, status=400)
|
|
||||||
|
|
||||||
path = "/api/sami/run" if agent == "sami" else "/api/sites/search"
|
|
||||||
base = (AGENTS_INTERNAL_BASE or "http://127.0.0.1:8002").rstrip("/")
|
|
||||||
url = f"{base}{path}"
|
|
||||||
|
|
||||||
r = requests.post(url, json=payload, timeout=90)
|
|
||||||
return JsonResponse(r.json(), status=r.status_code, safe=False)
|
|
||||||
|
|
||||||
except requests.Timeout:
|
|
||||||
return JsonResponse({"code": "UPSTREAM_TIMEOUT", "message": "agent upstream timed out"}, status=504)
|
|
||||||
except ValueError as ve:
|
|
||||||
return JsonResponse({"code": "BAD_JSON", "message": str(ve)}, status=400)
|
|
||||||
except Exception as e:
|
|
||||||
return JsonResponse({"code": "AGENT_EXEC_ERROR", "message": str(e)}, status=500)
|
|
||||||
|
|
||||||
# ----- formatters (call underlying APIs directly via fixed base) -----
|
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def format_sami(request: HttpRequest):
|
|
||||||
body = _load_body(request)
|
|
||||||
payload, src = _extract_payload(body)
|
|
||||||
status, data = _post_underlying("sami", payload, timeout=60.0)
|
|
||||||
data = data if isinstance(data, dict) else {"result": data}
|
|
||||||
_normalize_urls_to_public(data, request) # ensure public links
|
|
||||||
data.setdefault("_echo", {"src": src, "payload_keys": list(payload.keys())})
|
|
||||||
try:
|
|
||||||
data["text"] = _text_sami(data)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return JsonResponse(data, status=status, safe=False)
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
@require_http_methods(["GET", "POST"])
|
|
||||||
def format_sites(request: HttpRequest):
|
|
||||||
body = _load_body(request)
|
|
||||||
payload, src = _extract_payload(body)
|
|
||||||
status, data = _post_underlying("sites", payload, timeout=60.0)
|
|
||||||
data = data if isinstance(data, dict) else {"result": data}
|
|
||||||
_normalize_urls_to_public(data, request) # ensure public links
|
|
||||||
data.setdefault("_echo", {"src": src, "payload_keys": list(payload.keys())})
|
|
||||||
try:
|
|
||||||
data["text"] = _text_sites(data)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return JsonResponse(data, status=status, safe=False)
|
|
@ -1 +0,0 @@
|
|||||||
default_app_config = "pxy_api.apps.PxyApiConfig"
|
|
@ -1,5 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
class PxyApiConfig(AppConfig):
|
|
||||||
default_auto_field = "django.db.models.BigAutoField"
|
|
||||||
name = "pxy_api"
|
|
@ -1,57 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
import uuid, traceback
|
|
||||||
from django.conf import settings
|
|
||||||
from rest_framework.views import exception_handler as drf_exception_handler
|
|
||||||
from rest_framework.response import Response
|
|
||||||
from rest_framework import status
|
|
||||||
from rest_framework.exceptions import ValidationError as DRFValidationError
|
|
||||||
from pydantic import ValidationError as PydValidationError
|
|
||||||
|
|
||||||
def envelope_exception_handler(exc, context):
|
|
||||||
"""
|
|
||||||
Envuelve *todas* las excepciones DRF en:
|
|
||||||
{ ok: false, code, message, errors?, hint?, trace_id, detail?(DEBUG) }
|
|
||||||
"""
|
|
||||||
resp = drf_exception_handler(exc, context)
|
|
||||||
trace_id = str(uuid.uuid4())
|
|
||||||
|
|
||||||
if resp is not None:
|
|
||||||
# DRF ya resolvió un status_code razonable
|
|
||||||
code = getattr(exc, "default_code", "error")
|
|
||||||
message = None
|
|
||||||
|
|
||||||
if isinstance(exc, DRFValidationError):
|
|
||||||
message = "Validation error"
|
|
||||||
else:
|
|
||||||
# fallback a string corto
|
|
||||||
message = str(getattr(exc, "detail", "")) or exc.__class__.__name__
|
|
||||||
|
|
||||||
data = {
|
|
||||||
"ok": False,
|
|
||||||
"code": code,
|
|
||||||
"message": message,
|
|
||||||
"errors": resp.data, # DRF normaliza los errores aquí
|
|
||||||
"hint": None,
|
|
||||||
"trace_id": trace_id,
|
|
||||||
}
|
|
||||||
if settings.DEBUG:
|
|
||||||
data["detail"] = _short_trace()
|
|
||||||
return Response(data, status=resp.status_code)
|
|
||||||
|
|
||||||
# Excepción no manejada por DRF -> 500
|
|
||||||
data = {
|
|
||||||
"ok": False,
|
|
||||||
"code": "server_error",
|
|
||||||
"message": "Unexpected server error",
|
|
||||||
"hint": None,
|
|
||||||
"trace_id": trace_id,
|
|
||||||
}
|
|
||||||
if settings.DEBUG:
|
|
||||||
data["detail"] = _short_trace()
|
|
||||||
return Response(data, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
|
||||||
|
|
||||||
def _short_trace():
|
|
||||||
try:
|
|
||||||
return "\n".join(traceback.format_exc().splitlines()[-6:])
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
BIN
pxy_bots.zip
BIN
pxy_bots.zip
Binary file not shown.
BIN
pxy_bots/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
pxy_bots/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pxy_bots/__pycache__/admin.cpython-310.pyc
Normal file
BIN
pxy_bots/__pycache__/admin.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pxy_bots/__pycache__/apps.cpython-310.pyc
Normal file
BIN
pxy_bots/__pycache__/apps.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pxy_bots/__pycache__/handlers.cpython-310.pyc
Normal file
BIN
pxy_bots/__pycache__/handlers.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pxy_bots/__pycache__/models.cpython-310.pyc
Normal file
BIN
pxy_bots/__pycache__/models.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pxy_bots/__pycache__/urls.cpython-310.pyc
Normal file
BIN
pxy_bots/__pycache__/urls.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pxy_bots/__pycache__/utils.cpython-310.pyc
Normal file
BIN
pxy_bots/__pycache__/utils.cpython-310.pyc
Normal file
Binary file not shown.
BIN
pxy_bots/__pycache__/views.cpython-310.pyc
Normal file
BIN
pxy_bots/__pycache__/views.cpython-310.pyc
Normal file
Binary file not shown.
@ -1,80 +1,52 @@
|
|||||||
# pxy_bots/admin.py
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import TelegramBot, TelegramConversation, TelegramMessage, Connection, CommandRoute
|
from .models import TelegramBot
|
||||||
|
from django.utils.html import format_html
|
||||||
|
|
||||||
# ---- Connections ----
|
|
||||||
@admin.register(Connection)
|
|
||||||
class ConnectionAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("name", "base_url", "auth_type", "timeout_s", "is_active")
|
|
||||||
list_filter = ("auth_type", "is_active")
|
|
||||||
search_fields = ("name", "base_url")
|
|
||||||
readonly_fields = ("created_at", "updated_at")
|
|
||||||
fieldsets = (
|
|
||||||
(None, {
|
|
||||||
"fields": ("name", "is_active")
|
|
||||||
}),
|
|
||||||
("Endpoint", {
|
|
||||||
"fields": ("base_url", "path_default", "timeout_s", "allowed_hosts")
|
|
||||||
}),
|
|
||||||
("Auth & Headers", {
|
|
||||||
"fields": ("auth_type", "auth_value", "headers_json"),
|
|
||||||
"description": "Optional headers in JSON (e.g. {\"X-Tenant\":\"mx\"})."
|
|
||||||
}),
|
|
||||||
("Timestamps", {
|
|
||||||
"fields": ("created_at", "updated_at")
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---- Command routes inline under each bot ----
|
|
||||||
class CommandRouteInline(admin.TabularInline):
|
|
||||||
model = CommandRoute
|
|
||||||
extra = 0
|
|
||||||
fields = ("enabled", "priority", "trigger", "command_name", "connection", "path", "note")
|
|
||||||
ordering = ("priority", "id")
|
|
||||||
autocomplete_fields = ("connection",)
|
|
||||||
show_change_link = True
|
|
||||||
|
|
||||||
@admin.register(CommandRoute)
|
|
||||||
class CommandRouteAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("bot", "enabled", "priority", "trigger", "command_name", "connection", "path")
|
|
||||||
list_filter = ("enabled", "trigger", "bot", "connection")
|
|
||||||
search_fields = ("command_name", "note", "bot__name", "connection__name")
|
|
||||||
ordering = ("bot__name", "priority", "id")
|
|
||||||
|
|
||||||
# ---- Bots (with routes inline) ----
|
|
||||||
@admin.register(TelegramBot)
|
@admin.register(TelegramBot)
|
||||||
class TelegramBotAdmin(admin.ModelAdmin):
|
class TelegramBotAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name", "username", "token_preview", "is_active", "created_at")
|
list_display = ("name", "username", "is_active", "get_assistant_name", "set_webhook_action")
|
||||||
list_filter = ("is_active",)
|
|
||||||
search_fields = ("name", "username")
|
search_fields = ("name", "username")
|
||||||
inlines = [CommandRouteInline]
|
list_filter = ("is_active",)
|
||||||
readonly_fields = ("created_at", "updated_at")
|
actions = ["set_webhooks"]
|
||||||
|
|
||||||
def token_preview(self, obj):
|
@admin.action(description="Set webhooks for selected bots")
|
||||||
return f"{obj.token[:8]}…{obj.token[-4:]}" if obj.token else "—"
|
def set_webhooks(self, request, queryset):
|
||||||
token_preview.short_description = "Token"
|
base_url = request.build_absolute_uri("/")[:-1] # Get base server URL
|
||||||
|
results = []
|
||||||
|
for bot in queryset:
|
||||||
|
if bot.is_active:
|
||||||
|
try:
|
||||||
|
result = bot.set_webhook(base_url)
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"Webhook set for {bot.name}: {result}",
|
||||||
|
level="success",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"Failed to set webhook for {bot.name}: {str(e)}",
|
||||||
|
level="error",
|
||||||
|
)
|
||||||
|
results.append(result)
|
||||||
|
else:
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
f"Skipped inactive bot: {bot.name}",
|
||||||
|
level="warning",
|
||||||
|
)
|
||||||
|
|
||||||
# ---- (Optional) conversation/message logs, already simple ----
|
def get_assistant_name(self, obj):
|
||||||
@admin.register(TelegramConversation)
|
"""Show the name of the assistant linked to the bot."""
|
||||||
class TelegramConversationAdmin(admin.ModelAdmin):
|
return obj.assistant.name if obj.assistant else "None"
|
||||||
list_display = ("bot", "user_id", "started_at")
|
|
||||||
list_filter = ("bot",)
|
|
||||||
search_fields = ("user_id",)
|
|
||||||
|
|
||||||
@admin.register(TelegramMessage)
|
get_assistant_name.short_description = "Assistant Name"
|
||||||
class TelegramMessageAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("conversation", "direction", "short", "timestamp", "response_time_ms")
|
|
||||||
list_filter = ("direction",)
|
|
||||||
search_fields = ("content",)
|
|
||||||
def short(self, obj): return (obj.content or "")[:60]
|
|
||||||
|
|
||||||
|
def set_webhook_action(self, obj):
|
||||||
|
"""Button in the Django admin to manually trigger webhook setup."""
|
||||||
|
return format_html(
|
||||||
|
'<a class="button" href="{}">Set Webhook</a>',
|
||||||
|
f"/admin/pxy_bots/set_webhook/{obj.id}/"
|
||||||
|
)
|
||||||
|
|
||||||
from django.contrib import admin
|
set_webhook_action.short_description = "Webhook"
|
||||||
from .models import BotReplyTemplate
|
|
||||||
|
|
||||||
@admin.register(BotReplyTemplate)
|
|
||||||
class BotReplyTemplateAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("bot", "trigger", "command_name", "enabled", "priority")
|
|
||||||
list_filter = ("bot", "trigger", "enabled", "parse_mode")
|
|
||||||
search_fields = ("command_name", "text_template", "note")
|
|
||||||
ordering = ("bot", "priority", "id")
|
|
||||||
|
@ -1,9 +0,0 @@
|
|||||||
# pxy_bots/api/urls.py
|
|
||||||
from django.urls import path
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("bots/health/", views.health, name="pxy_bots_health"), # (if you have it)
|
|
||||||
path("bots/echo_render", views.echo_render, name="pxy_bots_echo_render"), # (you already had)
|
|
||||||
path("bots/template_reply", views.template_reply, name="pxy_bots_template_reply"), # <-- NEW
|
|
||||||
]
|
|
@ -1,117 +0,0 @@
|
|||||||
# pxy_bots/api/views.py
|
|
||||||
import json
|
|
||||||
from django.http import JsonResponse, HttpResponse
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
|
|
||||||
def health(request):
|
|
||||||
return JsonResponse({"ok": True, "service": "pxy_bots", "schema_ready": ["req.v1", "render.v1"]})
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
def echo_render(request):
|
|
||||||
try:
|
|
||||||
data = json.loads(request.body.decode("utf-8") or "{}")
|
|
||||||
except Exception:
|
|
||||||
data = {}
|
|
||||||
|
|
||||||
text = (((data.get("input") or {}).get("text")) or "Hola 👋")
|
|
||||||
who = (((data.get("user") or {}).get("id")) or "user")
|
|
||||||
cmd = (((data.get("command") or {}).get("name")) or "none")
|
|
||||||
|
|
||||||
spec = {
|
|
||||||
"schema_version": "render.v1",
|
|
||||||
"messages": [
|
|
||||||
{"type": "text", "text": f"echo: user={who} cmd={cmd}"},
|
|
||||||
{"type": "text", "text": f"you said: {text}"},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
return JsonResponse(spec)
|
|
||||||
|
|
||||||
# pxy_bots/api/views.py
|
|
||||||
import json, string
|
|
||||||
from django.http import JsonResponse, HttpResponseBadRequest
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
|
||||||
from pxy_bots.models import TelegramBot, BotReplyTemplate
|
|
||||||
|
|
||||||
def _ctx_from_req(req):
|
|
||||||
inp = (req.get("input") or {})
|
|
||||||
loc = inp.get("location") or {}
|
|
||||||
args_raw = (inp.get("args_raw") or "") or (inp.get("text") or "") or ""
|
|
||||||
# Strip /cmd if present for ${args}
|
|
||||||
if args_raw.startswith("/"):
|
|
||||||
parts = args_raw.split(" ", 1)
|
|
||||||
args_raw = parts[1] if len(parts) > 1 else ""
|
|
||||||
return {
|
|
||||||
"user_id": ((req.get("user") or {}).get("id")),
|
|
||||||
"chat_id": ((req.get("chat") or {}).get("id")),
|
|
||||||
"cmd": ((req.get("command") or {}).get("name")),
|
|
||||||
"trigger": ((req.get("command") or {}).get("trigger")),
|
|
||||||
"text": (inp.get("text") or ""),
|
|
||||||
"caption": (inp.get("caption") or ""),
|
|
||||||
"args": args_raw,
|
|
||||||
"lat": loc.get("lat"),
|
|
||||||
"lon": loc.get("lon"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _choose_template(bot, trigger, cmd):
|
|
||||||
qs = (BotReplyTemplate.objects
|
|
||||||
.filter(bot=bot, enabled=True, trigger=trigger)
|
|
||||||
.order_by("priority", "id"))
|
|
||||||
t = (qs.filter(command_name=(cmd or None)).first()
|
|
||||||
or qs.filter(command_name__isnull=True).first()
|
|
||||||
or qs.filter(command_name="").first())
|
|
||||||
return t
|
|
||||||
|
|
||||||
@csrf_exempt
|
|
||||||
def template_reply(request):
|
|
||||||
if request.method != "POST":
|
|
||||||
return HttpResponseBadRequest("POST only")
|
|
||||||
try:
|
|
||||||
req = json.loads(request.body.decode("utf-8") or "{}")
|
|
||||||
except Exception:
|
|
||||||
return HttpResponseBadRequest("invalid json")
|
|
||||||
|
|
||||||
bot_name = ((req.get("bot") or {}).get("username"))
|
|
||||||
if not bot_name:
|
|
||||||
return HttpResponseBadRequest("missing bot.username")
|
|
||||||
|
|
||||||
bot = TelegramBot.objects.filter(name=bot_name, is_active=True).first() \
|
|
||||||
or TelegramBot.objects.filter(username=bot_name, is_active=True).first()
|
|
||||||
if not bot:
|
|
||||||
return HttpResponseBadRequest("bot not found")
|
|
||||||
|
|
||||||
trigger = ((req.get("command") or {}).get("trigger")) or "message"
|
|
||||||
cmd = ((req.get("command") or {}).get("name") or "")
|
|
||||||
cmd = cmd.strip().lstrip("/").lower() or None
|
|
||||||
|
|
||||||
tpl = _choose_template(bot, trigger, cmd)
|
|
||||||
if not tpl:
|
|
||||||
# Soft fallback to help you see wiring issues
|
|
||||||
return JsonResponse({
|
|
||||||
"schema_version": "render.v1",
|
|
||||||
"messages": [{"type": "text",
|
|
||||||
"text": f"(no template) bot={bot_name} trigger={trigger} cmd={cmd or '(default)'}"}]
|
|
||||||
})
|
|
||||||
|
|
||||||
ctx = _ctx_from_req(req)
|
|
||||||
text = string.Template(tpl.text_template or "").safe_substitute(**{k:("" if v is None else v) for k,v in ctx.items()})
|
|
||||||
|
|
||||||
msg_list = []
|
|
||||||
if tpl.media_url:
|
|
||||||
# If media present, put text in caption; otherwise plain text message
|
|
||||||
if text.strip():
|
|
||||||
msg_list.append({"type": "photo", "media_url": tpl.media_url,
|
|
||||||
"caption": text, "parse_mode": tpl.parse_mode.upper() if tpl.parse_mode!="none" else None})
|
|
||||||
else:
|
|
||||||
msg_list.append({"type": "photo", "media_url": tpl.media_url})
|
|
||||||
if (not tpl.media_url) and text.strip():
|
|
||||||
msg = {"type": "text", "text": text}
|
|
||||||
if tpl.parse_mode != BotReplyTemplate.PARSE_NONE:
|
|
||||||
msg["parse_mode"] = tpl.parse_mode.upper()
|
|
||||||
msg_list.append(msg)
|
|
||||||
|
|
||||||
spec = {"schema_version": "render.v1", "messages": msg_list}
|
|
||||||
btns = tpl.buttons()
|
|
||||||
if btns:
|
|
||||||
spec["buttons"] = btns
|
|
||||||
return JsonResponse(spec)
|
|
||||||
|
|
@ -1,126 +0,0 @@
|
|||||||
# pxy_bots/canonical.py
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
|
|
||||||
def _pick_photo(sizes):
|
|
||||||
# Telegram sends photos as array of sizes; pick the largest
|
|
||||||
if not sizes:
|
|
||||||
return None
|
|
||||||
sizes = sorted(sizes, key=lambda s: (s.get("width", 0) * s.get("height", 0)), reverse=True)
|
|
||||||
top = sizes[0]
|
|
||||||
return {
|
|
||||||
"type": "photo",
|
|
||||||
"file_id": top.get("file_id"),
|
|
||||||
"mime": "image/jpeg", # Telegram photos are JPEG
|
|
||||||
"size_bytes": None, # Telegram doesn't include bytes here; leave None
|
|
||||||
"width": top.get("width"),
|
|
||||||
"height": top.get("height"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _extract_media(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
if "photo" in msg:
|
|
||||||
return _pick_photo(msg.get("photo") or [])
|
|
||||||
if "voice" in msg:
|
|
||||||
v = msg["voice"]
|
|
||||||
return {"type": "voice", "file_id": v.get("file_id"), "mime": v.get("mime_type"), "size_bytes": v.get("file_size"), "duration": v.get("duration")}
|
|
||||||
if "audio" in msg:
|
|
||||||
a = msg["audio"]
|
|
||||||
return {"type": "audio", "file_id": a.get("file_id"), "mime": a.get("mime_type"), "size_bytes": a.get("file_size"), "duration": a.get("duration")}
|
|
||||||
if "video" in msg:
|
|
||||||
v = msg["video"]
|
|
||||||
return {"type": "video", "file_id": v.get("file_id"), "mime": v.get("mime_type"), "size_bytes": v.get("file_size"), "duration": v.get("duration"), "width": v.get("width"), "height": v.get("height")}
|
|
||||||
if "video_note" in msg:
|
|
||||||
v = msg["video_note"]
|
|
||||||
return {"type": "video_note", "file_id": v.get("file_id"), "mime": None, "size_bytes": v.get("file_size"), "duration": v.get("duration"), "length": v.get("length")}
|
|
||||||
if "animation" in msg:
|
|
||||||
a = msg["animation"]
|
|
||||||
return {"type": "animation", "file_id": a.get("file_id"), "mime": a.get("mime_type"), "size_bytes": a.get("file_size")}
|
|
||||||
if "document" in msg:
|
|
||||||
d = msg["document"]
|
|
||||||
return {"type": "document", "file_id": d.get("file_id"), "mime": d.get("mime_type"), "size_bytes": d.get("file_size"), "file_name": d.get("file_name")}
|
|
||||||
return None
|
|
||||||
|
|
||||||
def build_req_v1(update: Dict[str, Any], bot_name: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Normalize a Telegram update into our canonical req.v1 envelope.
|
|
||||||
Pure function. No network, no state.
|
|
||||||
"""
|
|
||||||
schema_version = "req.v1"
|
|
||||||
update_id = update.get("update_id")
|
|
||||||
|
|
||||||
# Determine primary container: message, edited_message, callback_query
|
|
||||||
msg = update.get("message") or update.get("edited_message")
|
|
||||||
cbq = update.get("callback_query")
|
|
||||||
|
|
||||||
# Chat/user basics
|
|
||||||
if msg:
|
|
||||||
chat = msg.get("chat") or {}
|
|
||||||
user = msg.get("from") or {}
|
|
||||||
message_id = msg.get("message_id")
|
|
||||||
ts = msg.get("date")
|
|
||||||
text = msg.get("text")
|
|
||||||
caption = msg.get("caption")
|
|
||||||
location = msg.get("location")
|
|
||||||
media = _extract_media(msg)
|
|
||||||
trigger = "message"
|
|
||||||
elif cbq:
|
|
||||||
m = cbq.get("message") or {}
|
|
||||||
chat = m.get("chat") or {}
|
|
||||||
user = cbq.get("from") or {}
|
|
||||||
message_id = m.get("message_id")
|
|
||||||
ts = m.get("date") or None
|
|
||||||
text = None
|
|
||||||
caption = None
|
|
||||||
location = None
|
|
||||||
media = None
|
|
||||||
trigger = "callback"
|
|
||||||
else:
|
|
||||||
# Fallback for other update types we haven't mapped yet
|
|
||||||
chat = {}
|
|
||||||
user = update.get("from") or {}
|
|
||||||
message_id = None
|
|
||||||
ts = None
|
|
||||||
text = None
|
|
||||||
caption = None
|
|
||||||
location = None
|
|
||||||
media = None
|
|
||||||
trigger = "unknown"
|
|
||||||
|
|
||||||
# Command name (if text/caption starts with '/')
|
|
||||||
raw_cmd = None
|
|
||||||
if text and isinstance(text, str) and text.startswith("/"):
|
|
||||||
raw_cmd = text.split()[0][1:]
|
|
||||||
elif caption and isinstance(caption, str) and caption.startswith("/"):
|
|
||||||
raw_cmd = caption.split()[0][1:]
|
|
||||||
elif cbq and isinstance(cbq.get("data"), str):
|
|
||||||
raw_cmd = None # callbacks carry 'action' instead
|
|
||||||
|
|
||||||
# Build envelope
|
|
||||||
env = {
|
|
||||||
"schema_version": schema_version,
|
|
||||||
"bot": {"username": bot_name},
|
|
||||||
"chat": {"id": chat.get("id"), "type": chat.get("type")},
|
|
||||||
"user": {"id": user.get("id"), "language": user.get("language_code")},
|
|
||||||
"command": {
|
|
||||||
"name": raw_cmd,
|
|
||||||
"version": 1,
|
|
||||||
"trigger": ("text_command" if raw_cmd and trigger == "message" else ("callback" if trigger == "callback" else trigger)),
|
|
||||||
},
|
|
||||||
"input": {
|
|
||||||
"text": text,
|
|
||||||
"caption": caption,
|
|
||||||
"args_raw": text or caption,
|
|
||||||
"media": media,
|
|
||||||
"location": ({"lat": location.get("latitude"), "lon": location.get("longitude")} if location else None),
|
|
||||||
},
|
|
||||||
"callback": (
|
|
||||||
{"id": cbq.get("id"), "data": cbq.get("data"), "origin": {"message_id": message_id, "chat_id": chat.get("id")}}
|
|
||||||
if cbq else None
|
|
||||||
),
|
|
||||||
"context": {
|
|
||||||
"message_id": message_id,
|
|
||||||
"update_id": update_id,
|
|
||||||
"ts": ts,
|
|
||||||
"idempotency_key": f"tg:{message_id}:{user.get('id')}" if message_id and user.get("id") else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return env
|
|
@ -1,7 +1,7 @@
|
|||||||
from telegram import Update, ForceReply
|
from telegram import Update, ForceReply
|
||||||
import logging
|
import logging
|
||||||
from pxy_openai.assistants import OpenAIAssistant
|
from pxy_openai.assistants import OpenAIAssistant
|
||||||
from pxy_bots.models import TelegramBot
|
from .models import TelegramBot
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
from pxy_langchain.models import AIAssistant
|
from pxy_langchain.models import AIAssistant
|
||||||
from pxy_langchain.services import LangchainAIService
|
from pxy_langchain.services import LangchainAIService
|
||||||
@ -33,26 +33,18 @@ async def help_command(update: Update):
|
|||||||
user = update.effective_user
|
user = update.effective_user
|
||||||
await update.message.reply_text(f"Help! How can I assist you, {user.first_name}?")
|
await update.message.reply_text(f"Help! How can I assist you, {user.first_name}?")
|
||||||
|
|
||||||
|
|
||||||
async def handle_location(update: Update):
|
async def handle_location(update: Update):
|
||||||
"""Respond to a location message."""
|
"""Respond to a location message."""
|
||||||
location = update.message.location
|
location = update.message.location
|
||||||
if location:
|
if location:
|
||||||
lat = location.latitude
|
await update.message.reply_text(
|
||||||
lon = location.longitude
|
f"Thanks for sharing your location! Latitude: {location.latitude}, Longitude: {location.longitude}"
|
||||||
text = (
|
)
|
||||||
"🚀 Your Digital Twin is ready!\n\n"
|
|
||||||
f"🌍 Latitude: {lat}\n"
|
|
||||||
f"🌍 Longitude: {lon}\n\n"
|
|
||||||
"🔗 Access it here:\n"
|
|
||||||
f"https://app.polisplexity.tech/city/digital/twin/osm_city/?lat={lat}&long={lon}&scale=0.1\n\n"
|
|
||||||
"Thanks for sharing your location!")
|
|
||||||
await update.message.reply_text(text)
|
|
||||||
else:
|
else:
|
||||||
await update.message.reply_text("Please share your location.")
|
await update.message.reply_text("Please share your location.")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
async def respond(update, bot_name):
|
async def respond(update, bot_name):
|
||||||
"""Respond to user messages using the LangChain AI service."""
|
"""Respond to user messages using the LangChain AI service."""
|
||||||
try:
|
try:
|
@ -1,11 +0,0 @@
|
|||||||
from .common import start, help_command, handle_location, respond
|
|
||||||
from .handlers_citizen import next_truck, report_trash, private_pickup, green_balance
|
|
||||||
from .handlers_city import next_route, complete_stop, missed_stop, my_eco_score as city_eco_score
|
|
||||||
from .handlers_private import available_jobs, accept_job, next_pickup, complete_pickup, my_eco_score as private_eco_score
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"start", "help_command", "handle_location", "respond",
|
|
||||||
"next_truck", "report_trash", "private_pickup", "green_balance",
|
|
||||||
"next_route", "complete_stop", "missed_stop", "city_eco_score",
|
|
||||||
"available_jobs", "accept_job", "next_pickup", "complete_pickup", "private_eco_score"
|
|
||||||
]
|
|
@ -1,63 +0,0 @@
|
|||||||
from telegram import Update, KeyboardButton, ReplyKeyboardMarkup
|
|
||||||
|
|
||||||
async def next_truck(update: Update):
|
|
||||||
if update.message.location:
|
|
||||||
lat, lon = update.message.location.latitude, update.message.location.longitude
|
|
||||||
await update.message.reply_text(
|
|
||||||
"🎮 *Modo Ruta Activado*\n"
|
|
||||||
"🏟️ Estadio Banorte — punto de control fijado.\n"
|
|
||||||
f"📍 Coordenadas: ({lat:.5f}, {lon:.5f})\n"
|
|
||||||
"🚛 Próximo camión para tu zona aparece *mañana a las 08:00 AM*.\n"
|
|
||||||
"💡 Tip: Mantén el área limpia para sumar Monedas Verdes.",
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
keyboard = [[KeyboardButton("📍 Enviar ubicación", request_location=True)]]
|
|
||||||
markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True, one_time_keyboard=True)
|
|
||||||
await update.message.reply_text(
|
|
||||||
"🏟️ *Estadio Banorte*\n"
|
|
||||||
"Para activar el *radar de camiones*, comparte tu ubicación dentro del estadio.\n"
|
|
||||||
"Toca el botón de abajo para fijar tu base.",
|
|
||||||
reply_markup=markup,
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def report_trash(update: Update):
|
|
||||||
user_text = update.message.text
|
|
||||||
await update.message.reply_text(
|
|
||||||
"🧹 *Misión registrada*\n"
|
|
||||||
f"“{user_text}”\n"
|
|
||||||
"🌱 CO₂ evitado estimado: *1.6 kg*\n"
|
|
||||||
"🪙 Recompensa: *+5 Monedas Verdes*\n"
|
|
||||||
"🎯 Racha activa: ¡sigue reportando para subir de nivel en Estadio Banorte!",
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def private_pickup(update: Update):
|
|
||||||
if update.message.location:
|
|
||||||
await update.message.reply_text(
|
|
||||||
"🛵 *Reco Privado invocado*\n"
|
|
||||||
"NPC *Pepe (la motito)* aceptó la misión.\n"
|
|
||||||
"⏱️ ETA: *10 min*\n"
|
|
||||||
"📌 Dirígete al punto indicado y prepara el material. ¡Buen trabajo, asistente!",
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
keyboard = [[KeyboardButton("📍 Enviar ubicación", request_location=True)]]
|
|
||||||
markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True, one_time_keyboard=True)
|
|
||||||
await update.message.reply_text(
|
|
||||||
"🎯 Para solicitar *Reco Privado* dentro del *Estadio Banorte*, comparte tu ubicación.\n"
|
|
||||||
"Usa el botón de abajo para fijar tu punto.",
|
|
||||||
reply_markup=markup,
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def green_balance(update: Update):
|
|
||||||
await update.message.reply_text(
|
|
||||||
"📊 *Panel de Progreso — Estadio Banorte*\n"
|
|
||||||
"🪙 Monedas Verdes: *23*\n"
|
|
||||||
"🌱 CO₂ evitado: *15 kg*\n"
|
|
||||||
"🏅 Rango: *Asistente Bronce*\n"
|
|
||||||
"➡️ Completa misiones (reportes y entregas) para subir de rango.",
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
@ -1,42 +0,0 @@
|
|||||||
from telegram import Update
|
|
||||||
|
|
||||||
async def next_route(update: Update):
|
|
||||||
await update.message.reply_text(
|
|
||||||
"🎮 *Modo Circuito Activo*\n"
|
|
||||||
"🏟️ *Estadio Banorte*\n"
|
|
||||||
"Tu circuito de hoy:\n"
|
|
||||||
"• Anillo *Concourse Norte* (Puertas 1–4)\n"
|
|
||||||
"• Lateral *Oriente* — Secciones 112–120\n"
|
|
||||||
"• Cabecera *Sur* — Zona Eco (TX-05, TX-06)\n"
|
|
||||||
"\n"
|
|
||||||
"Marca cada punto del circuito cuando llegues.\n"
|
|
||||||
"Comandos rápidos: ✅ */complete_stop* · 🚧 */missed_stop*",
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def complete_stop(update: Update):
|
|
||||||
await update.message.reply_text(
|
|
||||||
"✅ *Punto asegurado*\n"
|
|
||||||
"Se registró la recolección en la bitácora del *Estadio Banorte*.\n"
|
|
||||||
"🪙 Recompensa: *+2 Monedas Verdes*\n"
|
|
||||||
"🔥 Racha +1 — ¡sigue así, asistente!",
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def missed_stop(update: Update):
|
|
||||||
await update.message.reply_text(
|
|
||||||
"🚧 *Punto no recolectado*\n"
|
|
||||||
"Abrí una alerta leve para replanificar el circuito en el *gemelo digital*.\n"
|
|
||||||
"💡 Sugerencia: anexa foto y motivo en el reporte para conservar tu racha.",
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def my_eco_score(update: Update):
|
|
||||||
await update.message.reply_text(
|
|
||||||
"📊 *Panel de Progreso — Estadio Banorte*\n"
|
|
||||||
"🪙 Monedas Verdes (mes): *120*\n"
|
|
||||||
"🌱 CO₂ evitado (estimado): *32 kg*\n"
|
|
||||||
"🏅 Rango: *Asistente Plata*\n"
|
|
||||||
"🎯 Tip: completa 5 puntos seguidos para activar el *Boost de Eficiencia*.",
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
@ -1,49 +0,0 @@
|
|||||||
from telegram import Update
|
|
||||||
|
|
||||||
async def available_jobs(update: Update):
|
|
||||||
await update.message.reply_text(
|
|
||||||
"🎮 *Misiones disponibles — Estadio Banorte*\n"
|
|
||||||
"1) ♻️ *BIN-112* · Concourse *Norte* (Puerta 3) — Orgánico\n"
|
|
||||||
"2) 🧴 *ECO-TX06* · Cabecera *Sur* — Punto Eco (plásticos/papel)\n"
|
|
||||||
"\n"
|
|
||||||
"Usa */accept_job* para tomar la primera misión disponible.",
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def accept_job(update: Update):
|
|
||||||
await update.message.reply_text(
|
|
||||||
"👌 *Misión aceptada*: *BIN-112*\n"
|
|
||||||
"📍 Dirígete a *Concourse Norte (Puerta 3)*\n"
|
|
||||||
"⏱️ ETA: *4 min a pie*\n"
|
|
||||||
"🎯 Objetivo: vacía contenedor orgánico y verifica tapa/bolsas.\n"
|
|
||||||
"Cuando termines, ejecuta */complete_pickup*.",
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def next_pickup(update: Update):
|
|
||||||
await update.message.reply_text(
|
|
||||||
"➡️ *Siguiente objetivo*: *ECO-TX06*\n"
|
|
||||||
"🏟️ Ubicación: *Cabecera Sur — Zona Eco*\n"
|
|
||||||
"📦 Material esperado: plásticos y papel (bolsas azules)\n"
|
|
||||||
"💡 Tip: registra foto si ves rebose para sumar Monedas extra.",
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def complete_pickup(update: Update):
|
|
||||||
await update.message.reply_text(
|
|
||||||
"✅ *Recolección completada*\n"
|
|
||||||
"🪙 Recompensa: *+3 Monedas Verdes*\n"
|
|
||||||
"🌱 CO₂ evitado (estimado): *0.9 kg*\n"
|
|
||||||
"🔥 Racha +1 — usa */next_pickup* para continuar el circuito.",
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
||||||
|
|
||||||
async def my_eco_score(update: Update):
|
|
||||||
await update.message.reply_text(
|
|
||||||
"🏆 *Progreso — Estadio Banorte*\n"
|
|
||||||
"🪙 Monedas Verdes (mes): *45*\n"
|
|
||||||
"🌱 CO₂ evitado (estimado): *12 kg*\n"
|
|
||||||
"🏅 Rango: *Asistente Bronce*\n"
|
|
||||||
"🎯 Objetivo del día: completa 3 misiones seguidas sin fallos para activar *Boost de Eficiencia*.",
|
|
||||||
parse_mode="Markdown",
|
|
||||||
)
|
|
@ -1,34 +0,0 @@
|
|||||||
# Generated by Django 5.0.3 on 2025-05-20 08:04
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('pxy_bots', '0005_remove_telegrambot_assistant_id_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='TelegramConversation',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('user_id', models.CharField(max_length=64)),
|
|
||||||
('started_at', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='conversations', to='pxy_bots.telegrambot')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='TelegramMessage',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('direction', models.CharField(choices=[('in', 'In'), ('out', 'Out')], max_length=4)),
|
|
||||||
('content', models.TextField()),
|
|
||||||
('timestamp', models.DateTimeField(auto_now_add=True)),
|
|
||||||
('response_time_ms', models.IntegerField(blank=True, null=True)),
|
|
||||||
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='pxy_bots.telegramconversation')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,89 +0,0 @@
|
|||||||
# Generated by Django 5.0.3 on 2025-09-17 03:49
|
|
||||||
|
|
||||||
import django.core.validators
|
|
||||||
import django.db.models.deletion
|
|
||||||
import django.utils.timezone
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('pxy_bots', '0006_telegramconversation_telegrammessage'),
|
|
||||||
('pxy_langchain', '0003_aiassistant_neo4j_profile_aiassistant_uses_graph'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Connection',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('name', models.CharField(max_length=120, unique=True)),
|
|
||||||
('base_url', models.CharField(help_text='e.g. https://api.example.com', max_length=500)),
|
|
||||||
('path_default', models.CharField(blank=True, default='', help_text='Optional default path, e.g. /bots/route', max_length=300)),
|
|
||||||
('auth_type', models.CharField(choices=[('none', 'None'), ('bearer', 'Bearer token'), ('api_key', 'API key (in header)'), ('basic', 'Basic user:pass')], default='none', max_length=20)),
|
|
||||||
('auth_value', models.CharField(blank=True, default='', help_text='token | key | user:pass', max_length=500)),
|
|
||||||
('headers_json', models.TextField(blank=True, default='', help_text='Extra headers as JSON object')),
|
|
||||||
('timeout_s', models.PositiveIntegerField(default=4, validators=[django.core.validators.MinValueValidator(1)])),
|
|
||||||
('allowed_hosts', models.CharField(blank=True, default='127.0.0.1,localhost,app.polisplexity.tech', help_text='Comma-separated host allowlist for safety.', max_length=800)),
|
|
||||||
('is_active', models.BooleanField(default=True)),
|
|
||||||
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['name'],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='telegrambot',
|
|
||||||
options={'ordering': ['name']},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='telegramconversation',
|
|
||||||
options={'ordering': ['-started_at']},
|
|
||||||
),
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='telegrammessage',
|
|
||||||
options={'ordering': ['timestamp', 'id']},
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='telegrambot',
|
|
||||||
name='created_at',
|
|
||||||
field=models.DateTimeField(default=django.utils.timezone.now, editable=False),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='telegrambot',
|
|
||||||
name='updated_at',
|
|
||||||
field=models.DateTimeField(auto_now=True),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='telegrambot',
|
|
||||||
name='assistant',
|
|
||||||
field=models.ForeignKey(help_text='LangChain AI assistant associated with this bot.', on_delete=django.db.models.deletion.CASCADE, related_name='telegram_bots', to='pxy_langchain.aiassistant'),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='telegrambot',
|
|
||||||
name='is_active',
|
|
||||||
field=models.BooleanField(default=True, help_text='If off, webhook can be refused.'),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='CommandRoute',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('trigger', models.CharField(choices=[('message', 'Message (no command)'), ('text_command', 'Text command (/cmd)'), ('callback', 'Callback')], default='message', max_length=20)),
|
|
||||||
('command_name', models.CharField(blank=True, help_text="Without leading '/'. Leave blank for default of that trigger.", max_length=80, null=True)),
|
|
||||||
('path', models.CharField(blank=True, default='', help_text='Overrides connection.path_default if set', max_length=300)),
|
|
||||||
('enabled', models.BooleanField(default=True)),
|
|
||||||
('priority', models.PositiveIntegerField(default=100, help_text='Lower runs first')),
|
|
||||||
('note', models.CharField(blank=True, default='', max_length=240)),
|
|
||||||
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='routes', to='pxy_bots.telegrambot')),
|
|
||||||
('connection', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='routes', to='pxy_bots.connection')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['priority', 'id'],
|
|
||||||
'indexes': [models.Index(fields=['bot', 'trigger', 'command_name', 'enabled', 'priority'], name='pxy_bots_co_bot_id_fd0f4c_idx')],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
@ -1,38 +0,0 @@
|
|||||||
# Generated by Django 5.0.3 on 2025-09-17 06:20
|
|
||||||
|
|
||||||
import django.core.validators
|
|
||||||
import django.db.models.deletion
|
|
||||||
import django.utils.timezone
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('pxy_bots', '0007_connection_alter_telegrambot_options_and_more'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='BotReplyTemplate',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('trigger', models.CharField(choices=[('message', 'Message (no command)'), ('text_command', 'Text command (/cmd)'), ('callback', 'Callback')], default='message', max_length=20)),
|
|
||||||
('command_name', models.CharField(blank=True, help_text="Without leading '/'. Blank = default for this trigger.", max_length=80, null=True)),
|
|
||||||
('text_template', models.TextField(blank=True, default='', help_text='Use ${args}, ${user_id}, ${lat}, ${lon}, etc.')),
|
|
||||||
('parse_mode', models.CharField(choices=[('none', 'None'), ('markdown', 'Markdown'), ('html', 'HTML')], default='markdown', max_length=20)),
|
|
||||||
('media_url', models.CharField(blank=True, default='', help_text='Optional image/video URL', max_length=600)),
|
|
||||||
('buttons_json', models.TextField(blank=True, default='', help_text='Optional JSON: [{"label":"Open","kind":"open_url","url":"https://..."}]')),
|
|
||||||
('enabled', models.BooleanField(default=True)),
|
|
||||||
('priority', models.PositiveIntegerField(default=100, help_text='Lower runs first', validators=[django.core.validators.MinValueValidator(1)])),
|
|
||||||
('note', models.CharField(blank=True, default='', max_length=240)),
|
|
||||||
('created_at', models.DateTimeField(default=django.utils.timezone.now, editable=False)),
|
|
||||||
('updated_at', models.DateTimeField(auto_now=True)),
|
|
||||||
('bot', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reply_templates', to='pxy_bots.telegrambot')),
|
|
||||||
],
|
|
||||||
options={
|
|
||||||
'ordering': ['priority', 'id'],
|
|
||||||
'indexes': [models.Index(fields=['bot', 'trigger', 'command_name', 'enabled', 'priority'], name='pxy_bots_bo_bot_id_19f2c3_idx')],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
BIN
pxy_bots/migrations/__pycache__/0001_initial.cpython-310.pyc
Normal file
BIN
pxy_bots/migrations/__pycache__/0001_initial.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
pxy_bots/migrations/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
pxy_bots/migrations/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
@ -1,247 +1,47 @@
|
|||||||
import json
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.core.validators import MinValueValidator
|
from pxy_langchain.models import AIAssistant # Now referencing LangChain AI assistants
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from pxy_langchain.models import AIAssistant # LangChain assistant
|
|
||||||
|
|
||||||
|
|
||||||
# Telegram bot + simple conversation log
|
|
||||||
class TelegramBot(models.Model):
|
class TelegramBot(models.Model):
|
||||||
|
"""
|
||||||
|
Represents a Telegram bot that interacts with users using a LangChain AI assistant.
|
||||||
|
"""
|
||||||
name = models.CharField(max_length=50, unique=True, help_text="Bot name (e.g., 'SupportBot').")
|
name = models.CharField(max_length=50, unique=True, help_text="Bot name (e.g., 'SupportBot').")
|
||||||
username = models.CharField(max_length=50, unique=True, help_text="Bot username (e.g., 'SupportBot').")
|
username = models.CharField(max_length=50, unique=True, help_text="Bot username (e.g., 'SupportBot').")
|
||||||
token = models.CharField(max_length=200, unique=True, help_text="Telegram bot token.")
|
token = models.CharField(max_length=200, unique=True, help_text="Telegram bot token.")
|
||||||
is_active = models.BooleanField(default=True, help_text="If off, webhook can be refused.")
|
is_active = models.BooleanField(default=True, help_text="Indicates if this bot is active.")
|
||||||
assistant = models.ForeignKey(
|
assistant = models.ForeignKey(
|
||||||
AIAssistant,
|
AIAssistant,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
related_name="telegram_bots",
|
related_name="telegram_bots",
|
||||||
help_text="LangChain AI assistant associated with this bot.",
|
help_text="The LangChain AI assistant associated with this Telegram bot.",
|
||||||
)
|
)
|
||||||
|
|
||||||
created_at = models.DateTimeField(default=timezone.now, editable=False)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["name"]
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} (@{self.username})"
|
return f"{self.name} (@{self.username})"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_bot_token(bot_name: str) -> str:
|
def get_bot_token(bot_name):
|
||||||
|
"""Retrieve the token for the given bot name."""
|
||||||
try:
|
try:
|
||||||
bot = TelegramBot.objects.get(name=bot_name, is_active=True)
|
bot = TelegramBot.objects.get(name=bot_name, is_active=True)
|
||||||
return bot.token
|
return bot.token
|
||||||
except TelegramBot.DoesNotExist:
|
except TelegramBot.DoesNotExist:
|
||||||
raise ValueError(f"Bot with name '{bot_name}' not found or inactive.")
|
raise ValueError(f"Bot with name '{bot_name}' not found or inactive.")
|
||||||
|
|
||||||
def set_webhook(self, base_url: str) -> dict:
|
def set_webhook(self, base_url):
|
||||||
|
"""
|
||||||
|
Set the webhook for this bot dynamically based on the server's base URL.
|
||||||
|
"""
|
||||||
if not self.is_active:
|
if not self.is_active:
|
||||||
raise ValueError(f"Bot '{self.name}' is inactive. Activate it before setting the webhook.")
|
raise ValueError(f"Bot '{self.name}' is inactive. Activate it before setting the webhook.")
|
||||||
webhook_url = f"{base_url.rstrip('/')}/bots/webhook/{self.name}/"
|
|
||||||
resp = requests.post(
|
webhook_url = f"{base_url}/bots/webhook/{self.name}/"
|
||||||
|
response = requests.post(
|
||||||
f"https://api.telegram.org/bot{self.token}/setWebhook",
|
f"https://api.telegram.org/bot{self.token}/setWebhook",
|
||||||
data={"url": webhook_url},
|
data={"url": webhook_url}
|
||||||
timeout=5,
|
|
||||||
)
|
)
|
||||||
resp.raise_for_status()
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
class TelegramConversation(models.Model):
|
return response.json()
|
||||||
bot = models.ForeignKey(TelegramBot, on_delete=models.CASCADE, related_name='conversations')
|
else:
|
||||||
user_id = models.CharField(max_length=64)
|
raise ValueError(f"Failed to set webhook for {self.name}: {response.json()}")
|
||||||
started_at = models.DateTimeField(auto_now_add=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["-started_at"]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.user_id} @ {self.started_at:%Y-%m-%d %H:%M}"
|
|
||||||
|
|
||||||
|
|
||||||
class TelegramMessage(models.Model):
|
|
||||||
IN = 'in'
|
|
||||||
OUT = 'out'
|
|
||||||
DIRECTION_CHOICES = [(IN, 'In'), (OUT, 'Out')]
|
|
||||||
|
|
||||||
conversation = models.ForeignKey(TelegramConversation, on_delete=models.CASCADE, related_name='messages')
|
|
||||||
direction = models.CharField(max_length=4, choices=DIRECTION_CHOICES)
|
|
||||||
content = models.TextField()
|
|
||||||
timestamp = models.DateTimeField(auto_now_add=True)
|
|
||||||
response_time_ms = models.IntegerField(null=True, blank=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["timestamp", "id"]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"[{self.direction}] {self.content[:30]}…"
|
|
||||||
|
|
||||||
|
|
||||||
# Configurable routing (Admin-driven)
|
|
||||||
class Connection(models.Model):
|
|
||||||
AUTH_NONE = "none"
|
|
||||||
AUTH_BEARER = "bearer"
|
|
||||||
AUTH_API_KEY = "api_key"
|
|
||||||
AUTH_BASIC = "basic"
|
|
||||||
AUTH_CHOICES = [
|
|
||||||
(AUTH_NONE, "None"),
|
|
||||||
(AUTH_BEARER, "Bearer token"),
|
|
||||||
(AUTH_API_KEY, "API key (in header)"),
|
|
||||||
(AUTH_BASIC, "Basic user:pass"),
|
|
||||||
]
|
|
||||||
|
|
||||||
name = models.CharField(max_length=120, unique=True)
|
|
||||||
base_url = models.CharField(max_length=500, help_text="e.g. https://api.example.com")
|
|
||||||
path_default = models.CharField(max_length=300, blank=True, default="", help_text="Optional default path, e.g. /bots/route")
|
|
||||||
|
|
||||||
auth_type = models.CharField(max_length=20, choices=AUTH_CHOICES, default=AUTH_NONE)
|
|
||||||
auth_value = models.CharField(max_length=500, blank=True, default="", help_text="token | key | user:pass")
|
|
||||||
headers_json = models.TextField(blank=True, default="", help_text='Extra headers as JSON object')
|
|
||||||
|
|
||||||
timeout_s = models.PositiveIntegerField(default=4, validators=[MinValueValidator(1)])
|
|
||||||
allowed_hosts = models.CharField(
|
|
||||||
max_length=800,
|
|
||||||
blank=True,
|
|
||||||
default="127.0.0.1,localhost,app.polisplexity.tech",
|
|
||||||
help_text="Comma-separated host allowlist for safety."
|
|
||||||
)
|
|
||||||
is_active = models.BooleanField(default=True)
|
|
||||||
|
|
||||||
created_at = models.DateTimeField(default=timezone.now, editable=False)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["name"]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.name
|
|
||||||
|
|
||||||
def allowed_host_set(self):
|
|
||||||
return {h.strip().lower() for h in (self.allowed_hosts or "").split(",") if h.strip()}
|
|
||||||
|
|
||||||
def extra_headers(self):
|
|
||||||
if not self.headers_json:
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
obj = json.loads(self.headers_json)
|
|
||||||
return obj if isinstance(obj, dict) else {}
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def auth_headers(self):
|
|
||||||
h = {}
|
|
||||||
if self.auth_type == self.AUTH_BEARER and self.auth_value:
|
|
||||||
h["Authorization"] = f"Bearer {self.auth_value}"
|
|
||||||
elif self.auth_type == self.AUTH_API_KEY and self.auth_value:
|
|
||||||
h["X-API-Key"] = self.auth_value # convention; adjust if needed
|
|
||||||
elif self.auth_type == self.AUTH_BASIC and self.auth_value:
|
|
||||||
h["Authorization"] = f"Basic {self.auth_value}" # store user:pass
|
|
||||||
return h
|
|
||||||
|
|
||||||
|
|
||||||
class CommandRoute(models.Model):
|
|
||||||
TRIG_MESSAGE = "message"
|
|
||||||
TRIG_TEXTCMD = "text_command"
|
|
||||||
TRIG_CALLBACK = "callback"
|
|
||||||
TRIGGER_CHOICES = [
|
|
||||||
(TRIG_MESSAGE, "Message (no command)"),
|
|
||||||
(TRIG_TEXTCMD, "Text command (/cmd)"),
|
|
||||||
(TRIG_CALLBACK, "Callback"),
|
|
||||||
]
|
|
||||||
|
|
||||||
bot = models.ForeignKey("pxy_bots.TelegramBot", on_delete=models.CASCADE, related_name="routes")
|
|
||||||
trigger = models.CharField(max_length=20, choices=TRIGGER_CHOICES, default=TRIG_MESSAGE)
|
|
||||||
command_name = models.CharField(
|
|
||||||
max_length=80, blank=True, null=True,
|
|
||||||
help_text="Without leading '/'. Leave blank for default of that trigger."
|
|
||||||
)
|
|
||||||
connection = models.ForeignKey(Connection, on_delete=models.PROTECT, related_name="routes")
|
|
||||||
path = models.CharField(max_length=300, blank=True, default="", help_text="Overrides connection.path_default if set")
|
|
||||||
enabled = models.BooleanField(default=True)
|
|
||||||
priority = models.PositiveIntegerField(default=100, help_text="Lower runs first")
|
|
||||||
note = models.CharField(max_length=240, blank=True, default="")
|
|
||||||
|
|
||||||
created_at = models.DateTimeField(default=timezone.now, editable=False)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["priority", "id"]
|
|
||||||
indexes = [
|
|
||||||
models.Index(fields=["bot", "trigger", "command_name", "enabled", "priority"]),
|
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
cmd = self.command_name or "(default)"
|
|
||||||
return f"{self.bot.name} · {self.trigger} · {cmd} → {self.connection.name}"
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
if self.command_name:
|
|
||||||
self.command_name = self.command_name.strip().lstrip("/").lower()
|
|
||||||
|
|
||||||
# --- Admin-configurable reply templates --------------------------------------
|
|
||||||
from django.core.validators import MinValueValidator
|
|
||||||
from django.utils import timezone
|
|
||||||
import json
|
|
||||||
|
|
||||||
class BotReplyTemplate(models.Model):
|
|
||||||
PARSE_NONE = "none"
|
|
||||||
PARSE_MD = "markdown"
|
|
||||||
PARSE_HTML = "html"
|
|
||||||
PARSE_CHOICES = [
|
|
||||||
(PARSE_NONE, "None"),
|
|
||||||
(PARSE_MD, "Markdown"),
|
|
||||||
(PARSE_HTML, "HTML"),
|
|
||||||
]
|
|
||||||
|
|
||||||
TRIG_MESSAGE = "message"
|
|
||||||
TRIG_TEXTCMD = "text_command"
|
|
||||||
TRIG_CALLBACK = "callback"
|
|
||||||
TRIGGER_CHOICES = [
|
|
||||||
(TRIG_MESSAGE, "Message (no command)"),
|
|
||||||
(TRIG_TEXTCMD, "Text command (/cmd)"),
|
|
||||||
(TRIG_CALLBACK, "Callback"),
|
|
||||||
]
|
|
||||||
|
|
||||||
bot = models.ForeignKey("pxy_bots.TelegramBot", on_delete=models.CASCADE, related_name="reply_templates")
|
|
||||||
trigger = models.CharField(max_length=20, choices=TRIGGER_CHOICES, default=TRIG_MESSAGE)
|
|
||||||
command_name = models.CharField(max_length=80, blank=True, null=True,
|
|
||||||
help_text="Without leading '/'. Blank = default for this trigger.")
|
|
||||||
# Content
|
|
||||||
text_template = models.TextField(blank=True, default="", help_text="Use ${args}, ${user_id}, ${lat}, ${lon}, etc.")
|
|
||||||
parse_mode = models.CharField(max_length=20, choices=PARSE_CHOICES, default=PARSE_MD)
|
|
||||||
media_url = models.CharField(max_length=600, blank=True, default="", help_text="Optional image/video URL")
|
|
||||||
buttons_json = models.TextField(blank=True, default="",
|
|
||||||
help_text='Optional JSON: [{"label":"Open","kind":"open_url","url":"https://..."}]')
|
|
||||||
|
|
||||||
enabled = models.BooleanField(default=True)
|
|
||||||
priority = models.PositiveIntegerField(default=100, validators=[MinValueValidator(1)], help_text="Lower runs first")
|
|
||||||
note = models.CharField(max_length=240, blank=True, default="")
|
|
||||||
|
|
||||||
created_at = models.DateTimeField(default=timezone.now, editable=False)
|
|
||||||
updated_at = models.DateTimeField(auto_now=True)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
ordering = ["priority", "id"]
|
|
||||||
indexes = [models.Index(fields=["bot", "trigger", "command_name", "enabled", "priority"])]
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
cmd = self.command_name or "(default)"
|
|
||||||
return f"{self.bot.name} · {self.trigger} · {cmd}"
|
|
||||||
|
|
||||||
def clean(self):
|
|
||||||
if self.command_name:
|
|
||||||
self.command_name = self.command_name.strip().lstrip("/").lower()
|
|
||||||
|
|
||||||
# helpers
|
|
||||||
def buttons(self):
|
|
||||||
if not self.buttons_json:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
obj = json.loads(self.buttons_json)
|
|
||||||
return obj if isinstance(obj, list) else None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
@ -1,165 +0,0 @@
|
|||||||
# pxy_bots/renderer.py
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import Dict, List, Optional
|
|
||||||
|
|
||||||
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Bot, Message
|
|
||||||
from telegram.error import TelegramError
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_keyboard(buttons: Optional[List[dict]]) -> Optional[InlineKeyboardMarkup]:
|
|
||||||
"""
|
|
||||||
Build an InlineKeyboardMarkup from a list of button specs.
|
|
||||||
|
|
||||||
Supported kinds:
|
|
||||||
- open_url: {"label":"...", "kind":"open_url", "url":"https://..."}
|
|
||||||
- callback_api: {"label":"...", "kind":"callback_api", "action":"rerun",
|
|
||||||
"params": {...}, "state_token":"..."}
|
|
||||||
"""
|
|
||||||
if not buttons:
|
|
||||||
return None
|
|
||||||
|
|
||||||
rows: List[List[InlineKeyboardButton]] = []
|
|
||||||
current_row: List[InlineKeyboardButton] = []
|
|
||||||
|
|
||||||
for b in buttons:
|
|
||||||
kind = (b.get("kind") or "").lower()
|
|
||||||
label = b.get("label") or "…"
|
|
||||||
|
|
||||||
if kind == "open_url":
|
|
||||||
url = b.get("url")
|
|
||||||
if not url:
|
|
||||||
logger.warning("renderer: open_url without url; skipping")
|
|
||||||
continue
|
|
||||||
btn = InlineKeyboardButton(text=label, url=url)
|
|
||||||
|
|
||||||
elif kind == "callback_api":
|
|
||||||
# Keep callback_data tiny; prefer server-issued state_token.
|
|
||||||
if b.get("state_token"):
|
|
||||||
data = {"t": b["state_token"]} # compact key
|
|
||||||
else:
|
|
||||||
data = {"a": b.get("action"), "p": b.get("params") or {}}
|
|
||||||
try:
|
|
||||||
payload = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
|
||||||
except Exception:
|
|
||||||
payload = '{"e":"bad"}'
|
|
||||||
# Telegram recommends <=64 bytes for callback_data
|
|
||||||
if len(payload.encode("utf-8")) > 64:
|
|
||||||
logger.warning("renderer: callback_data too long (%sB); trimming",
|
|
||||||
len(payload.encode("utf-8")))
|
|
||||||
payload = payload.encode("utf-8")[:64].decode("utf-8", errors="ignore")
|
|
||||||
btn = InlineKeyboardButton(text=label, callback_data=payload)
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.warning("renderer: unknown button kind=%s; skipping", kind)
|
|
||||||
continue
|
|
||||||
|
|
||||||
current_row.append(btn)
|
|
||||||
if len(current_row) >= 3:
|
|
||||||
rows.append(current_row)
|
|
||||||
current_row = []
|
|
||||||
|
|
||||||
if current_row:
|
|
||||||
rows.append(current_row)
|
|
||||||
|
|
||||||
return InlineKeyboardMarkup(rows) if rows else None
|
|
||||||
|
|
||||||
|
|
||||||
async def render_spec(*, bot: Bot, chat_id: int, spec: Dict) -> List[Message]:
|
|
||||||
"""
|
|
||||||
Send messages according to a render_spec:
|
|
||||||
{
|
|
||||||
"messages": [
|
|
||||||
{"type":"text","text":"..."},
|
|
||||||
{"type":"photo","media_url":"https://...","caption":"..."}
|
|
||||||
],
|
|
||||||
"buttons":[ ... ], # optional top-level buttons (attached to the LAST message)
|
|
||||||
"telemetry": {"run_id":"...", "cache_ttl_s":600},
|
|
||||||
"schema_version": "render.v1"
|
|
||||||
}
|
|
||||||
|
|
||||||
Returns the list of telegram.Message objects sent.
|
|
||||||
"""
|
|
||||||
msgs = spec.get("messages") or []
|
|
||||||
top_buttons = spec.get("buttons") or None
|
|
||||||
sent: List[Message] = []
|
|
||||||
|
|
||||||
for i, m in enumerate(msgs):
|
|
||||||
mtype = (m.get("type") or "text").lower()
|
|
||||||
kb = _build_keyboard(m.get("buttons") or (top_buttons if i == len(msgs) - 1 else None))
|
|
||||||
|
|
||||||
try:
|
|
||||||
if mtype == "text":
|
|
||||||
text = m.get("text") or ""
|
|
||||||
msg = await bot.send_message(chat_id=chat_id, text=text, reply_markup=kb)
|
|
||||||
sent.append(msg)
|
|
||||||
|
|
||||||
elif mtype == "photo":
|
|
||||||
file_id = m.get("file_id")
|
|
||||||
media_url = m.get("media_url")
|
|
||||||
caption = m.get("caption") or None
|
|
||||||
if not (file_id or media_url):
|
|
||||||
logger.warning("renderer: photo without file_id/media_url; skipping")
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
msg = await bot.send_photo(chat_id=chat_id,
|
|
||||||
photo=file_id or media_url,
|
|
||||||
caption=caption,
|
|
||||||
reply_markup=kb)
|
|
||||||
sent.append(msg)
|
|
||||||
except TelegramError as te:
|
|
||||||
# Typical: "BadRequest: Wrong type of the web page content"
|
|
||||||
logger.exception("renderer.photo_error send_photo err=%s url=%s", te, media_url)
|
|
||||||
# Fallback 1: try as document (Telegram is more permissive)
|
|
||||||
try:
|
|
||||||
msg = await bot.send_document(chat_id=chat_id,
|
|
||||||
document=file_id or media_url,
|
|
||||||
caption=caption,
|
|
||||||
reply_markup=kb)
|
|
||||||
sent.append(msg)
|
|
||||||
except TelegramError as te2:
|
|
||||||
logger.exception("renderer.photo_fallback_doc_error err=%s url=%s", te2, media_url)
|
|
||||||
# Fallback 2: plain text with link
|
|
||||||
fallback_text = (caption + "\n" if caption else "") + (media_url or "")
|
|
||||||
if fallback_text.strip():
|
|
||||||
try:
|
|
||||||
msg = await bot.send_message(chat_id=chat_id,
|
|
||||||
text=fallback_text,
|
|
||||||
reply_markup=kb)
|
|
||||||
sent.append(msg)
|
|
||||||
except TelegramError as te3:
|
|
||||||
logger.exception("renderer.photo_fallback_text_error err=%s url=%s", te3, media_url)
|
|
||||||
|
|
||||||
elif mtype == "document":
|
|
||||||
file_id = m.get("file_id")
|
|
||||||
media_url = m.get("media_url")
|
|
||||||
caption = m.get("caption") or None
|
|
||||||
if not (file_id or media_url):
|
|
||||||
logger.warning("renderer: document without file_id/media_url; skipping")
|
|
||||||
continue
|
|
||||||
msg = await bot.send_document(chat_id=chat_id, document=file_id or media_url,
|
|
||||||
caption=caption, reply_markup=kb)
|
|
||||||
sent.append(msg)
|
|
||||||
|
|
||||||
elif mtype == "video":
|
|
||||||
file_id = m.get("file_id")
|
|
||||||
media_url = m.get("media_url")
|
|
||||||
caption = m.get("caption") or None
|
|
||||||
if not (file_id or media_url):
|
|
||||||
logger.warning("renderer: video without file_id/media_url; skipping")
|
|
||||||
continue
|
|
||||||
msg = await bot.send_video(chat_id=chat_id, video=file_id or media_url,
|
|
||||||
caption=caption, reply_markup=kb)
|
|
||||||
sent.append(msg)
|
|
||||||
|
|
||||||
else:
|
|
||||||
logger.warning("renderer: unsupported message type=%s; skipping", mtype)
|
|
||||||
|
|
||||||
except TelegramError as te:
|
|
||||||
logger.exception("renderer.telegram_error type=%s err=%s", mtype, te)
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("renderer.unexpected type=%s err=%s", mtype, e)
|
|
||||||
|
|
||||||
return sent
|
|
@ -1,155 +0,0 @@
|
|||||||
# pxy_bots/router.py
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import time # <-- add
|
|
||||||
from typing import Dict, Optional, Tuple
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Try to use requests; fallback to urllib d
|
|
||||||
try:
|
|
||||||
import requests # type: ignore
|
|
||||||
_HAS_REQUESTS = True
|
|
||||||
except Exception:
|
|
||||||
import urllib.request # type: ignore
|
|
||||||
_HAS_REQUESTS = False
|
|
||||||
|
|
||||||
|
|
||||||
def _is_allowed(url: str, allowed_hosts: Optional[set]) -> Tuple[bool, str]:
|
|
||||||
"""Allowlist check for DB routes (per-Connection)."""
|
|
||||||
try:
|
|
||||||
p = urlparse(url)
|
|
||||||
if p.scheme not in {"http", "https"}:
|
|
||||||
return False, "bad_scheme"
|
|
||||||
host = (p.hostname or "").lower()
|
|
||||||
return (host in (allowed_hosts or set())), f"host={host}"
|
|
||||||
except Exception as e:
|
|
||||||
return False, f"invalid_url:{e}"
|
|
||||||
|
|
||||||
|
|
||||||
def _compose_url(base: str, path: str) -> str:
|
|
||||||
base = (base or "").rstrip("/")
|
|
||||||
path = (path or "").lstrip("/")
|
|
||||||
return f"{base}/{path}" if path else base
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# DB routing (Admin-driven)
|
|
||||||
# -----------------------------
|
|
||||||
def pick_db_route(bot_name: str, canon: Dict) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Look up CommandRoute for this bot + trigger/(optional) command.
|
|
||||||
Returns: {"url": str, "headers": dict, "timeout": int}
|
|
||||||
or None if no active route.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Lazy import to avoid circulars at startup
|
|
||||||
from .models import CommandRoute, Connection, TelegramBot # noqa
|
|
||||||
|
|
||||||
bot = TelegramBot.objects.filter(name=bot_name, is_active=True).first()
|
|
||||||
if not bot:
|
|
||||||
logger.info("router.no_bot bot=%s", bot_name)
|
|
||||||
return None
|
|
||||||
|
|
||||||
trigger = ((canon.get("command") or {}).get("trigger")) or "message"
|
|
||||||
cmd = ((canon.get("command") or {}).get("name")) or None
|
|
||||||
cmd = (cmd or "").strip().lstrip("/").lower() or None
|
|
||||||
|
|
||||||
logger.info("router.lookup bot=%s trigger=%s cmd=%s", bot.username, trigger, cmd or "")
|
|
||||||
|
|
||||||
qs = (
|
|
||||||
CommandRoute.objects
|
|
||||||
.select_related("connection")
|
|
||||||
.filter(bot=bot, enabled=True, connection__is_active=True, trigger=trigger)
|
|
||||||
.order_by("priority", "id")
|
|
||||||
)
|
|
||||||
# Log a small snapshot of candidates
|
|
||||||
snapshot = list(qs.values("command_name", "trigger", "priority", "path", "connection__name")[:10])
|
|
||||||
logger.info("router.candidates n=%s sample=%s", qs.count(), snapshot)
|
|
||||||
|
|
||||||
# Prefer exact command; then default (blank/null)
|
|
||||||
route = (
|
|
||||||
qs.filter(command_name=cmd).first()
|
|
||||||
or qs.filter(command_name__isnull=True).first()
|
|
||||||
or qs.filter(command_name="").first()
|
|
||||||
)
|
|
||||||
if not route:
|
|
||||||
logger.info("router.no_match bot=%s trigger=%s cmd=%s", bot.username, trigger, cmd or "")
|
|
||||||
return None
|
|
||||||
|
|
||||||
conn: Connection = route.connection
|
|
||||||
url = _compose_url(conn.base_url, route.path or conn.path_default)
|
|
||||||
|
|
||||||
ok, why = _is_allowed(url, conn.allowed_host_set())
|
|
||||||
if not ok:
|
|
||||||
logger.warning(
|
|
||||||
"router.db.reject url=%s reason=%s allowed=%s route_conn=%s",
|
|
||||||
url, why, conn.allowed_host_set(), conn.name
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
headers = {}
|
|
||||||
headers.update(conn.auth_headers())
|
|
||||||
headers.update(conn.extra_headers())
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"router.route_ok bot=%s trigger=%s cmd=%s url=%s conn=%s timeout=%ss",
|
|
||||||
bot.username, trigger, cmd or "", url, conn.name, conn.timeout_s
|
|
||||||
)
|
|
||||||
return {"url": url, "headers": headers, "timeout": conn.timeout_s}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("router.db.error: %s", e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# -----------------------------
|
|
||||||
# HTTP POST (DB routes)
|
|
||||||
# -----------------------------
|
|
||||||
def post_json(url: str, payload: Dict, timeout: float = 4.0, headers: Optional[Dict] = None) -> Tuple[int, Dict]:
|
|
||||||
"""
|
|
||||||
Blocking POST JSON; never raises.
|
|
||||||
Returns (status_code, body_json_or_wrapper).
|
|
||||||
`headers` is optional for DB routes.
|
|
||||||
"""
|
|
||||||
hdrs = {"Content-Type": "application/json", **(headers or {})}
|
|
||||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
||||||
t0 = time.perf_counter()
|
|
||||||
|
|
||||||
if _HAS_REQUESTS:
|
|
||||||
try:
|
|
||||||
r = requests.post(url, data=data, headers=hdrs, timeout=timeout)
|
|
||||||
dt = (time.perf_counter() - t0) * 1000
|
|
||||||
try:
|
|
||||||
body = r.json()
|
|
||||||
preview = str(body)[:200].replace("\n", " ")
|
|
||||||
except Exception:
|
|
||||||
body = {"text": r.text[:2000]}
|
|
||||||
preview = body["text"][:200].replace("\n", " ")
|
|
||||||
logger.info("router.http requests url=%s status=%s t_ms=%.1f body~=%s", url, r.status_code, dt, preview)
|
|
||||||
return r.status_code, body
|
|
||||||
except Exception as e:
|
|
||||||
dt = (time.perf_counter() - t0) * 1000
|
|
||||||
logger.exception("router.requests_failed url=%s t_ms=%.1f err=%s", url, dt, e.__class__.__name__)
|
|
||||||
return 502, {"ok": False, "error": f"requests_failed:{e.__class__.__name__}"}
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
req = urllib.request.Request(url, data=data, headers=hdrs, method="POST")
|
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as resp: # nosec
|
|
||||||
raw = resp.read(65536)
|
|
||||||
dt = (time.perf_counter() - t0) * 1000
|
|
||||||
try:
|
|
||||||
body = json.loads(raw.decode("utf-8"))
|
|
||||||
preview = str(body)[:200].replace("\n", " ")
|
|
||||||
except Exception:
|
|
||||||
txt = raw.decode("utf-8", errors="replace")[:2000]
|
|
||||||
body = {"text": txt}
|
|
||||||
preview = txt[:200].replace("\n", " ")
|
|
||||||
status = getattr(resp, "status", 200)
|
|
||||||
logger.info("router.http urllib url=%s status=%s t_ms=%.1f body~=%s", url, status, dt, preview)
|
|
||||||
return status, body
|
|
||||||
except Exception as e:
|
|
||||||
dt = (time.perf_counter() - t0) * 1000
|
|
||||||
logger.exception("router.urllib_failed url=%s t_ms=%.1f err=%s", url, dt, e.__class__.__name__)
|
|
||||||
return 502, {"ok": False, "error": f"urllib_failed:{e.__class__.__name__}"}
|
|
16
pxy_bots/set_webhook.py
Normal file
16
pxy_bots/set_webhook.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import requests
|
||||||
|
from pxy_bots.models import TelegramBot
|
||||||
|
|
||||||
|
BASE_URL = "https://your-domain.com/bots/webhook/"
|
||||||
|
|
||||||
|
def set_telegram_webhooks():
|
||||||
|
"""Sets webhooks for all active bots."""
|
||||||
|
bots = TelegramBot.objects.filter(is_active=True)
|
||||||
|
for bot in bots:
|
||||||
|
webhook_url = f"{BASE_URL}{bot.name}/"
|
||||||
|
response = requests.post(
|
||||||
|
f"https://api.telegram.org/bot{bot.token}/setWebhook",
|
||||||
|
data={"url": webhook_url}
|
||||||
|
)
|
||||||
|
print(f"Webhook for {bot.name} ({bot.username}) set to: {webhook_url}")
|
||||||
|
print("Response:", response.json())
|
@ -1,249 +1,69 @@
|
|||||||
# pxy_bots/views.py
|
|
||||||
import json
|
import json
|
||||||
import logging
|
from telegram import Update, Bot
|
||||||
from typing import Any, Dict, Optional
|
from django.http import JsonResponse
|
||||||
|
|
||||||
from telegram import Bot
|
|
||||||
from django.http import JsonResponse, HttpResponse
|
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.core.cache import cache
|
|
||||||
from asgiref.sync import sync_to_async
|
from asgiref.sync import sync_to_async
|
||||||
|
|
||||||
from .models import TelegramBot
|
from .models import TelegramBot
|
||||||
from .router import pick_db_route, post_json
|
from pxy_langchain.services import LangchainAIService
|
||||||
from .renderer import render_spec
|
from .handlers import dream_city_command, start, help_command, handle_location
|
||||||
|
import logging
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------
|
|
||||||
# Canonical req.v1 builder
|
|
||||||
# ---------------------------
|
|
||||||
|
|
||||||
def _pick_photo(sizes):
|
|
||||||
if not sizes:
|
|
||||||
return None
|
|
||||||
sizes = sorted(sizes, key=lambda s: (s.get("width", 0) * s.get("height", 0)), reverse=True)
|
|
||||||
top = sizes[0]
|
|
||||||
return {
|
|
||||||
"type": "photo",
|
|
||||||
"file_id": top.get("file_id"),
|
|
||||||
"mime": "image/jpeg",
|
|
||||||
"size_bytes": None,
|
|
||||||
"width": top.get("width"),
|
|
||||||
"height": top.get("height"),
|
|
||||||
}
|
|
||||||
|
|
||||||
def _extract_media(msg: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
||||||
if "photo" in msg:
|
|
||||||
return _pick_photo(msg.get("photo") or [])
|
|
||||||
if "voice" in msg:
|
|
||||||
v = msg["voice"]
|
|
||||||
return {"type": "voice", "file_id": v.get("file_id"), "mime": v.get("mime_type"),
|
|
||||||
"size_bytes": v.get("file_size"), "duration": v.get("duration")}
|
|
||||||
if "audio" in msg:
|
|
||||||
a = msg["audio"]
|
|
||||||
return {"type": "audio", "file_id": a.get("file_id"), "mime": a.get("mime_type"),
|
|
||||||
"size_bytes": a.get("file_size"), "duration": a.get("duration")}
|
|
||||||
if "video" in msg:
|
|
||||||
v = msg["video"]
|
|
||||||
return {"type": "video", "file_id": v.get("file_id"), "mime": v.get("mime_type"),
|
|
||||||
"size_bytes": v.get("file_size"), "duration": v.get("duration"),
|
|
||||||
"width": v.get("width"), "height": v.get("height")}
|
|
||||||
if "video_note" in msg:
|
|
||||||
v = msg["video_note"]
|
|
||||||
return {"type": "video_note", "file_id": v.get("file_id"), "mime": None,
|
|
||||||
"size_bytes": v.get("file_size"), "duration": v.get("duration"),
|
|
||||||
"length": v.get("length")}
|
|
||||||
if "animation" in msg:
|
|
||||||
a = msg["animation"]
|
|
||||||
return {"type": "animation", "file_id": a.get("file_id"), "mime": a.get("mime_type"),
|
|
||||||
"size_bytes": a.get("file_size")}
|
|
||||||
if "document" in msg:
|
|
||||||
d = msg["document"]
|
|
||||||
return {"type": "document", "file_id": d.get("file_id"), "mime": d.get("mime_type"),
|
|
||||||
"size_bytes": d.get("file_size"), "file_name": d.get("file_name")}
|
|
||||||
return None
|
|
||||||
|
|
||||||
def build_req_v1(update: Dict[str, Any], bot_name: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Normalize a Telegram update into our canonical req.v1 envelope.
|
|
||||||
Pure function. No network, no state.
|
|
||||||
"""
|
|
||||||
schema_version = "req.v1"
|
|
||||||
update_id = update.get("update_id")
|
|
||||||
|
|
||||||
msg = update.get("message") or update.get("edited_message")
|
|
||||||
cbq = update.get("callback_query")
|
|
||||||
|
|
||||||
if msg:
|
|
||||||
chat = msg.get("chat") or {}
|
|
||||||
user = msg.get("from") or {}
|
|
||||||
message_id = msg.get("message_id")
|
|
||||||
ts = msg.get("date")
|
|
||||||
text = msg.get("text")
|
|
||||||
caption = msg.get("caption")
|
|
||||||
location = msg.get("location")
|
|
||||||
media = _extract_media(msg)
|
|
||||||
trigger = "message"
|
|
||||||
elif cbq:
|
|
||||||
m = cbq.get("message") or {}
|
|
||||||
chat = m.get("chat") or {}
|
|
||||||
user = cbq.get("from") or {}
|
|
||||||
message_id = m.get("message_id")
|
|
||||||
ts = m.get("date") or None
|
|
||||||
text = None
|
|
||||||
caption = None
|
|
||||||
location = None
|
|
||||||
media = None
|
|
||||||
trigger = "callback"
|
|
||||||
else:
|
|
||||||
chat = {}
|
|
||||||
user = update.get("from") or {}
|
|
||||||
message_id = None
|
|
||||||
ts = None
|
|
||||||
text = None
|
|
||||||
caption = None
|
|
||||||
location = None
|
|
||||||
media = None
|
|
||||||
trigger = "unknown"
|
|
||||||
|
|
||||||
raw_cmd = None
|
|
||||||
if text and isinstance(text, str) and text.startswith("/"):
|
|
||||||
raw_cmd = text.split()[0][1:]
|
|
||||||
elif caption and isinstance(caption, str) and caption.startswith("/"):
|
|
||||||
raw_cmd = caption.split()[0][1:]
|
|
||||||
elif cbq and isinstance(cbq.get("data"), str):
|
|
||||||
raw_cmd = None # callbacks carry 'data' instead
|
|
||||||
|
|
||||||
# --- NEW: pseudo-commands for media so we can route from Admin ---
|
|
||||||
if not raw_cmd:
|
|
||||||
if media and isinstance(media, dict):
|
|
||||||
mtype = (media.get("type") or "").lower()
|
|
||||||
if mtype in {"voice", "audio", "video", "photo", "document", "animation", "video_note"}:
|
|
||||||
raw_cmd = f"__{mtype}__"
|
|
||||||
elif location:
|
|
||||||
raw_cmd = "__location__"
|
|
||||||
|
|
||||||
env = {
|
|
||||||
"schema_version": schema_version,
|
|
||||||
"bot": {"username": bot_name},
|
|
||||||
"chat": {"id": chat.get("id"), "type": chat.get("type")},
|
|
||||||
"user": {"id": user.get("id"), "language": user.get("language_code")},
|
|
||||||
"command": {
|
|
||||||
"name": raw_cmd,
|
|
||||||
"version": 1,
|
|
||||||
"trigger": ("text_command" if raw_cmd and trigger == "message"
|
|
||||||
else ("callback" if trigger == "callback" else trigger)),
|
|
||||||
},
|
|
||||||
"input": {
|
|
||||||
"text": text,
|
|
||||||
"caption": caption,
|
|
||||||
"args_raw": text or caption,
|
|
||||||
"media": media,
|
|
||||||
"location": ({"lat": location.get("latitude"), "lon": location.get("longitude")} if location else None),
|
|
||||||
},
|
|
||||||
"callback": (
|
|
||||||
{"id": cbq.get("id"), "data": cbq.get("data"),
|
|
||||||
"origin": {"message_id": message_id, "chat_id": chat.get("id")}}
|
|
||||||
if cbq else None
|
|
||||||
),
|
|
||||||
"context": {
|
|
||||||
"message_id": message_id,
|
|
||||||
"update_id": update_id,
|
|
||||||
"ts": ts,
|
|
||||||
"idempotency_key": f"tg:{message_id}:{user.get('id')}" if message_id and user.get("id") else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------
|
|
||||||
# Webhook (thin router)
|
|
||||||
# ---------------------------
|
|
||||||
|
|
||||||
@csrf_exempt
|
@csrf_exempt
|
||||||
async def telegram_webhook(request, bot_name: str):
|
async def telegram_webhook(request, bot_name):
|
||||||
|
"""
|
||||||
|
Webhook view that handles Telegram updates asynchronously and only uses LangChain.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info("Webhook called for bot=%s", bot_name)
|
logger.info(f"Webhook called for bot: {bot_name}")
|
||||||
|
|
||||||
if request.method != "POST":
|
# Step 1: Fetch the bot instance asynchronously
|
||||||
return HttpResponse(status=405)
|
|
||||||
|
|
||||||
# Load bot (sync ORM via sync_to_async)
|
|
||||||
try:
|
try:
|
||||||
bot_instance = await sync_to_async(TelegramBot.objects.get)(
|
bot_instance = await sync_to_async(TelegramBot.objects.get)(name=bot_name, is_active=True)
|
||||||
name=bot_name, is_active=True
|
logger.info(f"Loaded bot configuration: {bot_instance}")
|
||||||
)
|
|
||||||
except TelegramBot.DoesNotExist:
|
except TelegramBot.DoesNotExist:
|
||||||
|
logger.error(f"Bot '{bot_name}' not found or inactive.")
|
||||||
return JsonResponse({"error": f"Bot '{bot_name}' not found."}, status=400)
|
return JsonResponse({"error": f"Bot '{bot_name}' not found."}, status=400)
|
||||||
|
|
||||||
# Parse raw payload
|
# Step 2: Ensure the bot has a LangChain assistant
|
||||||
|
if not bot_instance.assistant:
|
||||||
|
logger.error(f"No assistant configured for bot '{bot_name}'.")
|
||||||
|
return JsonResponse({"error": "Assistant not configured."}, status=400)
|
||||||
|
|
||||||
|
# Step 3: Process POST request from Telegram
|
||||||
|
if request.method == "POST":
|
||||||
try:
|
try:
|
||||||
payload = json.loads(request.body.decode("utf-8") or "{}")
|
request_body = json.loads(request.body.decode("utf-8"))
|
||||||
except json.JSONDecodeError:
|
update = Update.de_json(request_body, Bot(token=bot_instance.token))
|
||||||
return JsonResponse({"ok": False, "error": "invalid_json"}, status=400)
|
logger.info(f"Update received: {update}")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Failed to decode JSON: {e}")
|
||||||
|
return JsonResponse({"error": "Invalid JSON payload"}, status=400)
|
||||||
|
|
||||||
# ----- Idempotency / retry guard (drops duplicates for ~90s) -----
|
# Step 4: Route commands to the appropriate handlers
|
||||||
upd_id = payload.get("update_id")
|
if update.message:
|
||||||
cbq = payload.get("callback_query") or {}
|
if update.message.text == "/start":
|
||||||
cbq_id = cbq.get("id")
|
await start(update)
|
||||||
msg = payload.get("message") or {}
|
elif update.message.text == "/help":
|
||||||
fallback_msg_id = msg.get("message_id")
|
await help_command(update)
|
||||||
fallback_user = (msg.get("from") or {}).get("id")
|
elif update.message.text == "/dream_city":
|
||||||
|
await dream_city_command(update)
|
||||||
|
elif update.message.location:
|
||||||
|
await handle_location(update)
|
||||||
|
else:
|
||||||
|
# Step 5: Process AI-generated response using LangChain
|
||||||
|
assistant_instance = await sync_to_async(LangchainAIService)(bot_instance.assistant)
|
||||||
|
bot_response = await sync_to_async(assistant_instance.generate_response)(update.message.text)
|
||||||
|
|
||||||
dedupe_key = None
|
# Step 6: Send the response back to Telegram
|
||||||
if upd_id is not None:
|
await update.message.reply_text(bot_response)
|
||||||
dedupe_key = f"tg:update:{upd_id}"
|
|
||||||
elif cbq_id:
|
|
||||||
dedupe_key = f"tg:cbq:{cbq_id}"
|
|
||||||
elif fallback_msg_id and fallback_user:
|
|
||||||
dedupe_key = f"tg:msg:{fallback_msg_id}:{fallback_user}"
|
|
||||||
|
|
||||||
if dedupe_key:
|
return JsonResponse({"status": "ok"})
|
||||||
if not cache.add(dedupe_key, "1", timeout=90):
|
|
||||||
logger.info("tg.idempotent.skip key=%s", dedupe_key)
|
|
||||||
return JsonResponse({"status": "duplicate_skipped"})
|
|
||||||
# -----------------------------------------------------------------
|
|
||||||
|
|
||||||
# Build canonical req.v1
|
logger.warning("Received non-POST request")
|
||||||
try:
|
return JsonResponse({"error": "Invalid request method"}, status=400)
|
||||||
canon = build_req_v1(payload, bot_name)
|
|
||||||
logger.info("tg.canonical env=%s", json.dumps(canon, ensure_ascii=False))
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception("tg.canonical.failed: %s", e)
|
|
||||||
return JsonResponse({"ok": False, "error": "canonical_failed"}, status=400)
|
|
||||||
|
|
||||||
# ----- DB-driven route (Admin) -----
|
|
||||||
route = pick_db_route(bot_name, canon)
|
|
||||||
if route:
|
|
||||||
chat_id = (canon.get("chat") or {}).get("id")
|
|
||||||
status, body = await sync_to_async(post_json)(
|
|
||||||
route["url"], canon, route.get("timeout", 4), route.get("headers")
|
|
||||||
)
|
|
||||||
logger.info("tg.routed(db) url=%s status=%s", route["url"], status)
|
|
||||||
|
|
||||||
spec = None
|
|
||||||
if isinstance(body, dict) and ("messages" in body or body.get("schema_version") == "render.v1"):
|
|
||||||
spec = body
|
|
||||||
elif isinstance(body, dict) and "text" in body:
|
|
||||||
spec = {"schema_version": "render.v1",
|
|
||||||
"messages": [{"type": "text", "text": str(body["text"])}]}
|
|
||||||
|
|
||||||
if spec and chat_id:
|
|
||||||
bot = Bot(token=bot_instance.token)
|
|
||||||
await render_spec(bot=bot, chat_id=chat_id, spec=spec)
|
|
||||||
return JsonResponse({"status": "ok", "routed": True, "status_code": status})
|
|
||||||
|
|
||||||
# Nothing matched → 204 (ack, but no action)
|
|
||||||
logger.warning(
|
|
||||||
"tg.no_route bot=%s trigger=%s cmd=%s",
|
|
||||||
bot_name,
|
|
||||||
(canon.get("command") or {}).get("trigger"),
|
|
||||||
(canon.get("command") or {}).get("name"),
|
|
||||||
)
|
|
||||||
return JsonResponse({"status": "no_route"}, status=204)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Error in webhook: %s", e)
|
logger.error(f"Error in webhook: {e}")
|
||||||
return JsonResponse({"error": f"Unexpected error: {str(e)}"}, status=500)
|
return JsonResponse({"error": f"Unexpected error: {str(e)}"}, status=500)
|
||||||
|
@ -1,3 +0,0 @@
|
|||||||
from django.contrib import admin
|
|
||||||
|
|
||||||
# Register your models here.
|
|
@ -1,6 +0,0 @@
|
|||||||
from django.apps import AppConfig
|
|
||||||
|
|
||||||
|
|
||||||
class PxyBuildingDigitalTwinsConfig(AppConfig):
|
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
|
||||||
name = 'pxy_building_digital_twins'
|
|
@ -1,3 +0,0 @@
|
|||||||
from django.db import models
|
|
||||||
|
|
||||||
# Create your models here.
|
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -1,131 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Building Twin — Random Stadium (Backend)</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
|
|
||||||
<link href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" rel="stylesheet" crossorigin="">
|
|
||||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
html, body { height:100%; margin:0; background:#0b1220; }
|
|
||||||
#map { position:fixed; inset:0; }
|
|
||||||
#legend {
|
|
||||||
position: fixed; top: 12px; left: 12px; z-index: 1000;
|
|
||||||
background: #0f172a; color: #e5e7eb; padding: 10px 12px; border-radius: 10px;
|
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,.35); font-size: 12px; line-height: 1.25;
|
|
||||||
}
|
|
||||||
.pill{display:inline-block; padding:2px 6px; border-radius:999px; margin-right:6px}
|
|
||||||
.p-field{background:#16a34a;color:#052e16}
|
|
||||||
.p-stands{background:#64748b;color:#0b1220}
|
|
||||||
.p-conc{background:#94a3b8;color:#0b1220}
|
|
||||||
.k-org{background:#0ea5e9} .k-rec{background:#10b981}
|
|
||||||
.k-lf{background:#8b5cf6} .k-sp{background:#b45309}
|
|
||||||
.k-dt{background:#7c3aed}
|
|
||||||
|
|
||||||
.btn { background:#1f2937; border:1px solid #334155; color:#e5e7eb;
|
|
||||||
padding:6px 10px; border-radius:8px; cursor:pointer }
|
|
||||||
.btn:hover { background:#111827; }
|
|
||||||
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="legend">
|
|
||||||
<div style="font-weight:600; margin-bottom:6px;">Random Stadium (backend)</div>
|
|
||||||
<div style="margin-bottom:6px">
|
|
||||||
<span class="pill p-field">Field</span>
|
|
||||||
<span class="pill p-stands">Stands</span>
|
|
||||||
<span class="pill p-conc">Concourse</span>
|
|
||||||
</div>
|
|
||||||
<div>Bins:
|
|
||||||
<span class="pill k-rec">recyclable</span>
|
|
||||||
<span class="pill k-org">organic</span>
|
|
||||||
<span class="pill k-lf">landfill</span>
|
|
||||||
<span class="pill k-sp">special</span>
|
|
||||||
<span class="pill k-dt">dry_toilet</span>
|
|
||||||
</div>
|
|
||||||
<div style="opacity:.7; margin-top:6px">
|
|
||||||
Tip: add <code>?seed=123</code> to make it deterministic.
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:8px">
|
|
||||||
<button id="btnBabylon" class="btn">Open 3D Banorte Stadium</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="map"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Map (local flat plane)
|
|
||||||
const map = L.map('map', { crs: L.CRS.Simple, minZoom:-5, maxZoom:5, zoomSnap:0.25 }).setView([0,0], 0);
|
|
||||||
|
|
||||||
// Faint grid background
|
|
||||||
const grid = L.gridLayer();
|
|
||||||
grid.createTile = function(){
|
|
||||||
const c = document.createElement('canvas'); c.width=256; c.height=256;
|
|
||||||
const ctx=c.getContext('2d'); ctx.strokeStyle='#1f2937'; ctx.lineWidth=1;
|
|
||||||
for(let i=0;i<=256;i+=32){ ctx.beginPath(); ctx.moveTo(i,0); ctx.lineTo(i,256); ctx.stroke();
|
|
||||||
ctx.beginPath(); ctx.moveTo(0,i); ctx.lineTo(256,i); ctx.stroke(); }
|
|
||||||
return c;
|
|
||||||
};
|
|
||||||
grid.addTo(map);
|
|
||||||
|
|
||||||
const spacesLayer = L.geoJSON(null, {
|
|
||||||
style: f => {
|
|
||||||
const t = (f.properties?.type || "").toLowerCase();
|
|
||||||
const fill =
|
|
||||||
t === "field" ? "#16a34a" :
|
|
||||||
t === "concourse" ? "#94a3b8" :
|
|
||||||
t === "stands" ? "#64748b" : "#9ca3af";
|
|
||||||
return { color:"#0b1220", weight:0.6, fillColor:fill, fillOpacity:0.35 };
|
|
||||||
}
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
const binsLayer = L.geoJSON(null, {
|
|
||||||
pointToLayer: (f, ll) => {
|
|
||||||
const k = (f.properties?.kind || "").toLowerCase();
|
|
||||||
const fill =
|
|
||||||
k==="organic" ? "#0ea5e9" :
|
|
||||||
k==="recyclable" ? "#10b981" :
|
|
||||||
k==="landfill" ? "#8b5cf6" :
|
|
||||||
k==="special" ? "#b45309" :
|
|
||||||
k==="dry_toilet" ? "#7c3aed" :
|
|
||||||
"#e5e7eb";
|
|
||||||
return L.circleMarker(ll, { radius:6, color:"#0b1220", weight:1, fillColor:fill, fillOpacity:0.95 });
|
|
||||||
},
|
|
||||||
onEachFeature: (f, layer) => {
|
|
||||||
const p = f.properties || {};
|
|
||||||
layer.bindPopup(`<b>${p.kind || "bin"}</b><br/>id: ${p.id || "-"}<br/>capacity: ${p.capacity_l ?? "-"} L`);
|
|
||||||
}
|
|
||||||
}).addTo(map);
|
|
||||||
|
|
||||||
function fetchStadium() {
|
|
||||||
const seed = new URLSearchParams(location.search).get("seed") || Date.now();
|
|
||||||
fetch(`/building/api/random/stadium/?seed=${seed}`)
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
spacesLayer.clearLayers().addData(data.spaces);
|
|
||||||
binsLayer.clearLayers().addData(data.bins);
|
|
||||||
const group = L.featureGroup([spacesLayer, binsLayer]);
|
|
||||||
try { map.fitBounds(group.getBounds().pad(0.12)); } catch(e){}
|
|
||||||
})
|
|
||||||
.catch(err => console.error("Failed to fetch stadium:", err));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-generate on load
|
|
||||||
fetchStadium();
|
|
||||||
|
|
||||||
// Open Babylon 3D viewer, preserving current query params
|
|
||||||
document.getElementById('btnBabylon').onclick = () => {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
if (!params.has('seed')) params.set('seed', Date.now()); // keep deterministic if you passed one
|
|
||||||
// Change the path below if your Babylon route differs
|
|
||||||
location.href = `/building/twin/wire/babylon/?${params.toString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,262 +0,0 @@
|
|||||||
{% load static %}
|
|
||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Building Twin — Wireframe Stadium (Real Size + Smart Can)</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
|
|
||||||
<!-- A-Frame -->
|
|
||||||
<script src="https://aframe.io/releases/1.4.1/aframe.min.js"></script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
html, body { height:100%; margin:0; background:#0b1220; color:#e5e7eb; font-family: system-ui, sans-serif; }
|
|
||||||
#hud {
|
|
||||||
position: fixed; top: 10px; left: 10px; z-index: 1000;
|
|
||||||
background:#0f172a; padding:10px 12px; border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.35);
|
|
||||||
font-size:12px; line-height:1.25; max-width: 560px;
|
|
||||||
}
|
|
||||||
code { background:#111827; padding:0 4px; border-radius:4px }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="hud">
|
|
||||||
<div style="font-weight:600; margin-bottom:6px">Wireframe Stadium (real meters)</div>
|
|
||||||
<div>Use <code>?scale=2</code> (double), <code>?scale=0.5</code> (half). Field defaults to 105×68 m (override with <code>?field_w=&field_h=</code>).</div>
|
|
||||||
<div>Smart can params: <code>?can_units=m|cm|mm</code>, <code>?can_scale=1</code>, <code>?can_angle=45</code>, <code>?can_y=0</code>, <code>?can_face_center=1</code>, optional <code>?can_tri=1</code> to download triangles JSON.</div>
|
|
||||||
<div style="opacity:.75; margin-top:6px">WASD to move, drag to look. Enter VR to feel the 1:1 scale.</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a-scene background="color: #0b1220"
|
|
||||||
renderer="antialias:false; powerPreference:high-performance; physicallyCorrectLights:false; logarithmicDepthBuffer:true">
|
|
||||||
<!-- Assets (GLB fast path; OBJ fallback) -->
|
|
||||||
<a-assets timeout="30000">
|
|
||||||
<a-asset-item id="smartcan-glb" src="{% static 'pxy_building_digital_twins/models/smartcan.glb' %}"></a-asset-item>
|
|
||||||
<a-asset-item id="smartcan-obj" src="{% static 'pxy_building_digital_twins/models/smartcan.obj' %}"></a-asset-item>
|
|
||||||
</a-assets>
|
|
||||||
|
|
||||||
<!-- Lights -->
|
|
||||||
<a-entity light="type: ambient; intensity: 0.6; color: #ffffff"></a-entity>
|
|
||||||
<a-entity light="type: directional; intensity: 0.7; color: #ffffff" position="50 80 40"></a-entity>
|
|
||||||
|
|
||||||
<!-- Camera rig at human eye height -->
|
|
||||||
<a-entity id="rig" position="0 1.6 180">
|
|
||||||
<a-entity camera look-controls wasd-controls="acceleration: 40" position="0 0 0"></a-entity>
|
|
||||||
</a-entity>
|
|
||||||
|
|
||||||
<!-- Ground grid (reference) -->
|
|
||||||
<a-entity id="grid"></a-entity>
|
|
||||||
|
|
||||||
<!-- Stadium parts -->
|
|
||||||
<a-entity id="stadium"></a-entity>
|
|
||||||
<a-entity id="bins"></a-entity>
|
|
||||||
</a-scene>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// ---------- URL params ----------
|
|
||||||
const q = new URLSearchParams(location.search);
|
|
||||||
const SCALE = Math.max(0.1, Math.min(5, parseFloat(q.get('scale') || '1'))); // 0.1x .. 5x
|
|
||||||
const FIELD_W = parseFloat(q.get('field_w') || '105'); // meters (FIFA)
|
|
||||||
const FIELD_H = parseFloat(q.get('field_h') || '68');
|
|
||||||
|
|
||||||
// ---------- Seeded RNG (deterministic with ?seed=) ----------
|
|
||||||
function mulberry32(a){ return function(){ var t=a+=0x6D2B79F5; t=Math.imul(t^t>>>15,t|1);
|
|
||||||
t^=t+Math.imul(t^t>>>7,t|61); return ((t^t>>>14)>>>0)/4294967296; } }
|
|
||||||
const urlSeed = q.get("seed");
|
|
||||||
const seedNum = urlSeed ? Array.from(String(urlSeed)).reduce((s,ch)=>s+ch.charCodeAt(0),0) : Math.floor(Math.random()*1e9);
|
|
||||||
const rand = mulberry32(seedNum);
|
|
||||||
const randf = (a,b)=> a + (b-a)*rand();
|
|
||||||
const randi = (a,b)=> Math.floor(randf(a,b+1));
|
|
||||||
|
|
||||||
// ---------- A-Frame helpers ----------
|
|
||||||
AFRAME.registerComponent('wire-ellipse', {
|
|
||||||
schema: { rx:{type:'number'}, ry:{type:'number'}, y:{type:'number',default:0},
|
|
||||||
seg:{type:'int',default:256}, color:{type:'color',default:'#93a3b8'}, width:{type:'number',default:1} },
|
|
||||||
init: function () {
|
|
||||||
const THREE = AFRAME.THREE, d = this.data;
|
|
||||||
const geom = new THREE.BufferGeometry(); const pts = [];
|
|
||||||
for(let i=0;i<d.seg;i++){ const a = i/d.seg * Math.PI*2; pts.push(d.rx*Math.cos(a), d.y, d.ry*Math.sin(a)); }
|
|
||||||
pts.push(pts[0], pts[1], pts[2]); // close
|
|
||||||
geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(pts), 3));
|
|
||||||
const mat = new THREE.LineBasicMaterial({ color: d.color, linewidth: d.width });
|
|
||||||
this.el.setObject3D('mesh', new THREE.Line(geom, mat));
|
|
||||||
},
|
|
||||||
remove: function(){ const o=this.el.getObject3D('mesh'); if(o){ o.geometry.dispose(); o.material.dispose(); this.el.removeObject3D('mesh'); } }
|
|
||||||
});
|
|
||||||
|
|
||||||
function addWireBox(parent, w, h, t, y, color){ // w=width (x), h=depth (z), t=thickness (y)
|
|
||||||
const e = document.createElement('a-box');
|
|
||||||
e.setAttribute('width', w); e.setAttribute('depth', h); e.setAttribute('height', t);
|
|
||||||
e.setAttribute('position', `0 ${y} 0`);
|
|
||||||
e.setAttribute('material', `wireframe: true; color: ${color}; opacity: 0.65; transparent: true`);
|
|
||||||
parent.appendChild(e); return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
function addBin(parent, x, y, z, color='#10b981'){
|
|
||||||
const s = document.createElement('a-sphere');
|
|
||||||
s.setAttribute('radius', 0.6 * SCALE); // ~60cm glowing puck
|
|
||||||
s.setAttribute('position', `${x} ${y} ${z}`);
|
|
||||||
s.setAttribute('material', `color: ${color}; emissive: ${color}; emissiveIntensity: 0.9`);
|
|
||||||
parent.appendChild(s); return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Smart can loader (GLB first, OBJ fallback)
|
|
||||||
function addSmartCan(parent, x, y, z, scale=1, rotY=0){
|
|
||||||
const e = document.createElement('a-entity');
|
|
||||||
if (document.getElementById('smartcan-glb')) {
|
|
||||||
e.setAttribute('gltf-model', '#smartcan-glb'); // FAST PATH
|
|
||||||
} else if (document.getElementById('smartcan-obj')) {
|
|
||||||
e.setAttribute('obj-model', 'obj: #smartcan-obj'); // fallback (no MTL)
|
|
||||||
e.setAttribute('material', 'color: #9ca3af; metalness: 0.2; roughness: 0.6');
|
|
||||||
} else {
|
|
||||||
// last-resort placeholder
|
|
||||||
e.setAttribute('geometry','primitive:cylinder; radius:0.35; height:1.0');
|
|
||||||
e.setAttribute('material','color:#9ca3af');
|
|
||||||
}
|
|
||||||
e.setAttribute('position', `${x} ${y} ${z}`);
|
|
||||||
e.setAttribute('rotation', `0 ${rotY} 0`);
|
|
||||||
e.setAttribute('scale', `${scale} ${scale} ${scale}`);
|
|
||||||
parent.appendChild(e);
|
|
||||||
return e;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: extract triangles from loaded model (works for GLB & OBJ)
|
|
||||||
AFRAME.registerComponent('extract-triangles', {
|
|
||||||
schema: { download:{type:'boolean', default:true}, log:{type:'boolean', default:true} },
|
|
||||||
init() {
|
|
||||||
this.onLoaded = () => {
|
|
||||||
const root = this.el.getObject3D('mesh'); if (!root) return;
|
|
||||||
const tris = [];
|
|
||||||
root.traverse(n => {
|
|
||||||
if (!n.isMesh || !n.geometry) return;
|
|
||||||
let g = n.geometry;
|
|
||||||
if (g.index) g = g.toNonIndexed();
|
|
||||||
const pos = g.getAttribute('position'); if (!pos) return;
|
|
||||||
const a = pos.array;
|
|
||||||
for (let i=0;i<a.length;i+=9){
|
|
||||||
tris.push([[a[i],a[i+1],a[i+2]],[a[i+3],a[i+4],a[i+5]],[a[i+6],a[i+7],a[i+8]]]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (this.data.log) console.log(`extract-triangles: ${tris.length} triangles`, tris);
|
|
||||||
if (this.data.download){
|
|
||||||
const blob = new Blob([JSON.stringify({model:'smartcan',triangles:tris})],{type:'application/json'});
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = Object.assign(document.createElement('a'), {href:url, download:'smartcan_triangles.json'});
|
|
||||||
document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url), 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.el.addEventListener('model-loaded', this.onLoaded);
|
|
||||||
},
|
|
||||||
remove(){ this.el.removeEventListener('model-loaded', this.onLoaded); }
|
|
||||||
});
|
|
||||||
|
|
||||||
// Grid in meters
|
|
||||||
(function makeGrid(){
|
|
||||||
const e = document.querySelector('#grid');
|
|
||||||
const size=500*SCALE, step=5*SCALE, y=0;
|
|
||||||
for(let x=-size; x<=size; x+=step){ const l=document.createElement('a-entity');
|
|
||||||
l.setAttribute('line', `start: ${x} ${y} ${-size}; end: ${x} ${y} ${size}; color: #1f2937`); e.appendChild(l); }
|
|
||||||
for(let z=-size; z<=size; z+=step){ const l=document.createElement('a-entity');
|
|
||||||
l.setAttribute('line', `start: ${-size} ${y} ${z}; end: ${size} ${y} ${z}; color: #1f2937`); e.appendChild(l); }
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ---------- Build at real scale (meters) ----------
|
|
||||||
(function build(){
|
|
||||||
// Clean previous geometry if re-running
|
|
||||||
const stadium = document.querySelector('#stadium');
|
|
||||||
const bins = document.querySelector('#bins');
|
|
||||||
while (stadium.firstChild) stadium.removeChild(stadium.firstChild);
|
|
||||||
while (bins.firstChild) bins.removeChild(bins.firstChild);
|
|
||||||
|
|
||||||
// Heights (meters)
|
|
||||||
const Y_FIELD = 0.20*SCALE; // field mesh lift
|
|
||||||
const Y_CONC = 8.0*SCALE; // concourse level
|
|
||||||
const Y_STAND = 22.0*SCALE; // upper stands level (wire)
|
|
||||||
const EYE_H = 1.6*SCALE; // human eye height
|
|
||||||
|
|
||||||
// Field (exact meters)
|
|
||||||
const fieldW = FIELD_W * SCALE;
|
|
||||||
const fieldH = FIELD_H * SCALE;
|
|
||||||
|
|
||||||
// Derive rings from field size (meters)
|
|
||||||
const safeX = (FIELD_W/2 + randf(6,10)) * SCALE;
|
|
||||||
const safeZ = (FIELD_H/2 + randf(6,10)) * SCALE;
|
|
||||||
|
|
||||||
const standsInRX = safeX + randf(12,18)*SCALE;
|
|
||||||
const standsInRZ = safeZ + randf(12,18)*SCALE;
|
|
||||||
|
|
||||||
const concInRX = standsInRX + randf(4,6)*SCALE; // concourse corridor inside
|
|
||||||
const concInRZ = standsInRZ + randf(4,6)*SCALE;
|
|
||||||
const concOutRX = concInRX + randf(6,10)*SCALE; // concourse corridor outside
|
|
||||||
const concOutRZ = concInRZ + randf(6,10)*SCALE;
|
|
||||||
|
|
||||||
const standsOutRX = standsInRX + randf(35,45)*SCALE;
|
|
||||||
const standsOutRZ = standsInRZ + randf(35,45)*SCALE;
|
|
||||||
|
|
||||||
// Field (wireframe box)
|
|
||||||
addWireBox(stadium, fieldW, fieldH, 0.4*SCALE, Y_FIELD, '#16a34a');
|
|
||||||
|
|
||||||
// Concourse inner/outer
|
|
||||||
const concInner = document.createElement('a-entity');
|
|
||||||
concInner.setAttribute('wire-ellipse', { rx: concInRX, ry: concInRZ, y: Y_CONC, color:'#22d3ee' });
|
|
||||||
const concOuter = document.createElement('a-entity');
|
|
||||||
concOuter.setAttribute('wire-ellipse', { rx: concOutRX, ry: concOutRZ, y: Y_CONC, color:'#38bdf8' });
|
|
||||||
stadium.appendChild(concInner); stadium.appendChild(concOuter);
|
|
||||||
|
|
||||||
// Stands inner/outer (higher)
|
|
||||||
const standInner = document.createElement('a-entity');
|
|
||||||
standInner.setAttribute('wire-ellipse', { rx: standsInRX, ry: standsInRZ, y: Y_STAND, color:'#a78bfa' });
|
|
||||||
const standOuter = document.createElement('a-entity');
|
|
||||||
standOuter.setAttribute('wire-ellipse', { rx: standsOutRX, ry: standsOutRZ, y: Y_STAND, color:'#60a5fa' });
|
|
||||||
stadium.appendChild(standInner); stadium.appendChild(standOuter);
|
|
||||||
|
|
||||||
// Ribs
|
|
||||||
const ribs = randi(14, 22);
|
|
||||||
for(let i=0;i<ribs;i++){
|
|
||||||
const a = i/ribs * Math.PI*2 + randf(-0.02,0.02);
|
|
||||||
const x1 = concOutRX * Math.cos(a), z1 = concOutRZ * Math.sin(a);
|
|
||||||
const x2 = standsInRX * Math.cos(a), z2 = standsInRZ * Math.sin(a);
|
|
||||||
const rib = document.createElement('a-entity');
|
|
||||||
rib.setAttribute('line', `start: ${x1} ${Y_CONC} ${z1}; end: ${x2} ${Y_STAND} ${z2}; color: #14b8a6`);
|
|
||||||
stadium.appendChild(rib);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bins around concourse midline
|
|
||||||
const midRX = (concInRX + concOutRX)/2, midRZ = (concInRZ + concOutRZ)/2;
|
|
||||||
const binCount = randi(44, 64);
|
|
||||||
const colors = ['#10b981', '#0ea5e9', '#8b5cf6', '#f59e0b'];
|
|
||||||
for(let i=0;i<binCount;i++){
|
|
||||||
const a = i/binCount * Math.PI*2 + randf(-0.03,0.03);
|
|
||||||
const x = midRX * Math.cos(a), z = midRZ * Math.sin(a);
|
|
||||||
addBin(bins, x, Y_CONC+0.25*SCALE, z, colors[i % colors.length]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Smart can (GLB fast path; OBJ fallback) ---
|
|
||||||
const units = (q.get('can_units') || 'm').toLowerCase(); // m|cm|mm
|
|
||||||
const canScaleUser = parseFloat(q.get('can_scale') || '1');
|
|
||||||
const canAngleDeg = parseFloat(q.get('can_angle') || (Math.random()*360).toFixed(1));
|
|
||||||
const canYOffset = parseFloat(q.get('can_y') || '0'); // meters
|
|
||||||
const faceCenter = (q.get('can_face_center') || '1') !== '0';
|
|
||||||
|
|
||||||
const unitToMeter = units==='cm' ? 0.01 : units==='mm' ? 0.001 : 1;
|
|
||||||
const canScaleFinal = SCALE * unitToMeter * canScaleUser;
|
|
||||||
|
|
||||||
const ang = canAngleDeg * Math.PI/180;
|
|
||||||
const canX = midRX * Math.cos(ang);
|
|
||||||
const canZ = midRZ * Math.sin(ang);
|
|
||||||
const rotY = faceCenter ? -canAngleDeg : 0;
|
|
||||||
|
|
||||||
const can = addSmartCan(stadium, canX, Y_CONC + canYOffset, canZ, canScaleFinal, rotY);
|
|
||||||
|
|
||||||
// Optional triangle dump: add ?can_tri=1 to URL
|
|
||||||
if (q.get('can_tri') === '1') {
|
|
||||||
can.setAttribute('extract-triangles', 'download:true; log:true');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start the rig at eye height just outside the outer stands, facing center
|
|
||||||
const startZ = Math.max(standsOutRX, standsOutRZ) + 15*SCALE;
|
|
||||||
document.querySelector('#rig').setAttribute('position', `0 ${EYE_H} ${startZ}`);
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,392 +0,0 @@
|
|||||||
{% load static %}
|
|
||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<title>Building Twin — Babylon Wireframe Stadium + Smart Can</title>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<!-- Babylon core + loaders (for GLB/GLTF/OBJ) -->
|
|
||||||
<script src="https://cdn.babylonjs.com/babylon.js"></script>
|
|
||||||
<script src="https://cdn.babylonjs.com/loaders/babylonjs.loaders.min.js"></script>
|
|
||||||
<style>
|
|
||||||
html, body { height:100%; margin:0; background:#0b1220; font-family:system-ui, sans-serif; }
|
|
||||||
#c {
|
|
||||||
position: fixed;
|
|
||||||
left: 0; top: 0;
|
|
||||||
width: 100vw; height: 100vh; /* fill viewport */
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
#hud {
|
|
||||||
position: fixed; bottom: 10px; left: 10px; z-index: 10; color: #e5e7eb;
|
|
||||||
background:#0f172a; padding:10px 12px; border-radius:10px; box-shadow:0 10px 30px rgba(0,0,0,.35);
|
|
||||||
font-size:12px; line-height:1.25; width: 300px;
|
|
||||||
}
|
|
||||||
.row { display:flex; gap:8px; margin-top:6px; }
|
|
||||||
.row > * { flex:1; }
|
|
||||||
label { display:flex; align-items:center; gap:6px; cursor:pointer; }
|
|
||||||
button { width:100%; background:#1f2937; color:#e5e7eb; border:1px solid #334155;
|
|
||||||
border-radius:8px; padding:6px 8px; cursor:pointer; }
|
|
||||||
button:hover { background:#111827; }
|
|
||||||
.muted { opacity:.75 }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<canvas id="c"></canvas>
|
|
||||||
|
|
||||||
<div id="hud">
|
|
||||||
<div style="font-weight:600; margin-bottom:6px;">Wireframe Stadium (Babylon) + Smart Can</div>
|
|
||||||
<div class="muted">Orbit with mouse/touch. Drag bins on the floor.</div>
|
|
||||||
<div class="row" style="margin-top:8px;">
|
|
||||||
<label><input type="checkbox" id="chkL1" checked> L1 Concourse</label>
|
|
||||||
<label><input type="checkbox" id="chkL2" checked> L2 Mezzanine</label>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<label><input type="checkbox" id="chkL3" checked> L3 Upper Stands</label>
|
|
||||||
<label><input type="checkbox" id="chkBins" checked> Bins</label>
|
|
||||||
</div>
|
|
||||||
<div class="row" style="margin-top:8px;">
|
|
||||||
<button id="btnRegen">Regenerate</button>
|
|
||||||
<button id="btnResetCam">Reset Camera</button>
|
|
||||||
<button id="btnViewer">Open 2D Banorte Stadium</button>
|
|
||||||
</div>
|
|
||||||
<div class="muted" style="margin-top:6px;">
|
|
||||||
URL knobs: <code>?seed=123&can_units=m|cm|mm&can_scale=1&can_angle=45&can_y=0</code>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// ---------- Params ----------
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
const canUnits = (params.get('can_units') || 'm').toLowerCase(); // m|cm|mm
|
|
||||||
const canScaleUser = parseFloat(params.get('can_scale') || '1');
|
|
||||||
const canAngleDeg = parseFloat(params.get('can_angle') || (Math.random()*360).toFixed(1));
|
|
||||||
const canYOffset = parseFloat(params.get('can_y') || '0');
|
|
||||||
|
|
||||||
const unitToMeter = canUnits === 'cm' ? 0.01 : canUnits === 'mm' ? 0.001 : 1;
|
|
||||||
const CAN_GLb_URL = "{% static 'pxy_building_digital_twins/models/smartcan.glb' %}";
|
|
||||||
const CAN_OBJ_URL = "{% static 'pxy_building_digital_twins/models/smartcan.obj' %}";
|
|
||||||
|
|
||||||
// ---------- Engine / Scene ----------
|
|
||||||
const canvas = document.getElementById('c');
|
|
||||||
const engine = new BABYLON.Engine(canvas, true, { antialias:true, preserveDrawingBuffer:true, stencil:true });
|
|
||||||
const scene = new BABYLON.Scene(engine);
|
|
||||||
function fitCanvas(){ canvas.style.width='100vw'; canvas.style.height='100vh'; canvas.width=canvas.clientWidth; canvas.height=canvas.clientHeight; engine.resize(); }
|
|
||||||
fitCanvas(); window.addEventListener('resize', fitCanvas);
|
|
||||||
scene.clearColor = new BABYLON.Color4(0.043,0.071,0.125,1.0);
|
|
||||||
|
|
||||||
// Camera
|
|
||||||
const camera = new BABYLON.ArcRotateCamera("cam", Math.PI*1.25, 1.05, 220, new BABYLON.Vector3(0,10,0), scene);
|
|
||||||
camera.attachControl(canvas, true);
|
|
||||||
camera.lowerRadiusLimit = 40; camera.upperRadiusLimit = 2000;
|
|
||||||
|
|
||||||
// Lights
|
|
||||||
const hemi = new BABYLON.HemisphericLight("h", new BABYLON.Vector3(0,1,0), scene);
|
|
||||||
hemi.intensity = 0.9;
|
|
||||||
const dir = new BABYLON.DirectionalLight("d", new BABYLON.Vector3(-1,-2,-1), scene);
|
|
||||||
dir.position = new BABYLON.Vector3(120,180,120); dir.intensity = 0.7;
|
|
||||||
|
|
||||||
// Axes
|
|
||||||
const SHOW_AXES = false;
|
|
||||||
if (SHOW_AXES){
|
|
||||||
(function axes(len=30){
|
|
||||||
const mk=(a,b,c,col)=>{const l=BABYLON.MeshBuilder.CreateLines("ax",{points:[a,b,c]},scene); l.color=col; l.isPickable=false; return l;};
|
|
||||||
mk(new BABYLON.Vector3(0,0,0), new BABYLON.Vector3(len,0,0), new BABYLON.Vector3(len,0,0), new BABYLON.Color3(1,0.2,0.2));
|
|
||||||
mk(new BABYLON.Vector3(0,0,0), new BABYLON.Vector3(0,len,0), new BABYLON.Vector3(0,len,0), new BABYLON.Color3(0.2,1,0.2));
|
|
||||||
mk(new BABYLON.Vector3(0,0,0), new BABYLON.Vector3(0,0,len), new BABYLON.Vector3(0,0,len), new BABYLON.Color3(0.3,0.7,1));
|
|
||||||
})();
|
|
||||||
}
|
|
||||||
// Subtle grid
|
|
||||||
(function grid(){
|
|
||||||
const size=1000, step=10, y=0, col=new BABYLON.Color3(0.18,0.22,0.29);
|
|
||||||
for(let x=-size; x<=size; x+=step){
|
|
||||||
const l=BABYLON.MeshBuilder.CreateLines("gx",{points:[new BABYLON.Vector3(x,y,-size),new BABYLON.Vector3(x,y,size)]},scene);
|
|
||||||
l.color=col; l.isPickable=false;
|
|
||||||
}
|
|
||||||
for(let z=-size; z<=size; z+=step){
|
|
||||||
const l=BABYLON.MeshBuilder.CreateLines("gz",{points:[new BABYLON.Vector3(-size,y,z),new BABYLON.Vector3(size,y,z)]},scene);
|
|
||||||
l.color=col; l.isPickable=false;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
// ---------- RNG ----------
|
|
||||||
function mulberry32(a){ return function(){ var t=a+=0x6D2B79F5; t=Math.imul(t^t>>>15,t|1); t^=t+Math.imul(t^t>>>7,t|61); return ((t^t>>>14)>>>0)/4294967296; } }
|
|
||||||
function makeRand(seedStr){
|
|
||||||
const s = seedStr ? Array.from(String(seedStr)).reduce((acc,ch)=>acc+ch.charCodeAt(0),0) : Math.floor(Math.random()*1e9);
|
|
||||||
const r = mulberry32(s); return { f:(a,b)=>a+(b-a)*r(), i:(a,b)=>Math.floor(a+(b-a+1)*r()), s };
|
|
||||||
}
|
|
||||||
const querySeed = new URLSearchParams(location.search).get("seed");
|
|
||||||
|
|
||||||
// ---------- Helpers ----------
|
|
||||||
function wireRect(name, w, d, y, color3){
|
|
||||||
const hw = w/2, hd = d/2;
|
|
||||||
const pts = [
|
|
||||||
new BABYLON.Vector3(-hw, y, -hd),
|
|
||||||
new BABYLON.Vector3( hw, y, -hd),
|
|
||||||
new BABYLON.Vector3( hw, y, hd),
|
|
||||||
new BABYLON.Vector3(-hw, y, hd),
|
|
||||||
new BABYLON.Vector3(-hw, y, -hd) // close loop
|
|
||||||
];
|
|
||||||
const ln = BABYLON.MeshBuilder.CreateLines(name, { points: pts }, scene);
|
|
||||||
ln.color = color3; ln.isPickable = false;
|
|
||||||
return ln;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function wireBox(name,w,d,h,y,color3){
|
|
||||||
const box=BABYLON.MeshBuilder.CreateBox(name,{width:w,depth:d,height:h},scene);
|
|
||||||
const mat=new BABYLON.StandardMaterial(name+"_m",scene); mat.wireframe=true; mat.diffuseColor=color3; mat.specularColor=BABYLON.Color3.Black();
|
|
||||||
box.material=mat; box.position.y=y; box.isPickable=false; return box;
|
|
||||||
}
|
|
||||||
function wireEllipse(name,rx,rz,y,seg,color3){
|
|
||||||
const pts=[]; for(let i=0;i<=seg;i++){const a=(i/seg)*Math.PI*2; pts.push(new BABYLON.Vector3(rx*Math.cos(a),y,rz*Math.sin(a))); }
|
|
||||||
const ln=BABYLON.MeshBuilder.CreateLines(name,{points:pts,updatable:false},scene); ln.color=color3; ln.isPickable=false; return ln;
|
|
||||||
}
|
|
||||||
function addRib(name,x1,y1,z1,x2,y2,z2,color3){
|
|
||||||
const ln=BABYLON.MeshBuilder.CreateLines(name,{points:[new BABYLON.Vector3(x1,y1,z1),new BABYLON.Vector3(x2,y2,z2)]},scene);
|
|
||||||
ln.color=color3; ln.isPickable=false; return ln;
|
|
||||||
}
|
|
||||||
function addBin(name,x,y,z,color3){
|
|
||||||
const s=BABYLON.MeshBuilder.CreateSphere(name,{diameter:2.8,segments:10},scene);
|
|
||||||
const mat=new BABYLON.StandardMaterial(name+"_m",scene); mat.emissiveColor=color3; mat.diffuseColor=color3.scale(0.35); s.material=mat;
|
|
||||||
s.position.set(x,y,z);
|
|
||||||
const drag=new BABYLON.PointerDragBehavior({dragPlaneNormal:new BABYLON.Vector3(0,1,0)}); s.addBehavior(drag);
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Groups
|
|
||||||
const L1=new BABYLON.TransformNode("L1_Concourse",scene);
|
|
||||||
const L2=new BABYLON.TransformNode("L2_Mezzanine",scene);
|
|
||||||
const L3=new BABYLON.TransformNode("L3_Upper",scene);
|
|
||||||
const BINS=new BABYLON.TransformNode("Bins",scene);
|
|
||||||
const CAN_PARENT=new BABYLON.TransformNode("SmartCan",scene);
|
|
||||||
|
|
||||||
// Camera framing
|
|
||||||
function frameCamera(maxRadius,yCenter){ camera.alpha=Math.PI*1.25; camera.beta=1.05; camera.radius=Math.max(120,maxRadius*2.2); camera.setTarget(new BABYLON.Vector3(0,yCenter,0)); }
|
|
||||||
|
|
||||||
// ---------- Smart Can loader ----------
|
|
||||||
function splitURL(u){ const i=u.lastIndexOf('/'); return {root:u.slice(0,i+1), file:u.slice(i+1)}; }
|
|
||||||
|
|
||||||
function addMarker(x, y, z) {
|
|
||||||
// vertical neon line + torus ring
|
|
||||||
const ring = BABYLON.MeshBuilder.CreateTorus("canRing", {diameter: 8, thickness: 0.6, tessellation: 32}, scene);
|
|
||||||
const matR = new BABYLON.StandardMaterial("canRingM", scene); matR.emissiveColor = new BABYLON.Color3(1, 0.5, 0.1);
|
|
||||||
ring.material = matR; ring.position.set(x, y, z);
|
|
||||||
|
|
||||||
const line = BABYLON.MeshBuilder.CreateLines("canLine", {points:[
|
|
||||||
new BABYLON.Vector3(x, y, z),
|
|
||||||
new BABYLON.Vector3(x, y + 10, z)
|
|
||||||
]}, scene);
|
|
||||||
line.color = new BABYLON.Color3(1, 0.8, 0.2);
|
|
||||||
line.isPickable = false;
|
|
||||||
|
|
||||||
return ring;
|
|
||||||
}
|
|
||||||
async function loadSmartCanAt(x, y, z, yawDeg, scaleMeters){
|
|
||||||
const qs = new URLSearchParams(location.search);
|
|
||||||
const desiredH = parseFloat(qs.get("can_h") || "1.1"); // meters
|
|
||||||
const wireOn = (qs.get("can_wire") ?? "1") !== "0";
|
|
||||||
|
|
||||||
// NEW: manual rotation overrides (degrees)
|
|
||||||
const rxDeg = parseFloat(qs.get("can_rx") || "90");
|
|
||||||
const ryDeg = parseFloat(qs.get("can_ry") || "0");
|
|
||||||
const rzDeg = parseFloat(qs.get("can_rz") || "0");
|
|
||||||
|
|
||||||
const urls = [
|
|
||||||
"{% static 'pxy_building_digital_twins/models/smartcan.glb' %}",
|
|
||||||
"{% static 'pxy_building_digital_twins/models/smartcan.obj' %}"
|
|
||||||
];
|
|
||||||
|
|
||||||
async function tryUrl(url){
|
|
||||||
try { return (await BABYLON.SceneLoader.ImportMeshAsync("", "", url, scene)).meshes; }
|
|
||||||
catch {
|
|
||||||
const i = url.lastIndexOf("/"); const root = url.slice(0,i+1), file = url.slice(i+1);
|
|
||||||
try { return (await BABYLON.SceneLoader.ImportMeshAsync("", root, file, scene)).meshes; } catch { return null; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let loaded=null;
|
|
||||||
for (const u of urls){ loaded = await tryUrl(u); if (loaded && loaded.length) break; }
|
|
||||||
if (!loaded){ console.error("[smartcan] FAILED to load"); return; }
|
|
||||||
|
|
||||||
// Parent & visible
|
|
||||||
loaded.forEach(m => { if (!m.parent) m.parent = CAN_PARENT; if (!m.material){ const t=new BABYLON.StandardMaterial("canWhite",scene); t.emissiveColor=new BABYLON.Color3(0.95,0.95,0.95); t.diffuseColor=t.emissiveColor; m.material=t; } });
|
|
||||||
|
|
||||||
// Helper to compute bounds in current world transform
|
|
||||||
const bounds = () => {
|
|
||||||
let min=new BABYLON.Vector3(+Infinity,+Infinity,+Infinity),
|
|
||||||
max=new BABYLON.Vector3(-Infinity,-Infinity,-Infinity);
|
|
||||||
loaded.forEach(m => {
|
|
||||||
const bi = m.getBoundingInfo?.(); if (!bi) return;
|
|
||||||
min = BABYLON.Vector3.Minimize(min, bi.boundingBox.minimumWorld);
|
|
||||||
max = BABYLON.Vector3.Maximize(max, bi.boundingBox.maximumWorld);
|
|
||||||
});
|
|
||||||
return {min, max, size: max.subtract(min)};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Reset transforms
|
|
||||||
CAN_PARENT.position.set(0,0,0);
|
|
||||||
CAN_PARENT.rotationQuaternion = BABYLON.Quaternion.Identity();
|
|
||||||
CAN_PARENT.scaling.setAll(1);
|
|
||||||
|
|
||||||
// Auto-orient to make the tallest axis become Y
|
|
||||||
const cands = [
|
|
||||||
BABYLON.Quaternion.Identity(),
|
|
||||||
BABYLON.Quaternion.FromEulerAngles(Math.PI/2, 0, 0),
|
|
||||||
BABYLON.Quaternion.FromEulerAngles(-Math.PI/2, 0, 0),
|
|
||||||
BABYLON.Quaternion.FromEulerAngles(0, 0, Math.PI/2),
|
|
||||||
BABYLON.Quaternion.FromEulerAngles(0, 0, -Math.PI/2),
|
|
||||||
BABYLON.Quaternion.FromEulerAngles(Math.PI, 0, 0),
|
|
||||||
BABYLON.Quaternion.FromEulerAngles(0, 0, Math.PI)
|
|
||||||
];
|
|
||||||
let bestQ = cands[0], bestH = -1;
|
|
||||||
for (const q of cands){
|
|
||||||
CAN_PARENT.rotationQuaternion = q;
|
|
||||||
const b = bounds();
|
|
||||||
if (b.size.y > bestH){ bestH = b.size.y; bestQ = q; }
|
|
||||||
}
|
|
||||||
CAN_PARENT.rotationQuaternion = bestQ;
|
|
||||||
|
|
||||||
// Auto-scale height to desiredH, then apply user scaleMeters
|
|
||||||
let b0 = bounds();
|
|
||||||
const scaleAuto = b0.size.y > 0 ? desiredH / b0.size.y : 1;
|
|
||||||
const finalScale = scaleMeters * scaleAuto;
|
|
||||||
CAN_PARENT.scaling.setAll(finalScale);
|
|
||||||
|
|
||||||
// NEW: apply manual Euler corrections (degrees) BEFORE yaw
|
|
||||||
const rFix = BABYLON.Quaternion.FromEulerAngles(
|
|
||||||
BABYLON.Angle.FromDegrees(rxDeg).radians(),
|
|
||||||
BABYLON.Angle.FromDegrees(ryDeg).radians(),
|
|
||||||
BABYLON.Angle.FromDegrees(rzDeg).radians()
|
|
||||||
);
|
|
||||||
CAN_PARENT.rotationQuaternion = CAN_PARENT.rotationQuaternion.multiply(rFix);
|
|
||||||
|
|
||||||
// Apply yaw facing
|
|
||||||
const yawQ = BABYLON.Quaternion.FromEulerAngles(0, BABYLON.Angle.FromDegrees(yawDeg).radians(), 0);
|
|
||||||
CAN_PARENT.rotationQuaternion = CAN_PARENT.rotationQuaternion.multiply(yawQ);
|
|
||||||
|
|
||||||
// Recompute bounds AFTER all rotations+scaling, then sit the base on the floor
|
|
||||||
const b1 = bounds();
|
|
||||||
const lift = -b1.min.y; // already in world units now
|
|
||||||
CAN_PARENT.position.set(x, y + lift, z);
|
|
||||||
|
|
||||||
// Optional wireframe overlay
|
|
||||||
if (wireOn){
|
|
||||||
loaded.forEach(m => {
|
|
||||||
if (!m.geometry) return;
|
|
||||||
const wf = m.clone(m.name+"_wf");
|
|
||||||
const mat = new BABYLON.StandardMaterial("wfM", scene);
|
|
||||||
mat.wireframe = true; mat.emissiveColor = new BABYLON.Color3(0.05,0.05,0.05);
|
|
||||||
wf.material = mat; wf.isPickable = false;
|
|
||||||
wf.parent = m.parent;
|
|
||||||
wf.scaling = m.scaling.multiply(new BABYLON.Vector3(1.001,1.001,1.001));
|
|
||||||
wf.rotationQuaternion = m.rotationQuaternion?.clone() || BABYLON.Quaternion.Identity();
|
|
||||||
wf.position = m.position.clone();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ---------- Builder ----------
|
|
||||||
function buildStadium(seedStr){
|
|
||||||
[L1,L2,L3,BINS].forEach(n => n.getChildren().slice().forEach(ch => ch.dispose()));
|
|
||||||
CAN_PARENT.getChildren().slice().forEach(ch => ch.dispose());
|
|
||||||
|
|
||||||
const R=makeRand(seedStr), rf=R.f, ri=R.i;
|
|
||||||
|
|
||||||
// Heights
|
|
||||||
const Y_FIELD=0.2, Y_CONC=2.0, Y_MEZZ=8.0, Y_STAND=14.0;
|
|
||||||
|
|
||||||
// Field
|
|
||||||
const FIELD_W=rf(100,110), FIELD_D=rf(64,72);
|
|
||||||
wireRect("field", FIELD_W, FIELD_D, Y_FIELD, BABYLON.Color3.FromHexString("#16a34a"));
|
|
||||||
|
|
||||||
|
|
||||||
// Rings
|
|
||||||
const CONC_RX_IN=rf(62,68), CONC_RZ_IN=rf(56,64);
|
|
||||||
const CONC_RX_OUT=CONC_RX_IN+rf(6,10), CONC_RZ_OUT=CONC_RZ_IN+rf(6,10);
|
|
||||||
|
|
||||||
const MEZZ_RX_IN=CONC_RX_OUT+rf(2,4), MEZZ_RZ_IN=CONC_RZ_OUT+rf(2,4);
|
|
||||||
const MEZZ_RX_OUT=MEZZ_RX_IN+rf(8,12), MEZZ_RZ_OUT=MEZZ_RZ_IN+rf(8,12);
|
|
||||||
|
|
||||||
const STANDS_RX_IN=MEZZ_RX_OUT+rf(3,5), STANDS_RZ_IN=MEZZ_RZ_OUT+rf(3,5);
|
|
||||||
const STANDS_RX_OUT=STANDS_RX_IN+rf(18,26), STANDS_RZ_OUT=STANDS_RZ_IN+rf(18,26);
|
|
||||||
|
|
||||||
wireEllipse("conc_in", CONC_RX_IN, CONC_RZ_IN, Y_CONC, 256, new BABYLON.Color3(0.10,0.94,0.86)).parent=L1;
|
|
||||||
wireEllipse("conc_out", CONC_RX_OUT, CONC_RZ_OUT, Y_CONC, 256, new BABYLON.Color3(0.00,0.75,1.00)).parent=L1;
|
|
||||||
wireEllipse("mezz_in", MEZZ_RX_IN, MEZZ_RZ_IN, Y_MEZZ, 256, new BABYLON.Color3(0.98,0.84,0.22)).parent=L2;
|
|
||||||
wireEllipse("mezz_out", MEZZ_RX_OUT, MEZZ_RZ_OUT, Y_MEZZ, 256, new BABYLON.Color3(1.00,0.66,0.10)).parent=L2;
|
|
||||||
wireEllipse("stand_in", STANDS_RX_IN, STANDS_RZ_IN, Y_STAND, 256, new BABYLON.Color3(0.80,0.52,1.00)).parent=L3;
|
|
||||||
wireEllipse("stand_out", STANDS_RX_OUT, STANDS_RZ_OUT, Y_STAND, 256, new BABYLON.Color3(0.62,0.82,1.00)).parent=L3;
|
|
||||||
|
|
||||||
// Ribs
|
|
||||||
const ribCol=new BABYLON.Color3(0.10,0.85,0.75);
|
|
||||||
const ribs1=ri(12,18);
|
|
||||||
for(let i=0;i<ribs1;i++){
|
|
||||||
const a=i/ribs1*Math.PI*2+rf(-0.03,0.03);
|
|
||||||
const x1=CONC_RX_OUT*Math.cos(a), z1=CONC_RZ_OUT*Math.sin(a);
|
|
||||||
const x2=MEZZ_RX_IN*Math.cos(a), z2=MEZZ_RZ_IN*Math.sin(a);
|
|
||||||
const ln=addRib("rib12_"+i, x1,Y_CONC,z1, x2,Y_MEZZ,z2, ribCol); ln.parent=L1;
|
|
||||||
}
|
|
||||||
const ribs2=ri(12,18);
|
|
||||||
for(let i=0;i<ribs2;i++){
|
|
||||||
const a=i/ribs2*Math.PI*2+rf(-0.03,0.03);
|
|
||||||
const x1=MEZZ_RX_OUT*Math.cos(a), z1=MEZZ_RZ_OUT*Math.sin(a);
|
|
||||||
const x2=STANDS_RX_IN*Math.cos(a), z2=STANDS_RZ_IN*Math.sin(a);
|
|
||||||
const ln=addRib("rib23_"+i, x1,Y_MEZZ,z1, x2,Y_STAND,z2, ribCol); ln.parent=L2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bins
|
|
||||||
const midRX=(CONC_RX_IN+CONC_RX_OUT)/2, midRZ=(CONC_RZ_IN+CONC_RZ_OUT)/2;
|
|
||||||
const colors=[new BABYLON.Color3(0.20,1.00,0.65), new BABYLON.Color3(0.10,0.80,1.00), new BABYLON.Color3(0.95,0.55,1.00), new BABYLON.Color3(1.00,0.70,0.25)];
|
|
||||||
const count=ri(40,60);
|
|
||||||
for(let i=0;i<count;i++){
|
|
||||||
const a=i/count*Math.PI*2+rf(-0.05,0.05);
|
|
||||||
const x=midRX*Math.cos(a), z=midRZ*Math.sin(a);
|
|
||||||
const s=addBin("bin_"+i, x, Y_CONC+0.5, z, colors[i%colors.length]); s.parent=BINS;
|
|
||||||
}
|
|
||||||
|
|
||||||
const angleRad = canAngleDeg * Math.PI/180;
|
|
||||||
const canX = midRX * Math.cos(angleRad);
|
|
||||||
const canZ = midRZ * Math.sin(angleRad);
|
|
||||||
const canScaleMeters = unitToMeter * canScaleUser;
|
|
||||||
|
|
||||||
// 🔶 drop a neon marker where the can should be (so you know the spot)
|
|
||||||
addMarker(canX, Y_CONC + canYOffset, canZ);
|
|
||||||
|
|
||||||
// log what we’re about to do
|
|
||||||
console.log("[smartcan] pos=", {x: canX, y: Y_CONC + canYOffset, z: canZ},
|
|
||||||
"yawDeg=", -canAngleDeg, "scaleMeters=", canScaleMeters);
|
|
||||||
|
|
||||||
// load the model there
|
|
||||||
loadSmartCanAt(canX, Y_CONC + canYOffset, canZ, -canAngleDeg, canScaleMeters);
|
|
||||||
|
|
||||||
// Frame camera
|
|
||||||
const maxR=Math.max(STANDS_RX_OUT, STANDS_RZ_OUT);
|
|
||||||
frameCamera(maxR, (Y_CONC + Y_STAND)/2);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build + UI
|
|
||||||
buildStadium(new URLSearchParams(location.search).get("seed"));
|
|
||||||
const $=q=>document.querySelector(q);
|
|
||||||
$("#chkL1").onchange=e=>L1.setEnabled(e.target.checked);
|
|
||||||
$("#chkL2").onchange=e=>L2.setEnabled(e.target.checked);
|
|
||||||
$("#chkL3").onchange=e=>L3.setEnabled(e.target.checked);
|
|
||||||
$("#chkBins").onchange=e=>BINS.setEnabled(e.target.checked);
|
|
||||||
$("#btnRegen").onclick=()=>buildStadium(Math.floor(Math.random()*1e9).toString());
|
|
||||||
$("#btnResetCam").onclick=()=>frameCamera(200,10);
|
|
||||||
|
|
||||||
engine.runRenderLoop(()=>scene.render());
|
|
||||||
window.addEventListener('resize',()=>engine.resize());
|
|
||||||
|
|
||||||
// Open Leaflet viewer, preserving current query params
|
|
||||||
document.getElementById('btnViewer').onclick = () => {
|
|
||||||
const params = new URLSearchParams(location.search);
|
|
||||||
if (!params.has('seed')) params.set('seed', Date.now()); // keep determinism if you provided one
|
|
||||||
location.href = `/building/twin/viewer/?${params.toString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
@ -1,3 +0,0 @@
|
|||||||
from django.test import TestCase
|
|
||||||
|
|
||||||
# Create your tests here.
|
|
@ -1,13 +0,0 @@
|
|||||||
# pxy_building_digital_twins/urls.py
|
|
||||||
from django.urls import path
|
|
||||||
from . import views
|
|
||||||
|
|
||||||
# 👇 Register a namespace for reverse()
|
|
||||||
app_name = "pxy_building_digital_twins"
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
path("twin/viewer/", views.viewer, name="viewer"),
|
|
||||||
path("api/random/stadium/", views.api_random_stadium, name="api_random_stadium"),
|
|
||||||
path("twin/wire/", views.wire_viewer, name="wire_viewer"),
|
|
||||||
path("twin/wire/babylon/", views.wire_babylon, name="wire_babylon"),
|
|
||||||
]
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user