Compare commits

...

No commits in common. "428697cd24dc810ecd858c63f124c2adbedcfd15" and "main" have entirely different histories.

5217 changed files with 1318749 additions and 349 deletions

51
.drone.yml Normal file
View File

@ -0,0 +1,51 @@
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
View File

@ -1,30 +0,0 @@
# 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

32
.gitignore vendored Normal file
View File

@ -0,0 +1,32 @@
# 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

0
.gitmodules vendored Normal file
View File

View File

@ -1,17 +1,40 @@
# Use official Python image
FROM python:3.10
# Etapa base: Python oficial
FROM python:3.10-slim as base
# Set working directory
# Variables de entorno para producción
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
# Instala dependencias del sistema (incluye lo necesario para mysqlclient)
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
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/*
# Crea directorio de trabajo
WORKDIR /app
# Copy application code
COPY . .
# Copia requirements primero (mejor cacheo)
COPY requirements.txt .
# Install dependencies
# Instala dependencias Python
RUN pip install --no-cache-dir -r requirements.txt
# Expose the application port
# Copia el resto del proyecto
COPY . .
# Expone el puerto del contenedor
EXPOSE 8000
# Run Gunicorn server
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "polisplexity.wsgi:application"]
# Comando por defecto para producción con Gunicorn
CMD ["gunicorn", "polisplexity.wsgi:application", "--bind", "0.0.0.0:8000", "--workers=3", "--timeout=120"]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
{"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": []}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,66 +1,53 @@
{% extends 'base.html' %}
{% extends 'pxy_dashboard/partials/base.html' %}
{% load static %}
{% 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 %}
<div class="container mt-4">
<h2 class="text-center mb-4">Welcome to Polisplexity</h2>
<p class="text-center mb-4">Select one of the options Bellow</p>
<div class="container-fluid">
<div class="row justify-content-center">
<div class="col-12 text-center mb-4">
<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 %}
{% if not forloop.first %}
<hr> <!-- Horizontal separator for every new category except the first one -->
{% endif %}
<div class="row">
<div class="col-12 col-md-3 mb-4">
<h3> {{ category.name }}</h3>
<small> {{ category.description }}</small>
<div class="row mt-4">
<div class="col-12">
<hr class="border-secondary">
</div>
</div>
{% 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">
{% for menu_item in items %}
<div class="col-12 col-md-3 mb-4">
<div class="card h-100">
{% if menu_item.image %}
<!-- Display Image if available -->
<img src="{{ menu_item.image.url }}" class="card-img-top" alt="{{ menu_item.title }}">
{% elif menu_item.icon %}
<!-- Display Icon if image is not available but icon is -->
<div class="card-header text-center">
<span class="material-symbols-rounded md-48">{{ menu_item.icon }}</span>
</div>
{% endif %}
<div class="card-body">
<h5 class="card-title">{{ menu_item.title }}</h5>
<p class="card-text">{{ menu_item.description }}</p>
</div>
<div class="card-footer bg-white">
<a href="{{ menu_item.url }}" class="btn btn-primary">{{ menu_item.url_text }}</a>
<div class="col-sm-6 col-lg-3">
<div class="card d-block">
{% if menu_item.image %}
<img class="card-img-top" src="{{ menu_item.image.url }}" alt="{{ menu_item.title }}">
{% elif menu_item.icon %}
<div class="card-header text-center bg-light-subtle">
<span class="material-symbols-rounded display-4 text-primary">{{ menu_item.icon }}</span>
</div>
{% endif %}
<div class="card-body">
<h5 class="card-title">{{ menu_item.title }}</h5>
<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>
</div>
{% endfor %}
</div>
{% endfor %}
</div>
{% endblock content %}
{% block extra_js %}
<script>
$(function () {
$('[data-bs-toggle="tooltip"]').tooltip() // Initialize Bootstrap tooltips
})
</script>
{% endblock extra_js %}

Binary file not shown.

View File

@ -5,12 +5,12 @@ services:
image: postgres:15
container_name: polisplexity_postgres
restart: always
ports:
- "5434:5432"
volumes:
- pgdata:/var/lib/postgresql/data
env_file:
- .env
ports:
- "5434:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s
@ -24,16 +24,21 @@ services:
depends_on:
db:
condition: service_healthy
volumes:
- .:/app # Ensure correct project structure
ports:
- "8010:8001"
- "8010:8002"
env_file:
- .env
command: >
sh -c "python manage.py migrate &&
python manage.py collectstatic --noinput &&
exec gunicorn --bind 0.0.0.0:8001 polisplexity.wsgi:application"
gunicorn polisplexity.wsgi:application --bind 0.0.0.0:8002 --workers=2 --timeout=180"
volumes:
- static_data:/app/static
- media_data:/app/media
- ./staticfiles:/app/staticfiles
# - .:/app # ←❌ No lo uses en producción: desactiva para evitar sobrescribir
volumes:
pgdata:
static_data:
media_data:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

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 one or more lines are too long

View File

@ -1,43 +1,40 @@
"""
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
import os
import dj_database_url
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
BASE_URL = "https://app.polisplexity.tech"
# SECURITY WARNING: keep the secret key used in production secret!
import sys
sys.path.append(str(BASE_DIR))
# Core security settings
SECRET_KEY = os.getenv("SECRET_KEY")
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = os.getenv("DEBUG") == "True"
DEBUG = os.getenv("DEBUG", "False") == "True"
ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",")
# Application definition
INSTALLED_APPS = [
# Django built-in apps
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"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",
"pxy_de",
"pxy_cr",
@ -48,14 +45,40 @@ INSTALLED_APPS = [
"pxy_meta_pages",
"pxy_langchain",
"pxy_neo4j",
"pxy_dashboard",
"pxy_dashboard.custom",
"pxy_dashboard.apps",
"pxy_dashboard.components",
"pxy_dashboard.layouts",
# 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 = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"allauth.account.middleware.AccountMiddleware",
"pxy_dashboard.middleware.LoginRequiredMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
@ -65,7 +88,10 @@ ROOT_URLCONF = "polisplexity.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "templates")],
"DIRS": [
os.path.join(BASE_DIR, "templates"),
os.path.join(BASE_DIR, "pxy_dashboard", "templates"),
],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
@ -73,14 +99,17 @@ TEMPLATES = [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"pxy_dashboard.context_processors.sidebar_context",
],
},
},
]
WSGI_APPLICATION = "polisplexity.wsgi.application"
# Database Configuration
# Database
DATABASES = {
"default": dj_database_url.config(default=os.getenv("DATABASE_URL"))
}
@ -99,29 +128,50 @@ TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static & Media Files
STATIC_URL = "/static/"
STATIC_ROOT = os.path.join(BASE_DIR, "static") # Ensure this line is correct
STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
# Add this if missing
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') # Fixes mixed content issues
CSRF_TRUSTED_ORIGINS = ['https://app.polisplexity.tech'] # Allow CSRF over HTTPS
STATICFILES_DIRS = [
os.path.join(BASE_DIR, "polisplexity/pxy_dashboard/static"), # Jidox assets
]
MEDIA_URL = "/media/"
MEDIA_ROOT = os.getenv("MEDIA_ROOT", os.path.join(BASE_DIR, "media"))
MEDIA_ROOT = os.path.join(BASE_DIR, "mediafiles")
# Default primary key field type
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Facebook API Tokens
# External services
PAGE_ACCESS_TOKEN = os.getenv("PAGE_ACCESS_TOKEN")
VERIFY_TOKEN = os.getenv("VERIFY_TOKEN")
# Async-safe for Neo4j or Celery
os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true"
# ...pero silenciamos la comprobación que falla en producción:
SILENCED_SYSTEM_CHECKS = ["async.E001"]
# Neo4j Database Configuration
# Neo4j
NEO4J_URI = os.getenv("NEO4J_URI")
NEO4J_USERNAME = os.getenv("NEO4J_USERNAME")
NEO4J_PASSWORD = os.getenv("NEO4J_PASSWORD")
# OpenAI API Key
# OpenAI
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>"

View File

@ -25,12 +25,15 @@ admin.site.index_title = "Welcome to Polisplexity City Technologies Portal"
urlpatterns = [
path("admin/", admin.site.urls),
path('', include('core.urls')),
path("accounts/", include("allauth.urls")), # ← Add this line
path('', include('pxy_dashboard.urls')),
path('core', include('core.urls')),
path('', include('pxy_city_digital_twins.urls')),
path('pxy_whatsapp/', include('pxy_whatsapp.urls')),
path('bots/', include('pxy_bots.urls')), # Webhook URL: /bots/webhook/<bot_name>/
path('bots/', include('pxy_bots.urls')),
path('pxy_meta_pages/', include('pxy_meta_pages.urls', namespace='pxy_meta_pages')),
]
if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@ -1,21 +1,26 @@
from django.contrib import admin
from .models import TelegramBot
from django.utils.html import format_html
@admin.register(TelegramBot)
class TelegramBotAdmin(admin.ModelAdmin):
list_display = ("name", "username", "is_active", "get_assistant_name", "set_webhook_action")
list_display = ("name", "username", "is_active", "get_assistant_name")
search_fields = ("name", "username")
list_filter = ("is_active",)
actions = ["set_webhooks"]
@admin.action(description="Set webhooks for selected bots")
def set_webhooks(self, request, queryset):
base_url = request.build_absolute_uri("/")[:-1] # Get base server URL
results = []
base_url = f"{request.scheme}://{request.get_host()}"
for bot in queryset:
if bot.is_active:
try:
if not bot.assistant:
self.message_user(
request,
f"Bot {bot.name} has no assistant configured.",
level="warning",
)
continue
result = bot.set_webhook(base_url)
self.message_user(
request,
@ -28,7 +33,6 @@ class TelegramBotAdmin(admin.ModelAdmin):
f"Failed to set webhook for {bot.name}: {str(e)}",
level="error",
)
results.append(result)
else:
self.message_user(
request,
@ -39,14 +43,4 @@ class TelegramBotAdmin(admin.ModelAdmin):
def get_assistant_name(self, obj):
"""Show the name of the assistant linked to the bot."""
return obj.assistant.name if obj.assistant else "None"
get_assistant_name.short_description = "Assistant Name"
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}/"
)
set_webhook_action.short_description = "Webhook"

View File

@ -0,0 +1,11 @@
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"
]

View File

@ -1,7 +1,7 @@
from telegram import Update, ForceReply
import logging
from pxy_openai.assistants import OpenAIAssistant
from .models import TelegramBot
from pxy_bots.models import TelegramBot
from asgiref.sync import sync_to_async
from pxy_langchain.models import AIAssistant
from pxy_langchain.services import LangchainAIService
@ -33,18 +33,26 @@ async def help_command(update: Update):
user = update.effective_user
await update.message.reply_text(f"Help! How can I assist you, {user.first_name}?")
async def handle_location(update: Update):
"""Respond to a location message."""
location = update.message.location
if location:
await update.message.reply_text(
f"Thanks for sharing your location! Latitude: {location.latitude}, Longitude: {location.longitude}"
)
lat = location.latitude
lon = 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:
await update.message.reply_text("Please share your location.")
async def respond(update, bot_name):
"""Respond to user messages using the LangChain AI service."""
try:

View File

@ -0,0 +1,30 @@
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(
f"🚛 El camión pasa por tu zona ({lat}, {lon}) mañana a las 8:00 AM.")
else:
keyboard = [[KeyboardButton("📍 Enviar ubicación", request_location=True)]]
markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True, one_time_keyboard=True)
await update.message.reply_text(
"Mándame tu ubicación para decirte cuándo pasa el camión.",
reply_markup=markup)
async def report_trash(update: Update):
user_text = update.message.text
await update.message.reply_text(f"🌱 Calculé tu CO₂ por '{user_text}' y te di 5 Monedas Verdes. ¡Chido!")
async def private_pickup(update: Update):
if update.message.location:
await update.message.reply_text("🛵 ¡Pepe de la motito va en camino! Llega en 10 min.")
else:
keyboard = [[KeyboardButton("📍 Enviar ubicación", request_location=True)]]
markup = ReplyKeyboardMarkup(keyboard, resize_keyboard=True, one_time_keyboard=True)
await update.message.reply_text(
"Mándame tu ubicación para buscar un reco privado.",
reply_markup=markup)
async def green_balance(update: Update):
await update.message.reply_text("💰 Llevas 23 Monedas Verdes acumuladas y evitaste 15 kg CO₂.")

View File

@ -0,0 +1,13 @@
from telegram import Update
async def next_route(update: Update):
await update.message.reply_text("🚛 Hoy te toca: Calle Reforma, Av. Juárez y Callejón Verde.")
async def complete_stop(update: Update):
await update.message.reply_text("✅ Parada marcada como recolectada. ¡Buen trabajo!")
async def missed_stop(update: Update):
await update.message.reply_text("🚧 Parada marcada como NO recolectada.")
async def my_eco_score(update: Update):
await update.message.reply_text("🌿 Llevas 120 Monedas Verdes este mes por tu eficiencia.")

View File

@ -0,0 +1,16 @@
from telegram import Update
async def available_jobs(update: Update):
await update.message.reply_text("📝 Hay 2 chambas cerca: Calle Pino y Calle Limón.")
async def accept_job(update: Update):
await update.message.reply_text("👌 Chamba aceptada. Ve a Calle Pino 123.")
async def next_pickup(update: Update):
await update.message.reply_text("➡️ Tu siguiente recolección es en Calle Limón 45.")
async def complete_pickup(update: Update):
await update.message.reply_text("✅ ¡Recolección completada! Te ganaste 3 Monedas Verdes.")
async def my_eco_score(update: Update):
await update.message.reply_text("🏆 Tienes 45 Monedas Verdes acumuladas este mes.")

View File

@ -0,0 +1,34 @@
# 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')),
],
),
]

View File

@ -1,3 +1,4 @@
import requests
from django.db import models
from pxy_langchain.models import AIAssistant # Now referencing LangChain AI assistants
@ -45,3 +46,40 @@ class TelegramBot(models.Model):
return response.json()
else:
raise ValueError(f"Failed to set webhook for {self.name}: {response.json()}")
from django.db import models
class TelegramConversation(models.Model):
bot = models.ForeignKey(
'TelegramBot',
on_delete=models.CASCADE,
related_name='conversations'
)
user_id = models.CharField(max_length=64)
started_at = models.DateTimeField(auto_now_add=True)
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)
def __str__(self):
return f"[{self.direction}] {self.content[:30]}"

View File

@ -1,16 +0,0 @@
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())

View File

@ -1,68 +1,164 @@
import os
import json
import logging
import openai
from telegram import Update, Bot
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from asgiref.sync import sync_to_async
from .models import TelegramBot
from pxy_langchain.services import LangchainAIService
from .handlers import dream_city_command, start, help_command, handle_location
import logging
from .handlers import (
start, help_command, handle_location,
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
)
logger = logging.getLogger(__name__)
openai.api_key = os.getenv("OPENAI_API_KEY")
async def handle_location_message(update):
if update.message.location:
await handle_location(update)
return True
return False
async def dispatch_citizen_commands(update, text):
if text == "/start":
await start(update)
elif text == "/help":
await help_command(update)
elif text == "/next_truck":
await next_truck(update)
elif text == "/report_trash":
await report_trash(update)
elif text == "/private_pickup":
await private_pickup(update)
elif text == "/green_balance":
await green_balance(update)
else:
return False
return True
async def dispatch_city_commands(update, text):
if text == "/start":
await start(update)
elif text == "/help":
await help_command(update)
elif text == "/next_route":
await next_route(update)
elif text == "/complete_stop":
await complete_stop(update)
elif text == "/missed_stop":
await missed_stop(update)
elif text == "/my_eco_score":
await city_eco_score(update)
else:
return False
return True
async def dispatch_private_commands(update, text):
if text == "/start":
await start(update)
elif text == "/help":
await help_command(update)
elif text == "/available_jobs":
await available_jobs(update)
elif text.startswith("/accept_job"):
await accept_job(update)
elif text == "/next_pickup":
await next_pickup(update)
elif text == "/complete_pickup":
await complete_pickup(update)
elif text == "/my_eco_score":
await private_eco_score(update)
else:
return False
return True
async def transcribe_with_whisper(update, bot):
# 1) Descarga el audio
tg_file = await bot.get_file(update.message.voice.file_id)
download_path = f"/tmp/{update.message.voice.file_id}.ogg"
await tg_file.download_to_drive(download_path)
# 2) Llama al endpoint de transcripción
with open(download_path, "rb") as audio:
# Como response_format="text", esto retorna un str
transcript_str = openai.audio.transcriptions.create(
model="gpt-4o-transcribe", # o "whisper-1"
file=audio,
response_format="text",
language="es"
)
return transcript_str.strip()
@csrf_exempt
async def telegram_webhook(request, bot_name):
"""
Webhook view that handles Telegram updates asynchronously and only uses LangChain.
"""
try:
logger.info(f"Webhook called for bot: {bot_name}")
# Step 1: Fetch the bot instance asynchronously
# Carga bot (solo ORM en sync_to_async)
try:
bot_instance = await sync_to_async(TelegramBot.objects.get)(name=bot_name, is_active=True)
logger.info(f"Loaded bot configuration: {bot_instance}")
bot_instance = await sync_to_async(TelegramBot.objects.get)(
name=bot_name, is_active=True
)
except TelegramBot.DoesNotExist:
logger.error(f"Bot '{bot_name}' not found or inactive.")
return JsonResponse({"error": f"Bot '{bot_name}' not found."}, status=400)
# 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)
if request.method != "POST":
return JsonResponse({"error": "Invalid request method"}, status=400)
# Step 3: Process POST request from Telegram
if request.method == "POST":
try:
request_body = json.loads(request.body.decode("utf-8"))
update = Update.de_json(request_body, Bot(token=bot_instance.token))
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)
payload = json.loads(request.body.decode("utf-8"))
update = Update.de_json(payload, Bot(token=bot_instance.token))
# Step 4: Route commands to the appropriate handlers
if update.message:
if update.message.text == "/start":
await start(update)
elif update.message.text == "/help":
await help_command(update)
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)
# Step 6: Send the response back to Telegram
await update.message.reply_text(bot_response)
if not update.message:
return JsonResponse({"status": "no message"})
# 1) Geolocalización
if await handle_location_message(update):
return JsonResponse({"status": "ok"})
logger.warning("Received non-POST request")
return JsonResponse({"error": "Invalid request method"}, status=400)
# 2) Voz: transcribe y report_trash
if update.message.voice:
bot = Bot(token=bot_instance.token)
transcript = await transcribe_with_whisper(update, bot)
if not transcript:
await update.message.reply_text(
"No pude entender tu mensaje de voz. Intenta de nuevo."
)
return JsonResponse({"status": "ok"})
assistant_instance = await sync_to_async(LangchainAIService)(bot_instance.assistant)
bot_response = await sync_to_async(assistant_instance.generate_response)(transcript)
await update.message.reply_text(bot_response)
return JsonResponse({"status": "ok"})
# 3) Comandos de texto
text = update.message.text or ""
if bot_name == "PepeBasuritaCoinsBot" and await dispatch_citizen_commands(update, text):
return JsonResponse({"status": "ok"})
if bot_name == "PepeCamioncitoBot" and await dispatch_city_commands(update, text):
return JsonResponse({"status": "ok"})
if bot_name == "PepeMotitoBot" and await dispatch_private_commands(update, text):
return JsonResponse({"status": "ok"})
# 4) Fallback LLM
assistant_instance = await sync_to_async(LangchainAIService)(bot_instance.assistant)
bot_response = await sync_to_async(assistant_instance.generate_response)(text)
await update.message.reply_text(bot_response)
return JsonResponse({"status": "ok"})
except Exception as e:
logger.error(f"Error in webhook: {e}")

@ -1 +0,0 @@
Subproject commit bf4767d280cb31a73b62ebb6ff912c604423de52

View File

@ -0,0 +1,75 @@
# Polisplexity Digital Twin Viewer
This application is a Django-based 3D digital twin city renderer using A-Frame and real-world OpenStreetMap (OSM) data. It allows visualization of buildings, fiber paths, cell towers, and other urban infrastructure in a simulated, interactive WebVR environment.
## ✨ Features
- 🔲 **Building extrusion from OSM**: Downloads building footprints with geometry and height/levels metadata and extrudes them into 3D blocks.
- 🛰️ **Street network rendering**: Downloads local driving network and represents it visually as 3D fiber links.
- 🏙️ **Recentered city layout**: All elements are normalized to a `(0,0)` coordinate center and scaled down to allow a birds-eye view or giant-perspective simulation.
- 📡 **A-Frame-based environment**: Uses `aframe-environment-component` for sky, lighting, ground, and interactions.
- 🎯 **Status gauges**: Each building displays a status gauge with a rotating ring and transparent glass core, labeled with mock status data.
- 🧠 **Per-entity click interaction**: Clicking on a gauge changes its color and toggles the status (mocked).
- 🌐 **Dynamic generation by coordinates**: Any city view can be created dynamically via URL parameters like `lat`, `long`, and `scale`.
## 🏗️ Stack
| Component | Technology |
|--------------------|-----------------------------|
| Backend | Django 5.x |
| Mapping API | `osmnx`, `shapely`, `geopandas` |
| Frontend (3D) | A-Frame 1.7.0 |
| Visualization Libs | `aframe-environment-component` |
| Deployment Ready? | Yes, via Docker + Gunicorn |
## 🔌 Example Usage
To load a city block from Centro Histórico, Mexico City:
```
[http://localhost:8001/city/digital/twin/osm\_city/?lat=19.391097\&long=-99.157815\&scale=0.1](http://localhost:8001/city/digital/twin/osm_city/?lat=19.391097&long=-99.157815&scale=0.1)
````
## 🧪 Directory Highlights
- `pxy_city_digital_twins/views.py`: Request handler that decides which generator to use (`osm_city`, `random_city`, etc.)
- `services/osm_city.py`: Main generator for real-world urban geometry based on lat/lon.
- `templates/pxy_city_digital_twins/city_digital_twin.html`: A-Frame scene renderer.
- `templates/pxy_city_digital_twins/_status_gauge.html`: UI fragment for interactive gauges on city elements.
## 📦 Dependencies
Add these to `requirements.txt`:
```txt
osmnx>=1.9.3
shapely
geopandas
````
Optional (for better performance in prod):
```txt
gunicorn
dj-database-url
```
## 🚧 To-Do
* [ ] Load `status` from a real database or agent simulation
* [ ] Add 3D models (e.g., trees, street furniture)
* [ ] Support texture-mapped facades
* [ ] Add time-based simulation / animation
* [ ] Integrate sensor/IoT mock data stream
## 👀 Screenshot
> *Coming soon* — consider generating A-Frame scene screenshots automatically using headless browser tools.
---
**Maintained by [Hadox Research Labs](https://hadox.org)**

View File

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class PxyCityDigitalTwinsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "pxy_city_digital_twins"

View File

@ -0,0 +1,25 @@
# Generated by Django 5.0.3 on 2025-05-21 21:44
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='WastePickup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('subdivision', models.CharField(max_length=100)),
('vehicle', models.CharField(max_length=50)),
('step_id', models.CharField(max_length=50)),
('quarter', models.PositiveSmallIntegerField()),
('timestamp', models.DateTimeField(auto_now_add=True)),
],
),
]

View File

@ -0,0 +1,11 @@
from django.db import models
class WastePickup(models.Model):
subdivision = models.CharField(max_length=100)
vehicle = models.CharField(max_length=50)
step_id = models.CharField(max_length=50)
quarter = models.PositiveSmallIntegerField() # 1 through 4
timestamp = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"{self.subdivision}/{self.vehicle}/{self.step_id} → Q{self.quarter} @ {self.timestamp}"

View File

@ -0,0 +1,155 @@
import random
from .network import compute_mst_fiber_paths, compute_network_summary
GRID_SIZE = 5
SPACING = 15 # Distance between objects
def generate_com_con_city_data(lat, long):
"""
Generate a digital twin for a real-world city (e.g., Concepción).
Returns towers, fiber paths, wifi hotspots, and a summary.
"""
random.seed(f"{lat},{long}")
center_x = lat
center_z = long
towers = generate_towers(center_x, center_z)
fiber_paths = compute_mst_fiber_paths(towers)
wifi_hotspots = generate_wifi_hotspots(center_x, center_z)
summary = compute_network_summary(towers, fiber_paths, wifi_hotspots)
return {
'towers': towers,
'fiber_paths': fiber_paths,
'wifi_hotspots': wifi_hotspots,
'network_summary': summary,
}
def generate_towers(center_x, center_z, mode="streets"):
"""
Generate towers either in a 'grid' or at realistic 'streets' (mocked).
mode: "grid" | "streets"
"""
if mode == "streets":
return generate_street_corner_towers(center_x, center_z)
else:
return generate_grid_towers(center_x, center_z)
import osmnx as ox
def generate_street_corner_towers(center_x, center_z, min_towers=10):
"""
Get real intersections from OSM and convert them to local x/z positions
relative to center_x / center_z (in meters). Fallbacks to mocked layout if needed.
"""
print("📍 Starting generate_street_corner_towers()")
print(f"→ center_x: {center_x}, center_z: {center_z}")
point = (center_x, center_z)
print(f"→ Using real lat/lon: {point}")
try:
for dist in [100, 200, 500, 1000]:
print(f"🛰️ Trying OSM download at radius: {dist} meters...")
G = ox.graph_from_point(point, dist=dist, network_type='all')
G_undirected = G.to_undirected()
degrees = dict(G_undirected.degree())
intersections = [n for n, d in degrees.items() if d >= 3]
print(f" ✅ Found {len(intersections)} valid intersections.")
if len(intersections) >= min_towers:
break
else:
raise ValueError("No sufficient intersections found.")
nodes, _ = ox.graph_to_gdfs(G)
origin_lon = nodes.loc[intersections]['x'].mean()
origin_lat = nodes.loc[intersections]['y'].mean()
print(f"📌 Using origin_lon: {origin_lon:.6f}, origin_lat: {origin_lat:.6f} for local projection")
def latlon_to_sim(lon, lat):
dx = (lon - origin_lon) * 111320
dz = (lat - origin_lat) * 110540
return center_x + dx, center_z + dz
towers = []
for i, node_id in enumerate(intersections):
row = nodes.loc[node_id]
x_sim, z_sim = latlon_to_sim(row['x'], row['y'])
print(f" 🗼 Tower #{i+1} at sim position: x={x_sim:.2f}, z={z_sim:.2f}")
towers.append(make_tower(x_sim, z_sim, i + 1))
print(f"✅ Done. Total towers returned: {len(towers)}\n")
return towers
except Exception as e:
print(f"❌ OSM tower generation failed: {e}")
print("⚠️ Falling back to mocked tower layout.")
# Return 3x3 fixed grid as fallback
offsets = [(-30, -30), (-30, 0), (-30, 30),
(0, -30), (0, 0), (0, 30),
(30, -30), (30, 0), (30, 30)]
towers = []
for i, (dx, dz) in enumerate(offsets):
x = center_x + dx
z = center_z + dz
towers.append(make_tower(x, z, i + 1))
print(f"✅ Fallback returned {len(towers)} towers.\n")
return towers
def generate_grid_towers(center_x, center_z):
"""Generates a 5×5 grid of towers around the city center."""
towers = []
for i in range(GRID_SIZE):
for j in range(GRID_SIZE):
x = center_x + (i - GRID_SIZE // 2) * SPACING
z = center_z + (j - GRID_SIZE // 2) * SPACING
towers.append({
'id': len(towers) + 1,
'status': 'Active' if random.random() > 0.2 else 'Inactive',
'position_x': x,
'position_y': 0,
'position_z': z,
'height': random.randint(40, 60),
'range': random.randint(500, 1000),
'color': '#ff4500'
})
return towers
def generate_wifi_hotspots(center_x, center_z):
"""Places 10 Wi-Fi hotspots randomly around the city center."""
hotspots = []
bound = SPACING * GRID_SIZE / 2
for i in range(10):
x = center_x + random.uniform(-bound, bound)
z = center_z + random.uniform(-bound, bound)
hotspots.append({
'id': i + 1,
'position_x': x,
'position_y': 1.5,
'position_z': z,
'status': 'Online' if random.random() > 0.2 else 'Offline',
'radius': random.randint(1, 3),
'color': '#32cd32'
})
return hotspots
def make_tower(x, z, id):
return {
'id': id,
'status': 'Active',
'position_x': x,
'position_y': 0,
'position_z': z,
'height': 50,
'range': 1000,
'color': '#ff4500'
}

View File

@ -0,0 +1,45 @@
import math
def rectangular_layout(num_elements, max_dimension):
grid_size = int(math.sqrt(num_elements))
spacing = max_dimension // grid_size
return [
{
'position_x': (i % grid_size) * spacing,
'position_z': (i // grid_size) * spacing
}
for i in range(num_elements)
]
def circular_layout(num_elements, radius):
return [
{
'position_x': radius * math.cos(2 * math.pi * i / num_elements),
'position_z': radius * math.sin(2 * math.pi * i / num_elements)
}
for i in range(num_elements)
]
def diagonal_layout(num_elements, max_position):
return [
{
'position_x': i * max_position // num_elements,
'position_z': i * max_position // num_elements
}
for i in range(num_elements)
]
def triangular_layout(num_elements):
positions = []
row_length = 1
while num_elements > 0:
for i in range(row_length):
if num_elements <= 0:
break
positions.append({
'position_x': i * 10 - (row_length - 1) * 5, # Spread out each row symmetrically
'position_z': row_length * 10
})
num_elements -= 1
row_length += 1
return positions

View File

@ -0,0 +1,63 @@
import networkx as nx
import math
def compute_distance(t1, t2):
"""
Compute Euclidean distance between two towers in the horizontal plane.
"""
dx = t1['position_x'] - t2['position_x']
dz = t1['position_z'] - t2['position_z']
return math.sqrt(dx**2 + dz**2)
def compute_mst_fiber_paths(towers):
"""
Given a list of tower dictionaries, compute a Minimum Spanning Tree (MST)
and return a list of fiber paths connecting the towers.
"""
G = nx.Graph()
# Add towers as nodes
for tower in towers:
G.add_node(tower['id'], **tower)
# Add edges: compute pairwise distances
n = len(towers)
for i in range(n):
for j in range(i+1, n):
d = compute_distance(towers[i], towers[j])
G.add_edge(towers[i]['id'], towers[j]['id'], weight=d)
# Compute MST
mst = nx.minimum_spanning_tree(G)
fiber_paths = []
for edge in mst.edges(data=True):
id1, id2, data = edge
# Find towers corresponding to these IDs
tower1 = next(t for t in towers if t['id'] == id1)
tower2 = next(t for t in towers if t['id'] == id2)
fiber_paths.append({
'id': len(fiber_paths) + 1,
'start_x': tower1['position_x'],
'start_z': tower1['position_z'],
'end_x': tower2['position_x'],
'end_z': tower2['position_z'],
'mid_x': (tower1['position_x'] + tower2['position_x']) / 2,
'mid_y': 0.1, # Slightly above the ground
'mid_z': (tower1['position_z'] + tower2['position_z']) / 2,
'length': data['weight'],
# Optionally, compute the angle in degrees if needed:
'angle': math.degrees(math.atan2(tower2['position_x'] - tower1['position_x'],
tower2['position_z'] - tower1['position_z'])),
'status': 'Connected',
'color': '#4682b4'
})
return fiber_paths
def compute_network_summary(towers, fiber_paths, wifi_hotspots):
total_fiber = sum(fiber['length'] for fiber in fiber_paths)
return {
'num_towers': len(towers),
'total_fiber_length': total_fiber,
'num_wifi': len(wifi_hotspots),
}

View File

@ -0,0 +1,120 @@
import osmnx as ox
import shapely
import random
import uuid
import networkx as nx
from matplotlib import cm
def generate_osm_city_data(lat, lon, dist=400, scale=1.0):
print(f"🏙️ Fetching OSM buildings and network at ({lat}, {lon})")
scale_factor = scale
status_options = ["OK", "Warning", "Critical", "Offline"]
# ————— STREET NETWORK —————
G = ox.graph_from_point((lat, lon), dist=dist, network_type='drive').to_undirected()
degree = dict(G.degree())
max_degree = max(degree.values()) if degree else 1
color_map = cm.get_cmap("plasma")
# ————— BUILDINGS —————
tags = {"building": True}
gdf = ox.features_from_point((lat, lon), tags=tags, dist=dist)
gdf = gdf[gdf.geometry.type.isin(["Polygon", "MultiPolygon"])].to_crs(epsg=3857)
gdf["centroid"] = gdf.geometry.centroid
raw_buildings = []
for i, row in gdf.iterrows():
centroid = row["centroid"]
polygon = row["geometry"]
building_id = f"BLD-{uuid.uuid4().hex[:6].upper()}"
status = random.choice(status_options)
try:
height = float(row.get("height", None))
except:
height = float(row.get("building:levels", 3)) * 3.2 if row.get("building:levels") else 10.0
try:
node = ox.distance.nearest_nodes(G, X=centroid.x, Y=centroid.y)
node_degree = degree.get(node, 0)
except:
node_degree = 0
norm_value = node_degree / max_degree
rgba = color_map(norm_value)
hex_color = '#%02x%02x%02x' % tuple(int(c * 255) for c in rgba[:3])
raw_buildings.append({
"id": building_id,
"raw_x": centroid.x,
"raw_z": centroid.y,
"width": polygon.bounds[2] - polygon.bounds[0],
"depth": polygon.bounds[3] - polygon.bounds[1],
"height": height,
"color": hex_color,
"status": status,
})
# ————— CENTER AND SCALE —————
if raw_buildings:
avg_x = sum(b['raw_x'] for b in raw_buildings) / len(raw_buildings)
avg_z = sum(b['raw_z'] for b in raw_buildings) / len(raw_buildings)
buildings = [{
"id": b['id'],
"position_x": (b['raw_x'] - avg_x) * scale_factor,
"position_z": (b['raw_z'] - avg_z) * scale_factor,
"width": b['width'] * scale_factor,
"depth": b['depth'] * scale_factor,
"height": b['height'] * scale_factor,
"color": b['color'],
"status": b['status'],
} for b in raw_buildings]
else:
buildings = []
return {"buildings": buildings}
def generate_osm_road_midpoints_only(lat, lon, dist=400, scale=1.0):
import osmnx as ox
import networkx as nx
from shapely.geometry import LineString
import geopandas as gpd
import random
print(f"🛣️ Fetching road midpoints only at ({lat}, {lon})")
# Obtener grafo y convertir a GeoDataFrame con geometría
G = ox.graph_from_point((lat, lon), dist=dist, network_type='drive').to_undirected()
edge_gdf = ox.graph_to_gdfs(G, nodes=False, edges=True)
# Proyectar a metros (3857)
edge_gdf = edge_gdf.to_crs(epsg=3857)
midpoints_raw = []
for _, row in edge_gdf.iterrows():
geom = row.get('geometry', None)
if isinstance(geom, LineString) and geom.length > 0:
midpoint = geom.interpolate(0.5, normalized=True)
midpoints_raw.append((midpoint.x, midpoint.y))
if not midpoints_raw:
return {"roads": []}
avg_x = sum(x for x, _ in midpoints_raw) / len(midpoints_raw)
avg_z = sum(z for _, z in midpoints_raw) / len(midpoints_raw)
midpoints = [{
"id": f"RD-{i}",
"position_x": (x - avg_x) * scale,
"position_z": (z - avg_z) * scale,
"status": random.choice(["OK", "Warning", "Critical", "Offline"])
} for i, (x, z) in enumerate(midpoints_raw)]
# DEBUG
for i in range(min(5, len(midpoints))):
print(f"🧭 Road {i}: x={midpoints[i]['position_x']:.2f}, z={midpoints[i]['position_z']:.2f}")
return {"roads": midpoints}

View File

@ -0,0 +1,25 @@
def get_environment_preset(lat, long):
"""
Determines the A-Frame environment preset based on latitude and longitude.
You can adjust the logic to suit your needs.
"""
# Example logic: adjust these thresholds as needed
if lat >= 60 or lat <= -60:
return 'snow' # Polar regions: snow environment
elif lat >= 30 or lat <= -30:
return 'forest' # Mid-latitudes: forest environment
elif long >= 100:
return 'goldmine' # Arbitrary example: for far east longitudes, a 'goldmine' preset
else:
return 'desert' # Default to desert for lower latitudes and moderate longitudes
def get_environment_by_lat(lat):
if lat > 60 or lat < -60:
return 'yeti'
elif 30 < lat < 60 or -30 > lat > -60:
return 'forest'
else:
return 'desert'

View File

@ -0,0 +1,81 @@
import random
from .layouts import rectangular_layout, circular_layout, diagonal_layout, triangular_layout
def generate_random_city_data(innovation_pct=100, technology_pct=100, science_pct=100, max_position=100, radius=50):
num_buildings = random.randint(5, 35)
num_lamps = random.randint(5, 100)
num_trees = random.randint(5, 55)
# Buildings layout distribution
num_rectangular_buildings = int(num_buildings * innovation_pct / 100)
num_circular_buildings = (num_buildings - num_rectangular_buildings) // 2
num_triangular_buildings = num_buildings - num_rectangular_buildings - num_circular_buildings
building_positions = rectangular_layout(num_rectangular_buildings, max_position) + \
circular_layout(num_circular_buildings, radius) + \
triangular_layout(num_triangular_buildings)
# Lamps layout distribution
num_triangular_lamps = int(num_lamps * technology_pct / 100)
num_circular_lamps = (num_lamps - num_triangular_lamps) // 2
num_diagonal_lamps = num_lamps - num_triangular_lamps - num_circular_lamps
lamp_positions = triangular_layout(num_triangular_lamps) + \
circular_layout(num_circular_lamps, radius) + \
diagonal_layout(num_diagonal_lamps, max_position)
# Trees layout distribution
num_circular_trees = int(num_trees * science_pct / 100)
num_triangular_trees = (num_trees - num_circular_trees) // 2
num_diagonal_trees = num_trees - num_circular_trees - num_triangular_trees
tree_positions = circular_layout(num_circular_trees, radius) + \
triangular_layout(num_triangular_trees) + \
diagonal_layout(num_diagonal_trees, max_position)
buildings = [
{
'id': i + 1,
'status': random.choice(['Occupied', 'Vacant', 'Under Construction']),
'position_x': pos['position_x'],
'position_z': pos['position_z'],
'height': random.randint(10, 50),
'width': random.randint(5, 20),
'depth': random.randint(5, 20),
'color': random.choice(['#8a2be2', '#5f9ea0', '#ff6347', '#4682b4']),
'file': ''
} for i, pos in enumerate(building_positions)
]
lamps = [
{
'id': i + 1,
'status': random.choice(['Functional', 'Non-functional']),
'position_x': pos['position_x'],
'position_z': pos['position_z'],
'height': random.randint(3, 10),
'color': random.choice(['#ffff00', '#ff0000', '#00ff00']),
} for i, pos in enumerate(lamp_positions)
]
trees = [
{
'id': i + 1,
'status': random.choice(['Healthy', 'Diseased', 'Wilting']),
'position_x': pos['position_x'],
'position_z': pos['position_z'],
'height': random.randint(5, 30),
'radius_bottom': random.uniform(0.1, 0.5),
'radius_top': random.uniform(0.5, 2.0),
'color_trunk': '#8b4513',
'color_leaves': random.choice(['#228b22', '#90ee90', '#8b4513']),
} for i, pos in enumerate(tree_positions)
]
return {
'buildings': buildings,
'lamps': lamps,
'trees': trees,
}

View File

@ -0,0 +1,52 @@
import json
import polyline
from django.conf import settings
from pxy_dashboard.apps.models import OptScenario
def get_dispatch_data_for(subdivision):
"""
Load the last OptScenario, decode its VROOM JSON for this subdivision,
and return a list of routes, each with 'route_id', 'coords', and 'steps'.
"""
scenario = OptScenario.objects.last()
if not scenario or not scenario.dispatch_json:
return None
# load & slice
with open(scenario.dispatch_json.path, encoding='utf-8') as f:
raw = json.load(f)
raw_subdiv = raw.get(subdivision)
if not raw_subdiv:
return None
dispatch_data = []
for route in raw_subdiv.get('routes', []):
vehicle = route.get('vehicle')
# decode polyline geometry → [ [lon, lat], … ]
coords = []
if route.get('geometry'):
pts = polyline.decode(route['geometry'])
coords = [[lng, lat] for lat, lng in pts]
# build steps
steps = []
for step in route.get('steps', []):
lon, lat = step['location']
popup = (
f"{step.get('type','job').title()}<br>"
f"ID: {step.get('id','')}<br>"
f"Load: {step.get('load',[0])[0]} kg"
)
steps.append({
'position': [lon, lat],
'popup': popup,
'step_type': step.get('type','job'),
})
dispatch_data.append({
'route_id': str(vehicle),
'coords': coords,
'steps': steps,
})
return dispatch_data

View File

@ -0,0 +1,38 @@
<a-entity
class="status-gauge"
gauge-click-toggle
position="0 {{ offset_y|default:'3' }} 0"
scale="0.3 0.3 0.3">
<!-- Glass core -->
<a-circle
radius="0.6"
class="glass-core"
color="{{ ring_color|default:'#00FFFF' }}"
material="shader: standard; transparent: true; opacity: 0.3; metalness: 0.8; roughness: 0.1; side: double"
rotation="0 90 0">
</a-circle>
<!-- Animated outer ring -->
<a-ring
class="gauge-ring"
radius-inner="0.7"
radius-outer="0.9"
color="{{ ring_color|default:'#00FFFF' }}"
material="shader: flat; opacity: 0.8; side: double"
rotation="0 90 0"
animation="property: rotation; to: 0 90 0; loop: true; dur: 2000; easing: linear">
</a-ring>
<!-- Dynamic Text -->
<a-text
class="gauge-label"
value="ID: {{ id }}\n{{ status }}"
align="center"
color="#FFFFFF"
width="2"
side="double"
position="0 0 0.02"
rotation="0 90 0">
</a-text>
</a-entity>

View File

@ -0,0 +1,38 @@
<a-entity
class="status-gauge-augmented"
gauge-click-toggle
position="{{ pos_x|default:'0' }} {{ pos_y|default:'1.5' }} {{ pos_z|default:'0' }}"
scale="{{ scale|default:'0.3 0.3 0.3' }}">
<!-- Glass core -->
<a-circle
radius="0.6"
class="glass-core"
color="{{ ring_color|default:'#00FFFF' }}"
material="shader: standard; transparent: true; opacity: 0.3; metalness: 0.8; roughness: 0.1; side: double"
rotation="0 0 0">
</a-circle>
<!-- Animated outer ring -->
<a-ring
class="gauge-ring"
radius-inner="0.7"
radius-outer="0.9"
color="{{ ring_color|default:'#00FFFF' }}"
material="shader: flat; opacity: 0.8; side: double"
rotation="0 0 0"
animation="property: rotation; to: 0 90 0; loop: true; dur: 2000; easing: linear">
</a-ring>
<!-- Text label -->
<a-text
class="gauge-label"
value="{{ label|default:'Status\nOK' }}"
align="center"
color="#FFFFFF"
width="2"
side="double"
position="0 0 0.02"
rotation="0 0 0">
</a-text>
</a-entity>

View File

@ -0,0 +1,31 @@
<!-- Glass core -->
<a-circle
radius="1"
class="glass-core"
color="{{ ring_color|default:'#00FFFF' }}"
material="shader: standard; transparent: true; opacity: 0.3; metalness: 0.8; roughness: 0.1; side: double"
rotation="0 90 0">
</a-circle>
<!-- Animated outer ring -->
<a-ring
class="gauge-ring"
radius-inner="1.2"
radius-outer="1.4"
color="{{ ring_color|default:'#00FFFF' }}"
material="shader: flat; opacity: 0.8; side: double"
rotation="0 90 0"
animation="property: rotation; to: 0 90 0; loop: true; dur: 2000; easing: linear">
</a-ring>
<!-- Dynamic Text -->
<a-text
class="gauge-label"
value="ID: {{ id }}\n{{ status }}"
align="center"
color="#FFFFFF"
width="2"
side="double"
position="0 0 0.02"
rotation="0 90 0">
</a-text>

View File

@ -0,0 +1,131 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Waste Route Visualization</title>
<!-- A-Frame core -->
<script src="https://aframe.io/releases/1.3.0/aframe.min.js"></script>
<script src="https://raw.githack.com/AR-js-org/AR.js/3.4.5/aframe/build/aframe-ar.js"></script>
</head>
<body>
<a-scene id="scene">
<!-- Camera & cursor -->
<a-entity
id="mainCamera"
camera look-controls wasd-controls
position="0 5 10">
<a-cursor color="#ffffff"></a-cursor>
</a-entity>
<!-- Invisible ground-plane fallback -->
<a-plane id="basePlane"
rotation="-90 0 0"
color="#444" opacity="0.0">
</a-plane>
<!-- 1) Draw city buildings from your OSM data -->
{% for building in city_data.buildings %}
<a-entity id="{{ building.id }}" status="{{ building.status }}">
<a-box position="{{ building.position_x }} {{ building.height }} {{ building.position_z }}"
width="{{ building.width }}"
height="{{ building.height }}"
depth="{{ building.depth }}"
color="{{ building.color }}"
opacity="0.8">
</a-box>
<a-entity position="{{ building.position_x }} {{ building.height }} {{ building.position_z }}">
</a-entity>
</a-entity>
{% endfor %}
<!-- 2) Draw each job-sphere & gauge from step_positions -->
{% for pos in step_positions %}
<a-entity position="{{ pos.x }} 1 {{ pos.z }}">
<a-sphere radius="10"
color="#FF4136"
emissive="#FF4136"
opacity="0.7">
</a-sphere>
<!-- gauge above the sphere -->
<a-entity class="status-gauge" gauge-click-toggle position="11 3 0">
{% include "pxy_city_digital_twins/_status_gauge_waste.html" with ring_color="#FF4136" status=pos.step.step_type id=pos.step.id %}
</a-entity>
</a-entity>
{% endfor %}
</a-scene>
<script>
document.querySelector('#scene').addEventListener('loaded', () => {
const sceneEl = document.getElementById('scene');
// 3) rel_coords was passed in context
const rel = {{ rel_coords|safe }};
// Compute bounding box & stageSize
const xs = rel.map(r => r[0]), zs = rel.map(r => r[1]);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minZ = Math.min(...zs), maxZ = Math.max(...zs);
const width = maxX - minX, height = maxZ - minZ;
const stageSize = Math.ceil(Math.max(width, height) * 1.2);
// Update invisible base-plane
const base = document.getElementById('basePlane');
base.setAttribute('width', width * 1.5);
base.setAttribute('height', height * 1.5);
base.setAttribute('position',
`${(minX+maxX)/2} 0 ${(minZ+maxZ)/2}`);
// Add environment
const envEl = document.createElement('a-entity');
envEl.setAttribute('environment', `
preset: forest;
stageSize: ${stageSize};
ground: flat;
grid: true;
gridColor: #00ffff;
skyColor: #000000;
fog: 0.3;
lighting: subtle;
`);
sceneEl.appendChild(envEl);
// Draw & animate each segment
const pulseDuration = 600;
rel.forEach(([x1,z1], i) => {
if (i + 1 >= rel.length) return;
const [x2,z2] = rel[i+1];
const seg = document.createElement('a-entity');
seg.setAttribute('line', `
start: ${x1.toFixed(2)} 1 ${z1.toFixed(2)};
end: ${x2.toFixed(2)} 1 ${z2.toFixed(2)};
color: #0077FF;
opacity: 0.6
`);
seg.setAttribute('animation__color', `
property: line.color;
from: #0077FF;
to: #FF4136;
dur: ${pulseDuration};
delay: ${i * pulseDuration};
dir: alternate;
loop: true
`);
sceneEl.appendChild(seg);
});
// Position camera at first step
if (rel.length) {
const [fx,fz] = rel[0];
const cam = document.getElementById('mainCamera');
cam.setAttribute('position', `${fx.toFixed(2)} 6 ${fz.toFixed(2)+6}`);
cam.setAttribute('look-at', `${fx.toFixed(2)} 1 ${fz.toFixed(2)}`);
}
});
</script>
</body>
</html>
<!--
-->

View File

@ -0,0 +1,65 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Digital Twin City - AR</title>
<!-- A-Frame & AR.js -->
<script src="https://aframe.io/releases/1.3.0/aframe.min.js"></script>
<script src="https://raw.githack.com/AR-js-org/AR.js/3.4.5/aframe/build/aframe-ar.js"></script>
<!-- Billboard: make objects face the camera -->
<script>
AFRAME.registerComponent('billboard', {
schema: {type: 'selector'},
tick: function () {
if (!this.data) return;
this.el.object3D.lookAt(this.data.object3D.position);
}
});
</script>
<!-- Toggle gauge color/status -->
<script>
AFRAME.registerComponent('gauge-click-toggle', {
init: function () {
const colors = ["#00FF00", "#FFFF00", "#FF0000", "#00FFFF"];
const statuses = ["OK", "Warning", "Critical", "Offline"];
let i = 0;
const ring = this.el.querySelector('.gauge-ring');
const label = this.el.querySelector('.gauge-label');
this.el.addEventListener('click', () => {
i = (i + 1) % colors.length;
if (ring) ring.setAttribute('color', colors[i]);
if (label) {
const current = label.getAttribute('value');
const idLine = current.split("\n")[0];
label.setAttribute('value', `${idLine}\n${statuses[i]}`);
}
});
}
});
</script>
</head>
<body>
<a-scene embedded arjs="sourceType: webcam; debugUIEnabled: false" vr-mode-ui="enabled: false">
<!-- Cámara y controles -->
<a-entity id="mainCamera" camera look-controls wasd-controls position="0 2 5">
<a-cursor color="#FF0000"></a-cursor>
</a-entity>
<!-- Solo los gauges -->
{% for road in city_data.roads %}
<a-entity id="{{ road.id }}" status="{{ road.status }}">
<a-entity
position="{{ road.position_x }} 1.5 {{ road.position_z }}">
{% include "pxy_city_digital_twins/_status_gauge.html" with ring_color="#FF6600" offset_y="0" status=road.status id=road.id %}
</a-entity>
</a-entity>
{% endfor %}
</a-scene>
</body>
</html>

View File

@ -0,0 +1,218 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Digital Twin City</title>
<!-- A-Frame 1.7.0 & environment component -->
<script src="https://aframe.io/releases/1.7.0/aframe.min.js"></script>
<script src="https://unpkg.com/aframe-environment-component/dist/aframe-environment-component.min.js"></script>
<!-- 1) Simple “look-at” component to face the camera -->
<script>
AFRAME.registerComponent('billboard', {
schema: {type: 'selector'},
tick: function () {
if (!this.data) return;
// Make this entity face the camera each frame
this.el.object3D.lookAt(this.data.object3D.position);
}
});
</script>
<script>
AFRAME.registerComponent('gauge-click-toggle', {
init: function () {
const colors = ["#00FF00", "#FFFF00", "#FF0000", "#00FFFF"];
const statuses = ["OK", "Warning", "Critical", "Offline"];
let i = 0;
const ring = this.el.querySelector('.gauge-ring');
const label = this.el.querySelector('.gauge-label');
this.el.addEventListener('click', () => {
i = (i + 1) % colors.length;
if (ring) ring.setAttribute('color', colors[i]);
if (label) {
const current = label.getAttribute('value');
const idLine = current.split("\n")[0]; // Preserve ID line
label.setAttribute('value', `${idLine}\n${statuses[i]}`);
}
});
}
});
</script>
</head>
<body>
<a-scene environment="preset: tron; groundTexture: walk; dressing: trees; fog: 0.7">
<!-- Camera & Controls (give it an id for look-at) -->
<a-entity id="mainCamera" camera look-controls wasd-controls position="0 2 5">
<a-cursor color="#FF0000"></a-cursor>
</a-entity>
<!-- Optional: Transparent ground plane (comment out if you want only environment ground) -->
<a-plane position="0 -0.1 0" rotation="-90 0 0"
width="200" height="200"
color="#444" opacity="0.3">
</a-plane>
<!-- Buildings -->
{% for building in city_data.buildings %}
<a-entity id="{{ building.id }}" status="{{ building.status }}">
<!-- Building geometry -->
<a-box position="{{ building.position_x }} 1 {{ building.position_z }}"
width="{{ building.width }}" height="{{ building.height }}" depth="{{ building.depth }}"
color="{{ building.color }}">
</a-box>
<a-entity
position="{{ building.position_x }} {{ building.height }} {{ building.position_z }}">
{% include "pxy_city_digital_twins/_status_gauge.html" with ring_color="#00FFFF" offset_y="1.50" status=building.status id=building.id %}
</a-entity>
</a-entity>
{% endfor %}
<!-- Lamps -->
{% for lamp in city_data.lamps %}
<a-entity id="{{ lamp.id }}" status="{{ lamp.status }}" position="{{ lamp.position_x }} 1 {{ lamp.position_z }}">
<!-- Lamp geometry -->
<a-cone radius-bottom="0.1" radius-top="0.5"
height="{{ lamp.height }}" color="{{ lamp.color }}">
</a-cone>
<a-sphere radius="0.2" color="#FFFFFF"
position="0 {{ lamp.height }} 0">
</a-sphere>
<!-- Label: billboard to camera -->
<a-entity position="0 3 0" billboard="#mainCamera">
<a-plane width="4" height="1.2" color="#000" opacity="0.5"></a-plane>
<a-text value="Status: {{ lamp.status }}"
width="4"
align="center"
color="#FFF"
position="0 0 0.01">
</a-text>
</a-entity>
</a-entity>
{% endfor %}
<!-- Trees -->
{% for tree in city_data.trees %}
<a-entity id="{{ tree.id }}" status="{{ tree.status }}" position="{{ tree.position_x }} 1 {{ tree.position_z }}">
<!-- Tree trunk & leaves -->
<a-cone radius-bottom="{{ tree.radius_bottom }}"
radius-top="{{ tree.radius_top }}"
height="{{ tree.height }}"
color="{{ tree.color_trunk }}">
</a-cone>
<a-sphere radius="{{ tree.radius_top }}"
color="{{ tree.color_leaves }}"
position="0 {{ tree.height }} 0">
</a-sphere>
<!-- Label: billboard to camera -->
<a-entity position="0 3 0" billboard="#mainCamera">
<a-plane width="4" height="1.2" color="#000" opacity="0.5"></a-plane>
<a-text value="Status: {{ tree.status }}"
width="4"
align="center"
color="#FFF"
position="0 0 0.01">
</a-text>
</a-entity>
</a-entity>
{% endfor %}
<!-- Cell Towers -->
{% for tower in city_data.towers %}
<a-entity id="tower{{ tower.id }}"
position="{{ tower.position_x }} {{ tower.position_y }} {{ tower.position_z }}">
<!-- Base tower cylinder -->
<a-cylinder height="{{ tower.height }}" radius="1" color="{{ tower.color }}"></a-cylinder>
<!-- Animated signal ring near top -->
<a-ring color="#FF0000"
radius-inner="2"
radius-outer="2.5"
position="0 {{ tower.height|add:'1' }} 0"
rotation="-90 0 0"
animation="property: scale; to: 1.5 1.5 1.5; dir: alternate; dur: 1000; loop: true">
</a-ring>
<!-- Tower label: billboard to camera -->
<a-entity position="0 -5 0" billboard="#mainCamera">
<a-plane width="4" height="1.2" color="#000" opacity="0.5"></a-plane>
<a-text value="📡 Tower {{ tower.id }} - {{ tower.status }}"
width="4"
align="center"
color="#FFF"
position="0 0 0.01">
</a-text>
</a-entity>
</a-entity>
{% endfor %}
<!-- Fiber Paths (Cylinders) -->
{% for fiber in city_data.fiber_paths %}
<a-entity>
<a-cylinder position="{{ fiber.mid_x }} {{ fiber.mid_y }} {{ fiber.mid_z }}"
height="{{ fiber.length }}"
radius="0.1"
rotation="90 {{ fiber.angle }} 0"
color="{{ fiber.color }}">
</a-cylinder>
<!-- Fiber label: billboard to camera -->
<a-entity position="{{ fiber.start_x }} 3 {{ fiber.start_z }}" billboard="#mainCamera">
<a-plane width="4" height="1.2" color="#000" opacity="0.5"></a-plane>
<a-text value="🔗 Fiber Path {{ fiber.id }} - {{ fiber.status }}"
width="4"
align="center"
color="{{ fiber.color }}"
position="0 0 0.01">
</a-text>
</a-entity>
</a-entity>
{% endfor %}
<!-- Wi-Fi Hotspots -->
{% for wifi in city_data.wifi_hotspots %}
<a-entity id="wifi{{ wifi.id }}"
position="{{ wifi.position_x }} {{ wifi.position_y }} {{ wifi.position_z }}">
<!-- Hotspot sphere (animated) -->
<a-sphere radius="{{ wifi.radius }}" color="{{ wifi.color }}"
animation="property: scale; to: 1.5 1.5 1.5; dir: alternate; dur: 1500; loop: true">
</a-sphere>
<!-- Coverage area (fixed or dynamic) -->
<a-sphere radius="5"
color="#00FFFF"
opacity="0.2"
position="0 {{ wifi.radius }} 0">
</a-sphere>
<!-- Wi-Fi label: billboard to camera -->
<a-entity position="0 3 0" billboard="#mainCamera">
<a-plane width="4" height="1.2" color="#000" opacity="0.5"></a-plane>
<a-text value="📶 WiFi {{ wifi.id }} - {{ wifi.status }}"
width="4"
align="center"
color="#FFF"
position="0 0 0.01">
</a-text>
</a-entity>
</a-entity>
{% endfor %}
</a-scene>
</body>
</html>

View File

@ -0,0 +1,59 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Gauge Ahead</title>
<!-- A-Frame & AR.js -->
<script src="https://aframe.io/releases/1.3.0/aframe.min.js"></script>
<script src="https://raw.githack.com/AR-js-org/AR.js/3.4.5/aframe/build/aframe-ar.js"></script>
<!-- Billboard & click toggle (ya los usas en tu sistema) -->
<script>
AFRAME.registerComponent('billboard', {
schema: {type: 'selector'},
tick: function () {
if (!this.data) return;
this.el.object3D.lookAt(this.data.object3D.position);
}
});
AFRAME.registerComponent('gauge-click-toggle', {
init: function () {
const colors = ["#00FF00", "#FFFF00", "#FF0000", "#00FFFF"];
const statuses = ["OK", "Warning", "Critical", "Offline"];
let i = 0;
const ring = this.el.querySelector('.gauge-ring');
const label = this.el.querySelector('.gauge-label');
this.el.addEventListener('click', () => {
i = (i + 1) % colors.length;
if (ring) ring.setAttribute('color', colors[i]);
if (label) {
const current = label.getAttribute('value');
const idLine = current.split("\n")[0];
label.setAttribute('value', `${idLine}\n${statuses[i]}`);
}
});
}
});
</script>
</head>
<body>
<a-scene embedded arjs="sourceType: webcam; debugUIEnabled: false" vr-mode-ui="enabled: false">
<!-- Cámara -->
<a-entity id="mainCamera" camera look-controls wasd-controls position="0 2 5">
<a-cursor color="#FF0000"></a-cursor>
</a-entity>
<!-- Gauge fijo visible a 3 unidades frente a cámara -->
<a-entity position="0 1.5 -3">
{% include "pxy_city_digital_twins/_status_gauge_augmented.html" with pos_x="0" pos_y="1.6" pos_z="-3" scale="2.7 2.7 2.7" ring_color="#00FF00" label="WELCOME\nUSER" %}
</a-entity>
</a-scene>
</body>
</html>

View File

@ -0,0 +1,198 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Waste Route Visualization</title>
<!-- A-Frame core -->
<script src="https://aframe.io/releases/1.7.0/aframe.min.js"></script>
<!-- A-Frame environment effects -->
<script src="https://unpkg.com/aframe-environment-component/dist/aframe-environment-component.min.js"></script>
</head>
<body>
<a-scene id="scene">
<!-- Camera & cursor -->
<a-entity
id="mainCamera"
camera look-controls wasd-controls
position="0 5 10">
<a-cursor color="#ffffff"></a-cursor>
</a-entity>
<!-- Invisible ground-plane fallback -->
<a-plane id="basePlane"
rotation="-90 0 0"
color="#444" opacity="0.0">
</a-plane>
<!-- 1) Draw city buildings from your OSM data -->
{% for building in city_data.buildings %}
<a-entity id="{{ building.id }}" status="{{ building.status }}">
<a-box position="{{ building.position_x }} {{ building.height }} {{ building.position_z }}"
width="{{ building.width }}"
height="{{ building.height }}"
depth="{{ building.depth }}"
color="{{ building.color }}"
opacity="0.8">
</a-box>
</a-entity>
{% endfor %}
<!-- 2) Draw each job-sphere & gauge from step_positions -->
{% for pos in step_positions %}
{% with sid=pos.step.id stype=pos.step.step_type %}
<a-entity id="step-{{ sid }}" position="{{ pos.x }} 1 {{ pos.z }}">
<!-- sphere now semi-transparent by default -->
<a-sphere id="sphere-{{ sid }}"
radius="10"
color="#FF4136"
emissive="#FF4136"
transparent="true"
opacity="0.4">
</a-sphere>
<!-- gauge above the sphere -->
<a-entity
class="status-gauge"
gauge-click-toggle
position="11 3 0"
data-step-id="{{ sid }}"
data-step-type="{{ stype }}">
{% include "pxy_city_digital_twins/_status_gauge_waste.html" with ring_color="#FF4136" status=stype id=sid %}
</a-entity>
</a-entity>
{% endwith %}
{% endfor %}
</a-scene>
<script>
// Expose Django vars into JS
const subdivision = "{{ subdivision }}";
const vehicle = "{{ vehicle }}";
const recordUrl = "{% url 'waste-record-pickup' subdivision=subdivision vehicle=vehicle %}";
document.querySelector('#scene').addEventListener('loaded', () => {
const sceneEl = document.getElementById('scene');
const rel = {{ rel_coords|safe }};
// 3) Compute bounding box & stageSize
const xs = rel.map(r => r[0]), zs = rel.map(r => r[1]);
const minX = Math.min(...xs), maxX = Math.max(...xs);
const minZ = Math.min(...zs), maxZ = Math.max(...zs);
const width = maxX - minX, height = maxZ - minZ;
const stageSize = Math.ceil(Math.max(width, height) * 1.2);
// 4) Update invisible base-plane
const base = document.getElementById('basePlane');
base.setAttribute('width', width * 1.5);
base.setAttribute('height', height * 1.5);
base.setAttribute('position',
`${(minX+maxX)/2} 0 ${(minZ+maxZ)/2}`);
// 5) Add environment
const envEl = document.createElement('a-entity');
envEl.setAttribute('environment', `
preset: forest;
stageSize: ${stageSize};
ground: flat;
grid: true;
gridColor: #00ffff;
skyColor: #000000;
fog: 0.3;
lighting: subtle;
`);
sceneEl.appendChild(envEl);
// 6) Draw & animate each segment
const pulseDuration = 600;
rel.forEach(([x1,z1], i) => {
if (i + 1 >= rel.length) return;
const [x2,z2] = rel[i+1];
const seg = document.createElement('a-entity');
seg.setAttribute('line', `
start: ${x1.toFixed(2)} 1 ${z1.toFixed(2)};
end: ${x2.toFixed(2)} 1 ${z2.toFixed(2)};
color: #0077FF;
opacity: 0.6
`);
seg.setAttribute('animation__color', `
property: line.color;
from: #0077FF;
to: #FF4136;
dur: ${pulseDuration};
delay: ${i * pulseDuration};
dir: alternate;
loop: true
`);
sceneEl.appendChild(seg);
});
// 7) Position camera at first step
if (rel.length) {
const [fx,fz] = rel[0];
const cam = document.getElementById('mainCamera');
cam.setAttribute('position', `${fx.toFixed(2)} 6 ${fz.toFixed(2)+6}`);
cam.setAttribute('look-at', `${fx.toFixed(2)} 1 ${fz.toFixed(2)}`);
}
// ─────────────────────────────────────────────────────────────
// 8) Record pickups by clicking each gauge
// ─────────────────────────────────────────────────────────────
// CSRF helper
function getCookie(name) {
const v = document.cookie.match('(^|;)\\s*'+name+'\\s*=\\s*([^;]+)');
return v ? v.pop() : '';
}
const csrftoken = getCookie('csrftoken');
// Track click counts & define colors
const clickCounts = {};
const colors = ['#2ECC40','#FFDC00','#FF851B','#FF4136'];
document.querySelectorAll('.status-gauge').forEach(el => {
const stepId = el.dataset.stepId;
clickCounts[stepId] = 0;
el.addEventListener('click', () => {
// cycle 1→4
clickCounts[stepId] = (clickCounts[stepId] % 4) + 1;
const quarter = clickCounts[stepId];
// recolor ring
const ring = el.querySelector('.gauge-ring');
if (ring) ring.setAttribute('color', colors[quarter-1]);
// recolor sphere
const sph = document.getElementById(`sphere-${stepId}`);
if (sph) {
sph.setAttribute(
'material',
`color: ${colors[quarter-1]};
emissive: ${colors[quarter-1]};
transparent: true; opacity: 0.4`
);
}
// log & POST
console.log('Recording pickup', { step_id: stepId, quarter }, '→', recordUrl);
fetch(recordUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrftoken
},
body: JSON.stringify({ step_id: stepId, quarter })
}).then(res => {
if (!res.ok) {
console.error('Record pickup failed', res.status);
} else {
console.log('Pickup recorded');
}
});
});
});
});
</script>
</body>
</html>

View File

@ -0,0 +1,190 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Waste Route Debug</title>
<style>
body { font-family: sans-serif; padding: 2rem; }
h1, h2 { margin-bottom: 0.5rem; }
pre { background: #f6f6f6; padding: 1rem; overflow-x: auto; }
ol { margin-left: 1.5rem; }
li { margin-bottom: 0.5rem; }
/* map container */
#route-map {
width: 600px; height: 400px;
border: 1px solid #ccc;
margin-bottom: 2rem;
}
#route-map svg {
width: 100%; height: 100%; display: block;
background: #f0f8ff;
}
.route-line {
fill: none;
stroke: #0074D9;
stroke-width: 2;
}
.coord-point {
fill: #0074D9;
opacity: 0.6;
}
.step-point {
fill: #FF4136;
stroke: #fff;
stroke-width: 1;
}
.building-point {
fill: #2ECC40;
stroke: #fff;
stroke-width: 1;
}
table {
border-collapse: collapse;
margin-top: 1rem;
width: 100%;
}
th, td {
border: 1px solid #aaa;
padding: 0.5rem;
text-align: left;
}
th { background: #ddd; }
</style>
</head>
<body>
<h1>Waste Route Debug</h1>
<p><strong>Subdivision:</strong> {{ subdivision }}</p>
<p><strong>Vehicle:</strong> {{ vehicle }}</p>
<div id="route-map">
<svg></svg>
</div>
<div id="dump"></div>
<h2>Buildings</h2>
{% if city_data.buildings %}
<table>
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Position X</th>
<th>Position Z</th>
<th>Width</th>
<th>Depth</th>
<th>Height</th>
</tr>
</thead>
<tbody>
{% for b in city_data.buildings %}
<tr>
<td>{{ b.id }}</td>
<td>{{ b.status }}</td>
<td>{{ b.position_x|floatformat:2 }}</td>
<td>{{ b.position_z|floatformat:2 }}</td>
<td>{{ b.width|floatformat:2 }}</td>
<td>{{ b.depth|floatformat:2 }}</td>
<td>{{ b.height|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p><em>No buildings generated.</em></p>
{% endif %}
<script>
const route = JSON.parse('{{ selected_route_json|escapejs }}');
const coords = route.coords || [];
// 1) Draw SVG route + steps
if (coords.length > 1) {
const svg = document.querySelector('#route-map svg');
const W = svg.clientWidth, H = svg.clientHeight, M = 20;
const lons = coords.map(c=>c[0]), lats = coords.map(c=>c[1]);
const minX = Math.min(...lons), maxX = Math.max(...lons);
const minY = Math.min(...lats), maxY = Math.max(...lats);
const scaleX = (W - 2*M)/(maxX - minX || 1);
const scaleY = (H - 2*M)/(maxY - minY || 1);
function project([lon, lat]) {
const x = M + (lon - minX)*scaleX;
const y = H - (M + (lat - minY)*scaleY);
return [x,y];
}
// route line
const pts = coords.map(project).map(p=>p.join(',')).join(' ');
const line = document.createElementNS('http://www.w3.org/2000/svg','polyline');
line.setAttribute('points', pts);
line.setAttribute('class', 'route-line');
svg.appendChild(line);
// decoded-coord dots
coords.forEach(c => {
const [x,y] = project(c);
const dot = document.createElementNS('http://www.w3.org/2000/svg','circle');
dot.setAttribute('cx', x);
dot.setAttribute('cy', y);
dot.setAttribute('r', 2);
dot.setAttribute('class', 'coord-point');
svg.appendChild(dot);
});
// step markers
(route.steps||[]).forEach(s => {
const [x,y] = project(s.position);
const mark = document.createElementNS('http://www.w3.org/2000/svg','circle');
mark.setAttribute('cx', x);
mark.setAttribute('cy', y);
mark.setAttribute('r', 5);
mark.setAttribute('class', 'step-point');
svg.appendChild(mark);
});
// OPTIONAL: if you ever pass raw building lon/lat into city_data,
// you could plot them here as green circles:
/*
({{ city_data.buildings|length }} && city_data.buildings.forEach(b => {
const geo = [b.lon, b.lat];
const [x,y] = project(geo);
const bp = document.createElementNS('http://www.w3.org/2000/svg','circle');
bp.setAttribute('cx', x);
bp.setAttribute('cy', y);
bp.setAttribute('r', 4);
bp.setAttribute('class', 'building-point');
svg.appendChild(bp);
}))
*/
}
// 2) Dump coords & steps below
let out = '';
out += '<h2>Coordinates</h2>';
out += '<pre>' + JSON.stringify(coords, null, 2) + '</pre>';
out += '<h2>Steps</h2>';
if (route.steps && route.steps.length) {
out += '<ol>';
route.steps.forEach((s,i) => {
out += '<li>';
out += `<strong>Step #${i+1}</strong><br>`;
out += `<strong>Type:</strong> ${s.step_type}<br>`;
out += `<strong>Position:</strong> ${JSON.stringify(s.position)}<br>`;
out += `<strong>Popup HTML:</strong> <code>${s.popup
.replace(/</g,'&lt;')
.replace(/>/g,'&gt;')}</code>`;
out += '</li>';
});
out += '</ol>';
} else {
out += '<p><em>(no steps)</em></p>';
}
document.getElementById('dump').innerHTML = out;
</script>
</body>
</html>

View File

@ -0,0 +1,57 @@
{% load static %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Ruta No Encontrada</title>
<!-- A-Frame & environment component -->
<script src="https://aframe.io/releases/1.7.0/aframe.min.js"></script>
<script src="https://unpkg.com/aframe-environment-component/dist/aframe-environment-component.min.js"></script>
</head>
<body>
<a-scene environment="preset: yavapai; skyType: atmosphere; skyColor: #001840; fog: 0.6; groundColor: #444; groundTexture: walk; dressing: none;">
<!-- Camera & Controls -->
<a-entity id="mainCamera" camera look-controls wasd-controls position="0 2 4">
<a-cursor color="#FF0000"></a-cursor>
</a-entity>
<!-- Ground plane -->
<a-plane position="0 -0.1 0" rotation="-90 0 0" width="200" height="200" color="#444" opacity="0.3"></a-plane>
<!-- Animated box as placeholder for a happy truck -->
<a-box color="#FF4136" depth="1" height="0.5" width="2" position="0 0.25 -3"
animation="property: rotation; to: 0 360 0; loop: true; dur: 3000">
</a-box>
<!-- Message -->
<a-text value="Ruta no encontrada"
align="center"
position="0 1 -3"
color="#FFF"
width="4">
</a-text>
</a-scene>
<!-- Sugerencia de URL si aplica -->
{% if first_subdivision %}
<div style="
position: absolute;
bottom: 20px;
left: 20px;
background: rgba(0,0,0,0.6);
color: #fff;
padding: 8px 12px;
border-radius: 4px;
font-family: sans-serif;
">
Quizá quieras ver la ruta por defecto en
<a href="{% url 'waste_route' first_subdivision %}{% if first_route %}?route={{ first_route }}{% endif %}"
style="color: #ffd700; text-decoration: underline;">
{{ first_subdivision }}{% if first_route %} (ruta {{ first_route }}){% endif %}
</a>
</div>
{% endif %}
</body>
</html>

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,21 @@
from django.urls import path
from . import views
urlpatterns = [
# Digital Twin (normal)
path('city/digital/twin/<uuid:city_id>/', views.city_digital_twin, name='city_digital_twin_uuid'),
path('city/digital/twin/<str:city_id>/', views.city_digital_twin, name='city_digital_twin_str'),
# Augmented Digital Twin
path('city/augmented/digital/twin/<uuid:city_id>/', views.city_augmented_digital_twin, name='city_augmented_digital_twin_uuid'),
path('city/augmented/digital/twin/<str:city_id>/', views.city_augmented_digital_twin, name='city_augmented_digital_twin_str'),
path('city/virtual/reality/digital/twin/waste/<str:subdivision>/<str:vehicle>/', views.waste_route, name='waste_route'),
path('city/digital/twin/waste/debug/<str:subdivision>/<str:vehicle>/', views.waste_route_debug, name='waste_route_debug'),
path('city/augmented/reality/digital/twin/waste/<str:subdivision>/<str:vehicle>/', views.augmented_waste_route, name='waste_route'),
path('city/augmented/reality/digital/twin/waste/<str:subdivision>/<str:vehicle>/record/', views.record_pickup, name="waste-record-pickup"),
]

View File

@ -0,0 +1,403 @@
from django.shortcuts import render, get_object_or_404
from django.http import Http404
import random
import math
from .services.presets import get_environment_preset
import networkx as nx
from .services.layouts import (
rectangular_layout,
circular_layout,
diagonal_layout,
triangular_layout,
)
from .services.random_city import generate_random_city_data
from .services.com_con_city import generate_com_con_city_data
from .services.osm_city import generate_osm_city_data, generate_osm_road_midpoints_only
def city_digital_twin(request, city_id, innovation_pct=None, technology_pct=None, science_pct=None):
try:
lat = float(request.GET.get('lat', 0))
long = float(request.GET.get('long', 0))
scale = float(request.GET.get('scale', 1.0)) # default to 1.0 (normal scale)
if city_id == "osm_city":
city_data = generate_osm_city_data(lat, long,scale=scale)
elif city_id == "com_con":
city_data = generate_com_con_city_data(lat, long)
elif city_id == "random_city":
city_data = generate_random_city_data()
elif city_id == "dream":
innovation_pct = innovation_pct or request.GET.get('innovation', 0)
technology_pct = technology_pct or request.GET.get('technology', 0)
science_pct = science_pct or request.GET.get('science', 0)
innovation_pct = int(innovation_pct)
technology_pct = int(technology_pct)
science_pct = int(science_pct)
city_data = generate_random_city_data(innovation_pct, technology_pct, science_pct)
else:
city_data = get_city_data(city_id)
if not city_data:
city_data = get_example_data()
preset = get_environment_preset(lat, long)
context = {
'city_data': city_data,
'environment_preset': preset,
'lat': lat,
'long': long,
}
return render(request, 'pxy_city_digital_twins/city_digital_twin.html', context)
except (ValueError, TypeError):
raise Http404("Invalid data provided.")
def get_city_data(city_id):
# Implement fetching logic here
# This is a mock function to demonstrate fetching logic
if str(city_id) == "1" or str(city_id) == "123e4567-e89b-12d3-a456-426614174000":
return {
# Real data retrieval logic goes here
}
return None
def get_example_data():
return {
'buildings': [
{
'id': 1,
'status': 'Occupied',
'position_x': 0,
'height': 10,
'position_z': 0,
'width': 5,
'depth': 5,
'color': '#8a2be2',
'file': '', # No file for a simple box representation
},
{
'id': 2,
'status': 'Vacant',
'position_x': 10,
'height': 15,
'position_z': 10,
'width': 7,
'depth': 7,
'color': '#5f9ea0',
'file': '', # No file for a simple box representation
}
],
'lamps': [
{
'id': 1,
'status': 'Functional',
'position_x': 3,
'position_z': 3,
'height': 4,
'color': '#ffff00',
},
{
'id': 2,
'status': 'Broken',
'position_x': 8,
'position_z': 8,
'height': 4,
'color': '#ff0000',
}
],
'trees': [
{
'id': 1,
'status': 'Healthy',
'position_x': 5,
'position_z': 5,
'height': 6,
'radius_bottom': 0.2,
'radius_top': 1,
'color_trunk': '#8b4513',
'color_leaves': '#228b22',
},
{
'id': 2,
'status': 'Diseased',
'position_x': 15,
'position_z': 15,
'height': 6,
'radius_bottom': 0.2,
'radius_top': 1,
'color_trunk': '#a0522d',
'color_leaves': '#6b8e23',
}
]
}
def city_augmented_digital_twin(request, city_id):
try:
lat = float(request.GET.get('lat', 0))
long = float(request.GET.get('long', 0))
scale = float(request.GET.get('scale', 1.0)) # default to 1.0
if city_id == "osm_city":
city_data = generate_osm_road_midpoints_only(lat, long, scale=scale)
elif city_id == "random_city":
city_data = generate_random_city_data()
elif city_id == "gauge_ahead":
# No city data needed, just render the special AR scene
return render(request, 'pxy_city_digital_twins/spawn_gauge_ahead.html')
else:
raise Http404("Unsupported city_id for AR view")
preset = get_environment_preset(lat, long)
context = {
'city_data': city_data,
'environment_preset': preset,
'lat': lat,
'long': long,
}
return render(request, 'pxy_city_digital_twins/city_augmented_digital_twin.html', context)
except (ValueError, TypeError):
raise Http404("Invalid parameters provided.")
from django.shortcuts import render
from django.http import Http404
from .services.waste_routes import get_dispatch_data_for
import json
def waste_route_debug(request, subdivision, vehicle):
# 1) load all routes for this subdivision
dispatch_data = get_dispatch_data_for(subdivision)
if not dispatch_data:
return render(request, 'pxy_city_digital_twins/waste_route_default.html', {
'subdivision': subdivision
})
# 2) pick your route by the required `vehicle` path-param
try:
selected = next(r for r in dispatch_data if r['route_id'] == vehicle)
except StopIteration:
return render(request, 'pxy_city_digital_twins/waste_route_not_found.html', {
'subdivision': subdivision
})
# 3) derive a “center” latitude/longitude from that route:
coords = selected.get('coords', [])
if coords:
avg_lon = sum(pt[0] for pt in coords) / len(coords)
avg_lat = sum(pt[1] for pt in coords) / len(coords)
else:
steps = selected.get('steps', [])
avg_lon = sum(s['position'][0] for s in steps) / len(steps)
avg_lat = sum(s['position'][1] for s in steps) / len(steps)
# 4) generate your OSMbased city around that center
city_data = generate_osm_city_data(avg_lat, avg_lon)
# 5) sanitize building heights (replace NaN or missing with default 10.0)
default_height = 10.0
for b in city_data.get('buildings', []):
h = b.get('height')
if not isinstance(h, (int, float)) or (isinstance(h, float) and math.isnan(h)):
b['height'] = default_height
# 6) render the VR template
return render(request, 'pxy_city_digital_twins/waste_route_debug.html', {
'subdivision': subdivision,
'vehicle': vehicle,
'selected_route_json': json.dumps(selected),
'city_data': city_data,
})
import math
import json
from django.shortcuts import render
from pyproj import Transformer
from .services.osm_city import generate_osm_city_data
from .services.waste_routes import get_dispatch_data_for
def waste_route(request, subdivision, vehicle):
"""
URL: /waste/<subdivision>/<vehicle>/
Renders a single vehicle's wastecollection route in VR,
overlaid on an OSMgenerated city around the route center.
"""
# 1) load all routes for this subdivision
dispatch_data = get_dispatch_data_for(subdivision)
if not dispatch_data:
return render(request, 'pxy_city_digital_twins/waste_route_default.html', {
'subdivision': subdivision
})
# 2) pick your route by the required `vehicle` path-param
try:
selected = next(r for r in dispatch_data if r['route_id'] == vehicle)
except StopIteration:
return render(request, 'pxy_city_digital_twins/waste_route_not_found.html', {
'subdivision': subdivision
})
# 3) derive center lon/lat from coords (or fallback to steps)
coords = selected.get('coords', [])
if coords:
avg_lon = sum(pt[0] for pt in coords) / len(coords)
avg_lat = sum(pt[1] for pt in coords) / len(coords)
else:
steps = selected.get('steps', [])
avg_lon = sum(s['position'][0] for s in steps) / len(steps)
avg_lat = sum(s['position'][1] for s in steps) / len(steps)
# 4) generate your OSMbased city around that center
city_data = generate_osm_city_data(avg_lat, avg_lon)
# 5) sanitize building heights (replace NaN or missing with default 10.0)
default_height = 10.0
for b in city_data.get('buildings', []):
h = b.get('height')
if not isinstance(h, (int, float)) or (isinstance(h, float) and math.isnan(h)):
b['height'] = default_height
# 6) project all coords (and steps) to Web Mercator, recenter them
transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
# route
merc = [transformer.transform(lon, lat) for lon, lat in coords]
if not merc:
merc = [transformer.transform(*s['position']) for s in selected.get('steps', [])]
avg_x = sum(x for x, _ in merc) / len(merc)
avg_z = sum(z for _, z in merc) / len(merc)
rel_coords = [[x - avg_x, z - avg_z] for x, z in merc]
# steps
step_positions = []
for step in selected.get('steps', []):
lon, lat = step['position']
x, z = transformer.transform(lon, lat)
step_positions.append({
'x': x - avg_x,
'z': z - avg_z,
'step_id': step.get('id'),
'step_type': step.get('step_type'),
'popup': step.get('popup'),
})
# 7) render
return render(request, 'pxy_city_digital_twins/virtual_waste_route.html', {
'subdivision': subdivision,
'vehicle': vehicle,
'selected_route_json': json.dumps(selected),
'city_data': city_data,
'rel_coords': rel_coords,
'step_positions': step_positions,
})
def augmented_waste_route(request, subdivision, vehicle):
"""
URL: /waste/<subdivision>/<vehicle>/
Renders a single vehicle's wastecollection route in VR,
overlaid on an OSMgenerated city around the route center.
"""
# 1) load all routes for this subdivision
dispatch_data = get_dispatch_data_for(subdivision)
if not dispatch_data:
return render(request, 'pxy_city_digital_twins/waste_route_default.html', {
'subdivision': subdivision
})
# 2) pick your route by the required `vehicle` path-param
try:
selected = next(r for r in dispatch_data if r['route_id'] == vehicle)
except StopIteration:
return render(request, 'pxy_city_digital_twins/waste_route_not_found.html', {
'subdivision': subdivision
})
# 3) derive center lon/lat from coords (or fallback to steps)
coords = selected.get('coords', [])
if coords:
avg_lon = sum(pt[0] for pt in coords) / len(coords)
avg_lat = sum(pt[1] for pt in coords) / len(coords)
else:
steps = selected.get('steps', [])
avg_lon = sum(s['position'][0] for s in steps) / len(steps)
avg_lat = sum(s['position'][1] for s in steps) / len(steps)
# 4) generate your OSMbased city around that center
city_data = generate_osm_city_data(avg_lat, avg_lon)
# 5) sanitize building heights (replace NaN or missing with default 10.0)
default_height = 10.0
for b in city_data.get('buildings', []):
h = b.get('height')
if not isinstance(h, (int, float)) or (isinstance(h, float) and math.isnan(h)):
b['height'] = default_height
# 6) project all coords (and steps) to Web Mercator, recenter them
transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
# route
merc = [transformer.transform(lon, lat) for lon, lat in coords]
if not merc:
merc = [transformer.transform(*s['position']) for s in selected.get('steps', [])]
avg_x = sum(x for x, _ in merc) / len(merc)
avg_z = sum(z for _, z in merc) / len(merc)
rel_coords = [[x - avg_x, z - avg_z] for x, z in merc]
# steps
step_positions = []
for step in selected.get('steps', []):
lon, lat = step['position']
x, z = transformer.transform(lon, lat)
step_positions.append({
'x': x - avg_x,
'z': z - avg_z,
'step': step
})
# 7) render
return render(request, 'pxy_city_digital_twins/augmented_waste_route.html', {
'subdivision': subdivision,
'vehicle': vehicle,
'selected_route_json': json.dumps(selected),
'city_data': city_data,
'rel_coords': rel_coords,
'step_positions': step_positions,
})
import json
from django.http import JsonResponse, HttpResponseBadRequest
from django.views.decorators.http import require_POST
from django.views.decorators.csrf import csrf_exempt # or use the csrf token client-side
from .models import WastePickup
@csrf_exempt
@require_POST
def record_pickup(request, subdivision, vehicle):
"""
Expect JSON body:
{ "step_id": "<id>", "quarter": <1-4> }
"""
try:
payload = json.loads(request.body)
step_id = payload['step_id']
quarter = int(payload['quarter'])
if quarter not in (1,2,3,4):
raise ValueError
except (KeyError, ValueError, json.JSONDecodeError):
return HttpResponseBadRequest("Invalid payload")
WastePickup.objects.create(
subdivision=subdivision,
vehicle=vehicle,
step_id=step_id,
quarter=quarter
)
return JsonResponse({"status":"ok"})

View File

10
pxy_dashboard/admin.py Normal file
View File

@ -0,0 +1,10 @@
from django.contrib import admin
from .models import SidebarMenuItem
@admin.register(SidebarMenuItem)
class SidebarMenuAdmin(admin.ModelAdmin):
list_display = ("label", "type", "url", "order", "parent")
list_filter = ("type", "parent")
search_fields = ("label", "url")
ordering = ("order",)

6
pxy_dashboard/apps.py Normal file
View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class PxyDashboardConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'pxy_dashboard'

View File

View File

@ -0,0 +1,47 @@
from django.contrib import admin
from .models import Country, GeoScenario, OptScenario
@admin.register(Country)
class CountryAdmin(admin.ModelAdmin):
list_display = ('code', 'name')
search_fields = ('code', 'name')
ordering = ('name',)
@admin.register(GeoScenario)
class GeoScenarioAdmin(admin.ModelAdmin):
list_display = ('name', 'country', 'upload_date', 'geographic_field_name')
list_filter = ('country', 'upload_date')
search_fields = ('name', 'country__name')
readonly_fields = ('upload_date',)
fieldsets = (
(None, {
'fields': (
'name',
'country',
'geographic_field_name',
'csv_file',
'upload_date',
)
}),
)
@admin.register(OptScenario)
class OptScenarioAdmin(admin.ModelAdmin):
list_display = ("name", "type_of_waste", "strategy", "geo_scenario", "upload_date")
list_filter = ("type_of_waste", "strategy", "upload_date")
search_fields = ("name",)
readonly_fields = ("upload_date",)
fieldsets = (
(None, {
"fields": ("name", "type_of_waste", "strategy", "geo_scenario")
}),
("Archivos del escenario", {
"fields": ("optimized_csv", "dispatch_json")
}),
("Metadata", {
"fields": ("upload_date",)
}),
)

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class AppsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'pxy_dashboard.apps'

View File

@ -0,0 +1,58 @@
# Generated by Django 5.0.3 on 2025-05-19 00:37
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Country',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=3, unique=True)),
('name', models.CharField(max_length=100)),
],
options={
'verbose_name': 'País',
'verbose_name_plural': 'Países',
},
),
migrations.CreateModel(
name='GeoScenario',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Nombre del escenario geográfico base', max_length=255)),
('upload_date', models.DateTimeField(auto_now_add=True)),
('geographic_field_name', models.CharField(help_text='Columna con el identificador geográfico (ej. N_URBANO, PUEBLO)', max_length=100)),
('csv_file', models.FileField(upload_to='geo_scenarios/')),
('country', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='apps.country')),
],
options={
'verbose_name': 'Escenario Geográfico',
'verbose_name_plural': 'Escenarios Geográficos',
},
),
migrations.CreateModel(
name='OptScenario',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(help_text='Nombre del escenario de optimización', max_length=255)),
('type_of_waste', models.CharField(help_text='Tipo de residuo (ej. orgánico, reciclable)', max_length=100)),
('strategy', models.CharField(help_text='Método de optimización utilizado', max_length=100)),
('upload_date', models.DateTimeField(auto_now_add=True)),
('optimized_csv', models.FileField(upload_to='opt_scenarios/')),
('geo_scenario', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='opt_scenarios', to='apps.geoscenario')),
],
options={
'verbose_name': 'Escenario de Optimización',
'verbose_name_plural': 'Escenarios de Optimización',
},
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.0.3 on 2025-05-19 21:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('apps', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='optscenario',
name='dispatch_json',
field=models.FileField(blank=True, help_text='Archivo JSON con el resultado de despacho por ruta', null=True, upload_to='opt_scenarios/json/'),
),
]

Some files were not shown because too many files have changed in this diff Show More