372 lines
12 KiB
Python
372 lines
12 KiB
Python
import sqlite3
|
|
import os
|
|
import json
|
|
import threading
|
|
import secrets
|
|
from datetime import datetime, timedelta
|
|
import random
|
|
|
|
DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
|
|
DB_PATH = os.path.join(DATA_DIR, 'anuncios.db')
|
|
OLD_JSON_PATH = os.path.join(DATA_DIR, 'anuncios.json')
|
|
|
|
_lock = threading.Lock()
|
|
|
|
|
|
def _get_conn():
|
|
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
|
conn.row_factory = sqlite3.Row
|
|
return conn
|
|
|
|
|
|
def _add_column_if_missing(conn, table, column, col_type):
|
|
"""SQLite-safe ALTER TABLE ADD COLUMN if the column does not exist."""
|
|
cur = conn.execute(f"PRAGMA table_info({table})")
|
|
cols = [row['name'] for row in cur.fetchall()]
|
|
if column not in cols:
|
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
|
|
|
|
|
|
def init_db():
|
|
os.makedirs(DATA_DIR, exist_ok=True)
|
|
with _lock:
|
|
conn = _get_conn()
|
|
conn.execute('''
|
|
CREATE TABLE IF NOT EXISTS anuncios (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
titulo TEXT NOT NULL,
|
|
contenido TEXT NOT NULL,
|
|
categoria TEXT NOT NULL DEFAULT 'general',
|
|
contacto TEXT NOT NULL DEFAULT 'madrid@craigslist.local',
|
|
ciudad TEXT NOT NULL DEFAULT 'madrid',
|
|
fecha TEXT NOT NULL
|
|
)
|
|
''')
|
|
_add_column_if_missing(conn, 'anuncios', 'ciudad', "TEXT NOT NULL DEFAULT 'madrid'")
|
|
_add_column_if_missing(conn, 'anuncios', 'token', "TEXT")
|
|
conn.execute('''
|
|
CREATE INDEX IF NOT EXISTS idx_categoria ON anuncios(categoria)
|
|
''')
|
|
conn.execute('''
|
|
CREATE INDEX IF NOT EXISTS idx_ciudad ON anuncios(ciudad)
|
|
''')
|
|
conn.execute('''
|
|
CREATE INDEX IF NOT EXISTS idx_fecha ON anuncios(fecha DESC)
|
|
''')
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
_migrate_json()
|
|
|
|
|
|
def _migrate_json():
|
|
if not os.path.exists(OLD_JSON_PATH):
|
|
return
|
|
with _lock:
|
|
conn = _get_conn()
|
|
cur = conn.execute('SELECT COUNT(*) as total FROM anuncios')
|
|
if cur.fetchone()['total'] > 0:
|
|
conn.close()
|
|
return
|
|
|
|
with open(OLD_JSON_PATH, 'r', encoding='utf-8') as f:
|
|
anuncios = json.load(f)
|
|
|
|
for a in anuncios:
|
|
conn.execute('''
|
|
INSERT INTO anuncios (id, titulo, contenido, categoria, contacto, ciudad, fecha)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
''', (
|
|
a.get('id'),
|
|
a.get('titulo', ''),
|
|
a.get('contenido', ''),
|
|
a.get('categoria', 'general'),
|
|
a.get('contacto', 'madrid@craigslist.local'),
|
|
a.get('ciudad', 'madrid'),
|
|
a.get('fecha', datetime.now().strftime('%Y-%m-%d %H:%M'))
|
|
))
|
|
conn.execute("DELETE FROM sqlite_sequence WHERE name='anuncios'")
|
|
if anuncios:
|
|
max_id = max(a.get('id', 0) for a in anuncios)
|
|
conn.execute("INSERT INTO sqlite_sequence(name,seq) VALUES ('anuncios', ?)", (max_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
try:
|
|
os.rename(OLD_JSON_PATH, OLD_JSON_PATH + '.backup')
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def listar_anuncios(categoria=None, busqueda=None, ciudad=None):
|
|
query = 'SELECT * FROM anuncios WHERE 1=1'
|
|
params = []
|
|
if ciudad:
|
|
query += ' AND ciudad = ?'
|
|
params.append(ciudad)
|
|
if categoria:
|
|
query += ' AND categoria = ?'
|
|
params.append(categoria)
|
|
if busqueda:
|
|
query += ' AND (titulo LIKE ? OR contenido LIKE ?)'
|
|
params.extend([f'%{busqueda}%', f'%{busqueda}%'])
|
|
query += ' ORDER BY fecha DESC'
|
|
|
|
with _lock:
|
|
conn = _get_conn()
|
|
cur = conn.execute(query, params)
|
|
rows = [dict(r) for r in cur.fetchall()]
|
|
conn.close()
|
|
return rows
|
|
|
|
|
|
def obtener_anuncio(anuncio_id):
|
|
with _lock:
|
|
conn = _get_conn()
|
|
cur = conn.execute('SELECT * FROM anuncios WHERE id = ?', (anuncio_id,))
|
|
row = cur.fetchone()
|
|
conn.close()
|
|
return dict(row) if row else None
|
|
|
|
|
|
def crear_anuncio(titulo, contenido, categoria, contacto, ciudad='madrid'):
|
|
fecha = datetime.now().strftime('%Y-%m-%d %H:%M')
|
|
token = secrets.token_urlsafe(16)
|
|
with _lock:
|
|
conn = _get_conn()
|
|
cur = conn.execute('''
|
|
INSERT INTO anuncios (titulo, contenido, categoria, contacto, ciudad, fecha, token)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
''', (titulo, contenido, categoria, contacto, ciudad, fecha, token))
|
|
anuncio_id = cur.lastrowid
|
|
conn.commit()
|
|
conn.close()
|
|
return anuncio_id, token
|
|
|
|
|
|
def borrar_anuncio(anuncio_id, token=None):
|
|
with _lock:
|
|
conn = _get_conn()
|
|
cur = conn.execute('SELECT token FROM anuncios WHERE id = ?', (anuncio_id,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
conn.close()
|
|
return False
|
|
stored_token = row['token']
|
|
if stored_token and stored_token != token:
|
|
conn.close()
|
|
return False
|
|
cur = conn.execute('DELETE FROM anuncios WHERE id = ?', (anuncio_id,))
|
|
deleted = cur.rowcount
|
|
conn.commit()
|
|
conn.close()
|
|
return deleted > 0
|
|
|
|
|
|
def actualizar_anuncio(anuncio_id, titulo=None, contenido=None, categoria=None, contacto=None, ciudad=None):
|
|
campos = []
|
|
valores = []
|
|
if titulo is not None:
|
|
campos.append('titulo = ?')
|
|
valores.append(titulo)
|
|
if contenido is not None:
|
|
campos.append('contenido = ?')
|
|
valores.append(contenido)
|
|
if categoria is not None:
|
|
campos.append('categoria = ?')
|
|
valores.append(categoria)
|
|
if contacto is not None:
|
|
campos.append('contacto = ?')
|
|
valores.append(contacto)
|
|
if ciudad is not None:
|
|
campos.append('ciudad = ?')
|
|
valores.append(ciudad)
|
|
if not campos:
|
|
return False
|
|
valores.append(anuncio_id)
|
|
|
|
with _lock:
|
|
conn = _get_conn()
|
|
cur = conn.execute(
|
|
f"UPDATE anuncios SET {', '.join(campos)} WHERE id = ?",
|
|
valores
|
|
)
|
|
updated = cur.rowcount
|
|
conn.commit()
|
|
conn.close()
|
|
return updated > 0
|
|
|
|
|
|
def listar_categorias(ciudad=None):
|
|
query = 'SELECT DISTINCT categoria FROM anuncios WHERE 1=1'
|
|
params = []
|
|
if ciudad:
|
|
query += ' AND ciudad = ?'
|
|
params.append(ciudad)
|
|
query += ' ORDER BY categoria'
|
|
|
|
with _lock:
|
|
conn = _get_conn()
|
|
cur = conn.execute(query, params)
|
|
rows = [r['categoria'] for r in cur.fetchall()]
|
|
conn.close()
|
|
return rows
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Seed / migración helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TITULOS = {
|
|
'trabajo': [
|
|
'Desarrollador backend senior',
|
|
'Community manager freelance',
|
|
'Repartidor con moto propia',
|
|
'Camarero/a para fin de semana',
|
|
'Diseñador UX/UI remoto',
|
|
'Ingeniero devops',
|
|
'Profesor de inglés nativo',
|
|
'Ayudante de almacén',
|
|
],
|
|
'venta': [
|
|
'Sofa chaise longue casi nuevo',
|
|
'iPhone 14 pro 256gb',
|
|
'Bicicleta de montaña trek',
|
|
'Libros de programación pack',
|
|
'Monitor 27 pulgadas 4k',
|
|
'Nevera combi no frost',
|
|
'Coche citroen c3 2019',
|
|
'Patinete electrico xiaomi',
|
|
],
|
|
'servicios': [
|
|
'Limpieza de hogar por horas',
|
|
'Reparación de electrodomesticos',
|
|
'Clases particulares de mates',
|
|
'Diseño web profesional',
|
|
'Fontanero económico',
|
|
'Paseador de perros',
|
|
'Traducciones inglés-español',
|
|
'Electricista autorizado',
|
|
],
|
|
'alquiler': [
|
|
'Piso centro 2 habitaciones',
|
|
'Habitación en piso compartido',
|
|
'Estudio luminoso con terraza',
|
|
'Alquiler temporal verano',
|
|
'Duplex con parking incluido',
|
|
'Chalet adosado zona norte',
|
|
'Loft industrial reformado',
|
|
'Apartamento playa primera línea',
|
|
],
|
|
'compra': [
|
|
'Busco piso en el centro',
|
|
'Compro coche gasolina manual',
|
|
'Se busca mobiliario vintage',
|
|
'Quiero comprar portatil gaming',
|
|
'Busco terreno para autocaravana',
|
|
'Compro material de escalada',
|
|
'Se busca piano de pared',
|
|
'Quiero comprar patinete eléctrico',
|
|
],
|
|
'eventos': [
|
|
'Concierto indie en la sala',
|
|
'Fiesta de inauguración local',
|
|
'Mercadillo artesanal domingo',
|
|
'Taller de cocina vegana',
|
|
'Ruta senderista guiada',
|
|
'Exposición fotográfica gratis',
|
|
'Open mic comedy night',
|
|
'Yoga en el parque sábados',
|
|
],
|
|
'tecnologia': [
|
|
'Raspberry pi 4 kit completo',
|
|
'Teclado mecánico custom',
|
|
'Router mesh wifi 6',
|
|
'Gafas realidad virtual',
|
|
'Disco duro externo 2tb',
|
|
'Webcam 4k para streaming',
|
|
'Impresora 3d ender 3',
|
|
'Switch oled con juegos',
|
|
],
|
|
'hogar': [
|
|
'Lámpara de pie nordica',
|
|
'Juego de sillas de comedor',
|
|
'Cortinas blackout a medida',
|
|
'Aspirador robot con mapeo',
|
|
'Planta monstera grande',
|
|
'Mesa de centro elevable',
|
|
'Colchón viscoelástico 150',
|
|
'Cubertería acero inox 72 piezas',
|
|
],
|
|
'ocio': [
|
|
'Entradas cine miercoles',
|
|
'Mazo cartas magic coleccion',
|
|
'Patines en línea talla 42',
|
|
'Videojuego elden ring ps5',
|
|
'Kitesurf completo principiante',
|
|
'Libro cocina japonesa',
|
|
'Entrada festival música',
|
|
'Guitarra acústica yamaha',
|
|
],
|
|
'contactos': [
|
|
'Busco compañero de piso',
|
|
'Quedada rol en la taberna',
|
|
'Club de lectura mensual',
|
|
'Grupo de running mañanas',
|
|
'Busco duo para ranked lol',
|
|
'Quedada idiomas intercambio',
|
|
'Compañero de escalada',
|
|
'Busco banda para batería',
|
|
],
|
|
}
|
|
|
|
_DESCRIPCIONES = {
|
|
'trabajo': 'Horario flexible. Se valora experiencia previa. Envía CV por correo.',
|
|
'venta': 'En perfecto estado. Precio negociable. Entrega en mano preferible.',
|
|
'servicios': 'Presupuesto sin compromiso. Atención rápida y profesional.',
|
|
'alquiler': 'Sin comisión de agencia. Gastos incluidos o no según caso.',
|
|
'compra': 'Busco buen estado y precio razonable. Pago al contado.',
|
|
'eventos': 'Entrada libre hasta completar aforo. Más info por privado.',
|
|
'tecnologia': 'Con garantía y caja original. Factura disponible.',
|
|
'hogar': 'Muy cuidado, sin ralladuras. Recogida en domicilio.',
|
|
'ocio': 'Perfecto para regalo o uso personal. Consulta detalles.',
|
|
'contactos': 'Gente con buen rollo y ganas de pasarlo bien. Únete!',
|
|
}
|
|
|
|
_CATEGORIAS = list(_TITULOS.keys())
|
|
|
|
|
|
def _random_date(days_back=90):
|
|
delta = timedelta(days=random.randint(0, days_back), hours=random.randint(0, 23))
|
|
return (datetime.now() - delta).strftime('%Y-%m-%d %H:%M')
|
|
|
|
|
|
def seed_anuncios(ciudades, por_ciudad=4):
|
|
"""Genera anuncios aleatorios distribuidos por ciudades."""
|
|
with _lock:
|
|
conn = _get_conn()
|
|
for ciudad in ciudades:
|
|
for _ in range(por_ciudad):
|
|
cat = random.choice(_CATEGORIAS)
|
|
titulo = random.choice(_TITULOS[cat])
|
|
contenido = _DESCRIPCIONES[cat]
|
|
contacto = f"{ciudad}@craigslist.local"
|
|
fecha = _random_date()
|
|
conn.execute('''
|
|
INSERT INTO anuncios (titulo, contenido, categoria, contacto, ciudad, fecha)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
''', (titulo, contenido, cat, contacto, ciudad, fecha))
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def migrar_ciudad_existentes(ciudad_default='madrid'):
|
|
"""Asigna ciudad a anuncios que aún no la tengan definida."""
|
|
with _lock:
|
|
conn = _get_conn()
|
|
conn.execute('''
|
|
UPDATE anuncios SET ciudad = ? WHERE ciudad IS NULL OR ciudad = ''
|
|
''', (ciudad_default,))
|
|
conn.commit()
|
|
conn.close()
|