# -*- coding: utf-8 -*-
"""
Monomotapa - A Micro CMS
Copyright (C) 2014, Paul Munday.
PO Box 28228, Portland, OR, USA 97228
paul at paulmunday.net
Modificado por: Rodrigo Garcia 2017 https://rmgss.net/contacto
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
Monomotapa:
a city whose inhabitants are bounded by deep feelings of friendship,
so that they intuit one another's most secret needs and desire.
For instance, if one dreams that his friend is sad, the friend will
perceive the distress and rush to the sleepers rescue.
(Jean de La Fontaine, *Fables choisies, mises en vers*, VIII:11 Paris,
2nd ed., 1678-9)
cited in :
Alberto Manguel and Gianni Guadalupi, *The Dictionary of Imaginary Places*,
Bloomsbury, London, 1999.
A micro cms written using the Flask microframework, orignally to manage my
personal site. It is designed so that publishing a page requires no more than
dropping a markdown page in the appropriate directory (though you need to edit
a json file if you want it to appear in the top navigation).
It can also display its own source code and run its own unit tests.
The name 'monomotapa' was chosen more or less at random (it shares an initial
with me) as I didn't want to name it after the site and be typing import
paulmunday, or something similar, as that would be strange.
"""
from flask import render_template, abort, Markup, escape, request #, make_response
from flask import redirect
from werkzeug.utils import secure_filename
from pygments import highlight
from pygments.lexers import PythonLexer, HtmlDjangoLexer, TextLexer
from pygments.formatters import HtmlFormatter
import markdown
from time import gmtime, strptime, strftime, ctime, mktime
import datetime
import os.path
import os
import subprocess
import json
import traceback
from collections import OrderedDict
from simplemotds import SimpleMotd
from monomotapa import app
from monomotapa.config import ConfigError
from monomotapa.utils import captcha_comprobar_respuesta, captcha_pregunta_opciones_random
from monomotapa.utils import categorias_de_post, categoriasDePost, categoriasList, cabezaPost
from monomotapa.utils import titulo_legible, metaTagsAutomaticos
from markdown.extensions.toc import TocExtension
json_pattrs = {}
with open(os.path.join('monomotapa','pages.json'), 'r') as pagefile:
json_pattrs = json.load(pagefile)
simplemotd = SimpleMotd("config_simplemotds.json")
class MonomotapaError(Exception):
"""create classs for own errors"""
pass
def get_page_attributes(jsonfile):
"""Returns dictionary of page_attributes.
Defines additional static page attributes loaded from json file.
N.B. static pages do not need to have attributes defined there,
it is sufficient to have a page.md in src for each /page
possible values are src (name of markdown file to be rendered)
heading, title, and trusted (i.e. allow embeded html in markdown)"""
try:
with open(src_file(jsonfile), 'r') as pagesfile:
page_attributes = json.load(pagesfile)
except IOError:
page_attributes = []
return page_attributes
def get_page_attribute(attr_src, page, attribute):
"""returns attribute of page if it exists, else None.
attr_src = dictionary(from get_page_attributes)"""
if page in attr_src and attribute in attr_src[page]:
return attr_src[page][attribute]
else:
return None
# Navigation
def top_navigation(page):
"""Generates navigation as an OrderedDict from navigation.json.
Navigation.json consists of a json array(list) "nav_order"
containing the names of the top navigation elements and
a json object(dict) called "nav_elements"
if a page is to show up in the top navigation
there must be an entry present in nav_order but there need not
be one in nav_elements. However if there is the key must be the same.
Possible values for nav_elements are link_text, url and urlfor
The name from nav_order will be used to set the link text,
unless link_text is present in nav_elements.
url and urlfor are optional, however if ommited the url wil be
generated in the navigation by url_for('staticpage', page=[key])
equivalent to @app.route"/page"; def page())
which may not be correct. If a url is supplied it will be used
otherwise if urlfor is supplied it the url will be
generated with url_for(urlfor). url takes precendence so it makes
no sense to supply both.
Web Sign-in is supported by adding a "rel": "me" attribute.
"""
with open(src_file('navigation.json'), 'r') as navfile:
navigation = json.load(navfile)
base_nav = OrderedDict({})
for key in navigation["nav_order"]:
nav = {}
nav['base'] = key
nav['link_text'] = key
if key in navigation["nav_elements"]:
elements = navigation["nav_elements"][key]
nav.update(elements)
base_nav[key] = nav
return {'navigation' : base_nav, 'page' : page}
# For pages
class Page:
"""Generates pages as objects"""
def __init__(self, page, **kwargs):
"""Define attributes for pages (if present).
Sets self.name, self.title, self.heading, self.trusted etc
This is done through indirection so we can update the defaults
(defined in the 'attributes' dictionary) with values from config.json
or pages.json easily without lots of if else statements.
If css is supplied it will overide any default css. To add additional
style sheets on a per page basis specifiy them in pages.json.
The same also applies with hlinks.
css is used to set locally hosted stylesheets only. To specify
external stylesheets use hlinks: in config.json for
default values that will apply on all pages unless overidden, set here
to override the default. Set in pages.json to add after default.
"""
# set default attributes
self.page = page.rstrip('/')
self.defaults = get_page_attributes('defaults.json')
self.pages = get_page_attributes('pages.json')
self.url_base = self.defaults['url_base']
title = titulo_legible(page.lower())
heading = titulo_legible(page.capitalize())
self.categorias = categoriasDePost(self.page)
self.exclude_toc = True
try:
self.default_template = self.defaults['template']
except KeyError:
raise ConfigError('template not found in default.json')
# will become self.name, self.title, self.heading,
# self.footer, self.internal_css, self.trusted
attributes = {'name' : self.page, 'title' : title,
'navigation' : top_navigation(self.page),
'heading' : heading, 'footer' : None,
'css' : None , 'hlinks' :None, 'internal_css' : None,
'trusted': False,
'preview-chars': 250,
}
# contexto extra TODO: revisar otra forma de incluir un contexto
self.contexto = {}
self.contexto['consejo'] = simplemotd.getMotdContent()
# set from defaults
attributes.update(self.defaults)
# override with kwargs
attributes.update(kwargs)
# override attributes if set in pages.json
if page in self.pages:
attributes.update(self.pages[page])
# set attributes (as self.name etc) using indirection
for attribute, value in attributes.items():
# print('attribute', attribute, '=-==>', value)
setattr(self, attribute, value)
# meta tags
try:
self.pages[self.page]['title'] = attributes['title']
self.pages[self.page]['url_base'] = self.url_base
metaTags = metaTagsAutomaticos(self.page, self.pages.get(self.page, {}))
self.meta = metaTags
# for key, value in self.pages[self.page].items():
# print(' ', key, ' = ', value)
except Exception as e:
tb = traceback.format_exc()
print('Error assigning meta:', str(e), '\n', str(tb))
# reset these as we want to append rather than overwrite if supplied
if 'css' in kwargs:
self.css = kwargs['css']
elif 'css' in self.defaults:
self.css = self.defaults['css']
if 'hlinks' in kwargs:
self.hlinks = kwargs['hlinks']
elif 'hlinks' in self.defaults:
self.hlinks = self.defaults['hlinks']
# append hlinks and css from pages.json rather than overwriting
# if css or hlinks are not supplied they are set to default
if page in self.pages:
if 'css' in self.pages[page]:
self.css = self.css + self.pages[page]['css']
if 'hlinks' in self.pages[page]:
self.hlinks = self.hlinks + self.pages[page]['hlinks']
# append heading to default if set in config
self.title = self.title + app.config.get('default_title', '')
def _get_markdown(self):
"""returns rendered markdown or 404 if source does not exist"""
src = self.get_page_src(self.page, 'src', 'md')
if src is None:
abort(404)
else:
return render_markdown(src, self.trusted)
def get_page_src(self, page, directory=None, ext=None):
""""return path of file (used to generate page) if it exists,
or return none.
Also returns the template used to render that page, defaults
to static.html.
It will optionally add an extension, to allow
specifiying pages by route."""
# is it stored in a config
pagename = get_page_attribute(self.pages, page, 'src')
if not pagename:
pagename = page + get_extension(ext)
if os.path.exists(src_file(pagename , directory)):
return src_file(pagename, directory)
else:
return None
def get_template(self, page):
"""returns the template for the page"""
pagetemplate = get_page_attribute(self.pages, page, 'template')
if not pagetemplate:
pagetemplate = self.default_template
if os.path.exists(src_file(pagetemplate , 'templates')):
return pagetemplate
else:
raise MonomotapaError("Template: %s not found" % pagetemplate)
def generate_page(self, contents=None):
"""return a page generator function.
For static pages written in Markdown under src/.
contents are automatically rendered.
N.B. See note above in about headers"""
toc = '' # table of contents
if not contents:
contents, toc = self._get_markdown()
# print('////', toc)
template = self.get_template(self.page)
# print('......................')
# def mos(**kwargs):
# for k in kwargs:
# print(k, end=',')
# mos(**vars(self))
return render_template(template,
contents = Markup(contents),
toc=toc,
**vars(self))
# helper functions
def src_file(name, directory=None):
"""return potential path to file in this app"""
if not directory:
return os.path.join( 'monomotapa', name)
else:
return os.path.join('monomotapa', directory, name)
def get_extension(ext):
'''constructs extension, adding or stripping leading . as needed.
Return null string for None'''
if ext is None:
return ''
elif ext[0] == '.':
return ext
else:
return '.%s' % ext
def render_markdown(srcfile, trusted=False):
""" Returns markdown file rendered as html and the table of contents as html.
Defaults to untrusted:
html characters (and character entities) are escaped
so will not be rendered. This departs from markdown spec
which allows embedded html."""
try:
with open(srcfile, 'r') as f:
src = f.read()
md = markdown.Markdown(extensions=['toc', 'codehilite'])
md.convert(src)
toc = md.toc
if trusted == True:
content = markdown.markdown(src,
extensions=['codehilite',
TocExtension(permalink=True)])
else:
content = markdown.markdown(escape(src),
extensions=['codehilite',
TocExtension(permalink=True)])
return content, toc
except IOError:
return None
def render_pygments(srcfile, lexer_type):
"""returns src(file) marked up with pygments"""
if lexer_type == 'python':
with open(srcfile, 'r') as f:
src = f.read()
contents = highlight(src, PythonLexer(), HtmlFormatter())
elif lexer_type == 'html':
with open(srcfile, 'r') as f:
src = f.read()
contents = highlight(src, HtmlDjangoLexer(), HtmlFormatter())
# default to TextLexer for everything else
else:
with open(srcfile, 'r') as f:
src = f.read()
contents = highlight(src, TextLexer(), HtmlFormatter())
return contents
def get_pygments_css(style=None):
"""returns css for pygments, use as internal_css"""
if style is None:
style = 'friendly'
return HtmlFormatter(style=style).get_style_defs('.highlight')
def heading(text, level):
"""return as html heading at h[level]"""
heading_level = 'h%s' % str(level)
return '\n<%s>%s</%s>\n' % (heading_level, text, heading_level)
def posts_list(ordenar_por_fecha=True, ordenar_por_nombre=False):
'''Retorna una lista con los nombres de archivos con extension .md
dentro de la cappeta src/posts, por defecto retorna una lista con
la tupla (nombre_archivo, fecha_subida)'''
lista_posts = []
lp = []
if ordenar_por_nombre:
try:
ow = os.walk("monomotapa/src/posts")
p , directorios , archs = ow.__next__()
except OSError:
print ("[posts] - Error: Cant' os.walk() on monomotapa/src/posts except OSError")
else:
for arch in archs:
if arch.endswith(".md") and not arch.startswith("#") \
and not arch.startswith("~") and not arch.startswith("."):
lista_posts.append(arch)
lista_posts.sort()
return lista_posts
if ordenar_por_fecha:
try:
ow = os.walk("monomotapa/src/posts")
p,d,files=ow.__next__()
except OSError:
print ("[posts] - Error: Can't os.walk() on monomotapa/src/posts except OSError.")
else:
for f in files:
nombre_con_ruta = os.path.join("monomotapa/src/posts", f)
if not f.endswith("~") and not f.startswith("#") and not f.startswith("."):
secs_modificacion = SecsModificacionPostDesdeJson(f, json_pattrs)
ultima_modificacion = os.path.getmtime(nombre_con_ruta)
lp.append((secs_modificacion, ultima_modificacion, f))
lp.sort()
lp.reverse()
# colocando fecha en formato
for tupla in lp:
#fecha = strftime("a, %d %b %Y %H:%M:%S", ctime(tupla[0]))
cfecha = ctime(tupla[1])
#fecha = strptime("%a %b %d %H:%M:%S %Y", cfecha)
lista_posts.append((cfecha, tupla[2]))
return lista_posts
def categorias_list(categoria=None):
""" Rotorna una lista con los nombres de posts y el numero de posts que
pertenecen a la categoria dada o a cada categoria.
Las categorias se obtienen analizando la primera linea de cada archivo .md
an la carpeta donde se almacenan los posts.
Si no se especifica `categoria' cada elemento de la lista devuelta es:
(nombre_categoria, numero_posts, [nombres_posts])
si se especifica `categoria' cada elemento de la lista devuelta es:
(numero_posts, [nombres_posts]
"""
lista_posts = posts_list(ordenar_por_nombre=True)
lista_categorias = []
if categoria is not None:
c = 0
posts = []
for post in lista_posts:
nombre_arch = "monomotapa/src/posts/"+post
with open(nombre_arch, 'r') as file:
linea = file.readline().decode("utf-8")
lc = linea.split("[#")[1:]
for cad in lc:
cat = cad.split("]")[0]
if cat == categoria:
c += 1
posts.append(post)
lista_categorias = (c, posts)
return lista_categorias
dic_categorias = {}
for post in lista_posts:
nombre_arch = "monomotapa/src/posts/"+post
with open(nombre_arch, 'r') as fil:
linea = fil.readline().decode('utf-8') # primera linea
# extrayendo las categorias y registrando sus ocurrencias
# ejemplo: catgorías: [#reflexión](categoria/reflexion) [#navidad](categoria/navidad)
# extrae: [reflexion,navidad]
lc = linea.split("[#")[1:]
for cad in lc:
cat = cad.split("]")[0]
if cat not in dic_categorias:
dic_categorias[cat] = (1,[post]) # nuevo registro por categoria
else:
tupla = dic_categorias[cat]
c = tupla[0] + 1
lis = tupla[1]
if post not in lis:
lis.append(post)
dic_categorias[cat] = (c, lis)
# convirtiendo en lista
for k, v in dic_categorias.iteritems():
lista_categorias.append((k,v[0],v[1]))
lista_categorias.sort()
lista_categorias.reverse()
return lista_categorias
def cabeza_post(archivo , max_caracteres=250, categorias=True):
""" Devuelve las primeras lineas de una archivo de post (en formato markdown)
con un maximo numero de caracteres excluyendo titulos en la cabeza devuelta.
Si se especifica `categorias' en True
Se devuelve una lista de la forma:
(cabeza_post, categorias)
donde categorias son cadenas con los nombres de las categorias a la que
pertenece el post
"""
cabeza_post = ""
cats = []
with open(os.path.join("monomotapa/src/posts",archivo)) as file:
# analizando si hay titulos al principio
# Se su pone que la primera linea es de categorias
for linea in file.readlines():
linea = linea.decode("utf-8")
if linea.startswith(u"categorías:") or linea.startswith("categorias"):
if categorias:
cats = categoriasDePost(archivo)
#cats = categorias_de_post(archivo)
else:
# evitando h1, h2
if linea.startswith("##") or linea.startswith("#"):
cabeza_post += " "
else:
cabeza_post += linea
if len(cabeza_post) >= max_caracteres:
break
cabeza_post = cabeza_post[0:max_caracteres-1]
if categorias:
return (cabeza_post, cats)
return cabeza_post
def ultima_modificacion_archivo(archivo):
""" Retorna una cadena indicando la fecha de ultima modificacion del
`archivo' dado, se asume que `archivo' esta dentro la carpeta "monomotapa/src"
Retorna una cadena vacia en caso de no poder abrir `archivo'
"""
try:
ts = strptime(ctime(os.path.getmtime("monomotapa/src/"+archivo+".md")))
return strftime("%d %B %Y", ts)
except OSError:
return ""
def SecsModificacionPostDesdeJson(archivo, dict_json):
''' dado el post con nombre 'archivo' busca en 'dict_json' el
attribute 'date' y luego obtiene los segundos totales desde
esa fecha.
Si no encuentra 'date' para 'archivo' en 'dict.json'
retorna los segundos totales desde la ultima modificacion
del archivo de post directamente (usa os.path.getmtime)
'''
nombre = archivo.split('.md')[0] # no contar extension .md
nombre_con_ruta = os.path.join("monomotapa/src/posts", archivo)
date_str = dict_json.get('posts/'+nombre, {}).\
get('attributes',{}).\
get('date','')
if date_str == '':
# el post no tiene "date" en pages.json
return os.path.getmtime(nombre_con_ruta)
else:
time_struct = strptime(date_str, '%Y-%m-%d')
dt = datetime.datetime.fromtimestamp(mktime(time_struct))
return (dt - datetime.datetime(1970,1,1)).total_seconds()
def noticias_recientes(cantidad=11, max_caracteres=250,
categoria=None, pagina=0):
'''Devuelve una lista con hasta `cantidad' de posts mas recientes,
un maximo de `max_caracteres' de caracteres del principio del post y
el numero total de posts encontrados
Si se proporciona `categoria' devuelve la lista de posts solamente
pertenecientes esa categoria.
Si `pagina' > 0 se devulve hasta `cantidad' numero de posts en el
rango de [ cantidad*pagina : cantidad*(pagina+1)]
Cada elemento de la lista devuelta contiene:
(nombre_post, ultima_modificacion, cabeza_archivo, categorias)
Al final se retorna: (lista_posts, numero_de_posts)
'''
lista_posts = []
lp = []
num_posts = 0
posts_en_categoria = []
if categoria is not None:
#posts_en_categoria = categorias_list(categoria)[1]
posts_en_categoria = categoriasList(categoria)[1]
# categoria especial fotos
if categoria == "fotos":
l = []
for p in posts_en_categoria:
l.append(p + '.md')
posts_en_categoria = l
try:
ow = os.walk("monomotapa/src/posts")
p,d,files = ow.__next__()
#p,d,files=ow.next()
except OSError:
print ("[posts] - Error: Can't os.walk() on monomotapa/src/posts except OSError.")
else:
for f in files:
nombre_con_ruta = os.path.join("monomotapa/src/posts", f)
if not f.endswith("~") and not f.startswith("#") and not f.startswith("."):
secs_modificacion = SecsModificacionPostDesdeJson(f, json_pattrs)
ultima_modificacion = os.path.getmtime(nombre_con_ruta)
previewChars = json_pattrs.get('posts/'+f[:-3], {}).\
get('attributes', {}).\
get('preview-chars', max_caracteres)
if categoria is not None:
if f in posts_en_categoria:
lp.append((secs_modificacion,
ultima_modificacion,
previewChars,
f))
num_posts += 1
else:
lp.append((secs_modificacion,
ultima_modificacion,
previewChars,
f))
num_posts += 1
lp.sort()
lp.reverse()
# seleccionado por paginas
lp = lp[cantidad*pagina : cantidad*(pagina+1)]
# colocando fecha en formato
for tupla in lp:
cfecha = ctime(tupla[1])
nombre_post = tupla[3].split(os.sep)[-1]
previewChars = tupla[2]
#contenido = cabeza_post(tupla[3], max_caracteres=previewChars)[0]
#categorias = cabeza_post(tupla[3], max_caracteres=previewChars)[1]
contenido = cabezaPost(tupla[3], max_caracteres=previewChars)[0]
categorias = cabezaPost(tupla[3], max_caracteres=previewChars)[1]
cabeza_archivo = markdown.markdown(escape(contenido + ' ...'))
lista_posts.append((nombre_post[:-3], cfecha, \
cabeza_archivo, categorias))
return (lista_posts, num_posts)
def noticias_relacionadas(cantidad=5, nombre=None):
"""Retorna una lista con posts relacionadas, es decir que tienen son de las
mismas categorias que el post con nombre `nombre'.
Cada elemento de la lista de posts contiene el nombre del post
"""
#categorias = categorias_de_post(nombre) ## TODO: corregir categorias de post
categorias = categoriasDePost(nombre)
numero = 0
if categorias is None:
return None
posts = []
for categoria in categorias:
#lista = categorias_list(categoria)[1] # nombres de posts
lista = categoriasList(categoria)[1]
numero += len(lista)
for nombre_post in lista:
if nombre_post + '.md' != nombre:
posts.append(nombre_post)
if numero >= cantidad:
return posts
return posts
def rss_ultimos_posts_jinja(cantidad=15):
"""Retorna una lista de los ultimos posts preparados para
ser renderizados (usando jinja) como un feed rss
Examina cada post del mas reciente al menos reciente, en
total `cantidad' posts. Por cada post devuelve:
id: id which identifies the entry using a
universally unique and permanent URI
author: Get or set autor data. An author element is a dict containing a
name, an email adress and a uri.
category: A categories has the following fields:
- *term* identifies the category
- *scheme* identifies the categorization scheme via a URI.
- *label* provides a human-readable label for display
comments: Get or set the the value of comments which is the url of the
comments page for the item.
content: Get or set the cntent of the entry which contains or links to the
complete content of the entry.
description(no contiene): Get or set the description value which is the item synopsis.
Description is an RSS only element.
link: Get or set link data. An link element is a dict with the fields
href, rel, type, hreflang, title, and length. Href is mandatory for
ATOM.
pubdate(no contiene): Get or set the pubDate of the entry which indicates when the entry
was published.
title: the title value of the entry. It should contain a human
readable title for the entry.
updated: the updated value which indicates the last time the entry
was modified in a significant way.
"""
lista_posts = []
lp = []
num_posts = 0
try:
ow = os.walk("monomotapa/src/posts")
p,d,files=ow.__next__()
except OSError:
print ("[posts] - Error: Can't os.walk() on monomotapa/src/posts except OSError.")
else:
for f in files:
nombre_con_ruta = os.path.join("monomotapa/src/posts", f)
if not f.endswith("~") and not f.startswith("#") and not f.startswith("."):
lp.append((os.path.getmtime(nombre_con_ruta), f))
num_posts += 1
if num_posts > cantidad:
break
lp.sort()
lp.reverse()
# colocando fecha en formato
for tupla in lp:
nombre_post = tupla[1].split(os.sep)[-1]
#contenido = cabeza_post(tupla[1], max_caracteres=149999)
contenido = cabezaPost(tupla[1], max_caracteres=149999)
id_post = "https://rmgss.net/posts/"+nombre_post[:-3]
#categorias = categorias_de_post(nombre_post)
categorias = categoriasDePost(nombre_post)
dict_categorias = {}
c = ""
for cat in categorias:
c += cat + " "
dict_categorias['label'] = c
#dict_categorias['term'] = c
html = markdown.markdown(escape(contenido))
link = id_post
pubdate = ctime(tupla[0])
title = titulo_legible(nombre_post[:-3]) # no incluir '.md'
updated = pubdate
dict_feed_post = {
"id":id_post,
"author": "Rodrigo Garcia",
"category" : categorias,
"content": html,
"link" : id_post,
"updated" : updated,
"title": title
}
lista_posts.append(dict_feed_post)
return lista_posts
###### Define routes
@app.errorhandler(404)
def page_not_found(e):
""" provides basic 404 page"""
defaults = get_page_attributes('defaults.json')
try:
css = defaults['css']
except KeyError:
css = None
pages = get_page_attributes('pages.json')
if '404' in pages:
if'css' in pages['404']:
css = pages['404']['css']
return render_template('static.html',
title = "404::page not found", heading = "Page Not Found",
navigation = top_navigation('404'),
css = css,
contents = Markup(
"This page is not there, try somewhere else.")), 404
@app.route('/users/', defaults={'page': 1})
@app.route('/users/page/<int:page>')
@app.route("/", defaults={'pagina':0})
@app.route('/<int:pagina>')
def index(pagina):
"""provides index page"""
index_page = Page('index')
lista_posts_recientes, total_posts = noticias_recientes(pagina=pagina)
index_page.contexto['lista_posts_recientes'] = lista_posts_recientes
index_page.contexto['total_posts'] = total_posts
index_page.contexto['pagina_actual'] = int(pagina)
return index_page.generate_page()
# default route is it doe not exist elsewhere
@app.route("/<path:page>")
def staticpage(page):
""" display a static page rendered from markdown in src
i.e. displays /page or /page/ as long as src/page.md exists.
srcfile, title and heading may be set in the pages global
(ordered) dictionary but are not required"""
static_page = Page(page)
return static_page.generate_page()
@app.route("/posts/<page>")
def rposts(page):
""" Mustra las paginas dentro la carpeta posts, no es coincidencia
que en este ultimo directorio se guarden los posts.
Ademas incrusta en el diccionario de contexto de la pagina la
fecha de ultima modificacion del post
"""
static_page = Page("posts/"+page)
ultima_modificacion = ultima_modificacion_archivo("posts/"+page)
static_page.contexto['relacionadas'] = noticias_relacionadas(nombre=page+".md")
static_page.contexto['ultima_modificacion'] = ultima_modificacion
static_page.exclude_toc = False # no excluir Índice de contenidos
return static_page.generate_page()
@app.route("/posts")
def indice_posts():
""" Muestra una lista de todos los posts
"""
lista_posts_fecha = posts_list()
#lista_posts_categoria = categorias_list()
lista_posts_categoria = categoriasList()
static_page = Page("posts")
static_page.contexto['lista_posts_fecha'] = lista_posts_fecha
static_page.contexto['lista_posts_categoria'] = lista_posts_categoria
return static_page.generate_page()
@app.route("/posts/categorias")
def lista_categorias():
""" Muestra una lista de las categorias , los posts pertenecen
a cada una y un conteo"""
#lista_categorias = categorias_list()
lista_categorias = categoriasList()
static_page = Page("categorias")
static_page.contexto['lista_posts_categoria'] = lista_categorias
#return (str(lista_categorias))
return static_page.generate_page()
@app.route("/posts/categoria/<categoria>")
def posts_de_categoria(categoria):
""" Muestra los posts que perteneces a la categoria dada
"""
lista_posts = []
if categoria == "fotos": # caegoria especial fotos
lista_posts, total_posts = noticias_recientes(max_caracteres=1250,categoria=categoria)
static_page = Page("fotos")
static_page.contexto['categoria_actual'] = categoria
static_page.contexto['lista_posts_recientes'] = lista_posts
return static_page.generate_page()
#lista_posts = categorias_list(categoria=categoria)
lista_posts = categoriasList(categoria=categoria)
static_page = Page("categorias")
static_page.contexto['categoria_actual'] = categoria
static_page.contexto['lista_posts_categoria'] = lista_posts
return static_page.generate_page()
@app.route("/posts/recientes", defaults={'pagina':0})
@app.route("/posts/recientes/<int:pagina>")
def posts_recientes(pagina):
""" muestra una lista de los posts mas recientes
TODO: terminar
"""
lista_posts, total_posts = noticias_recientes(max_caracteres=368,
pagina=pagina)
static_page = Page("recientes")
static_page.contexto['lista_posts_recientes'] = lista_posts
static_page.contexto['total_posts'] = total_posts
static_page.contexto['pagina_actual'] = pagina
#return (str(lista_posts))
return static_page.generate_page()
@app.route("/contacto", methods=['GET'])
def contacto():
tupla_captcha = captcha_pregunta_opciones_random()
if tupla_captcha is None:
return ("<br>Parece un error interno!</br>")
pregunta = tupla_captcha[0]
opciones = tupla_captcha[1]
static_page = Page("contacto")
static_page.contexto['pregunta'] = pregunta
static_page.contexto['opciones'] = opciones
return static_page.generate_page()
@app.route("/contactoe", methods=['POST'])
def comprobar_mensaje():
""" Comprueba que el mensaje enviado por la caja de texto sea valido
y si lo es, guarda un archivo de texto con los detalles"""
errors = []
if request.method == "POST":
# comprobando validez
nombre = request.form["nombre"]
dir_respuesta = request.form['dir_respuesta']
mensaje = request.form['mensaje']
pregunta = request.form['pregunta']
respuesta = request.form['respuesta']
if len(mensaje) < 2 or mensaje.startswith(" "):
errors.append("Mensaje invalido")
if not captcha_comprobar_respuesta(pregunta, respuesta):
errors.append("Captcha invalido")
if len(errors) > 0:
return str(errors)
# guardando texto
texto = "Remitente: "+nombre
texto += "\nResponder_a: "+dir_respuesta
texto += "\n--- mensaje ---\n"
texto += mensaje
# TODO: cambiar a direccion especificada en archivo de configuracion
dt = datetime.datetime.now()
nombre = "m_"+str(dt.day)+"_"+str(dt.month)+\
"_"+str(dt.year)+"-"+str(dt.hour)+\
"-"+str(dt.minute)+"-"+str(dt.second)
with open(os.path.join("fbs",nombre), "wb") as f:
f.write(texto.encode("utf-8"))
return redirect("/mensaje_enviado", code=302)
@app.route("/mensaje_enviado")
def mensaje_enviado():
static_page = Page("mensaje_enviado")
return static_page.generate_page()
@app.route("/rss")
def rss_feed():
"""Genera la cadena rss con las 15 ultimas noticias del sitio
TODO: Agregar mecenismo para no generar los rss feeds y solo
devolver el archivo rss.xml generado anteriormente. Esto
quiere decir solamente generar el rss_feed cuando se haya hecho
un actualizacion en los posts mas reciente que la ultima vez
que se genero el rss_feed
"""
#return str(rss_ultimos_posts_jinja())
return render_template("rss.html",
contents = rss_ultimos_posts_jinja())
#**vars(self)
#)
##### specialized pages
@app.route("/source")
def source():
"""Display source files used to render a page"""
source_page = Page('source', title = "view the source code",
#heading = "Ver el código fuente",
heading = "Ver el codigo fuente",
internal_css = get_pygments_css())
page = request.args.get('page')
# get source for markdown if any. 404's for non-existant markdown
# unless special page eg source
pagesrc = source_page.get_page_src(page, 'src', 'md')
special_pages = ['source', 'unit-tests', '404']
if not page in special_pages and pagesrc is None:
abort(404)
# set enable_unit_tests to true in config.json to allow
# unit tests to be run through the source page
if app.config['enable_unit_tests']:
contents = '''<p><a href="/unit-tests" class="button">Run unit tests
</a></p>'''
# render tests.py if needed
if page == 'unit-tests':
contents += heading('tests.py', 2)
contents += render_pygments('tests.py', 'python')
else:
contents = ''
# render views.py
contents += heading('views.py', 2)
contents += render_pygments(source_page.get_page_src('views.py'),
'python')
# render markdown if present
if pagesrc:
contents += heading(os.path.basename(pagesrc), 2)
contents += render_pygments(pagesrc, 'markdown')
# render jinja templates
contents += heading('base.html', 2)
contents += render_pygments(
source_page.get_page_src('base.html', 'templates'), 'html')
template = source_page.get_template(page)
contents += heading(template, 2)
contents += render_pygments(
source_page.get_page_src(template, 'templates'), 'html')
return source_page.generate_page(contents)
# @app.route("/unit-tests")
# def unit_tests():
# """display results of unit tests"""
# unittests = Page('unit-tests', heading = "Test Results",
# internal_css = get_pygments_css())
# # exec unit tests in subprocess, capturing stderr
# capture = subprocess.Popen(["python", "tests.py"],
# stdout = subprocess.PIPE, stderr = subprocess.PIPE)
# output = capture.communicate()
# results = output[1]
# contents = '''<p>
# <a href="/unit-tests" class="button">Run unit tests</a>
# </p><br>\n
# <div class="output" style="background-color:'''
# if 'OK' in results:
# color = "#ddffdd"
# result = "TESTS PASSED"
# else:
# color = "#ffaaaa"
# result = "TESTS FAILING"
# contents += ('''%s">\n<strong>%s</strong>\n<pre>%s</pre>\n</div>\n'''
# % (color, result, results))
# # render test.py
# contents += heading('tests.py', 2)
# contents += render_pygments('tests.py', 'python')
# return unittests.generate_page(contents)
![thumb_perrosEscoltaBajoLlojeta2.jpg](/static/imgs/posts/2023/thumb_perrosEscoltaBajoLlojeta2.jpg)
[Original](/static/imgs/posts/2023/perrosEscoltaBajoLlojeta2.jpg)
El fin de semana del 12 de junio a la hora del almuerzo se escucharon los gritos de dolor y susto de un perro que se acercó a un radiotaxi y mordió el aro de la rueda delantera derecha. La fuerza del avanzar del automóvil arrancó varios dientes del perro y el quedó atascado gritando sin poder zafar el hocico de la rueda. La gente miraba y gritaba, y del radiotaxi que se detuvo salieron los pasajeros y el conductor. Yo salí corriendo y vi al perrito atrapado, otros perros le ladraban y uno le mordió la espalda quizá para animarlo a zafarse por si solo.
El conductor dijo hay que sacar la llanta y yo exclamé que sí mientras la sangre de la boca del perro manchaba el asfalto. El perro no paraba de gritar y yo no estaba seguro de qué hacer sino también intentar animar al perro a que se zafe. Alrededor de dos minutos después (aunque ahora pienso que tal vez fué mucho menos), el perro con mucho esfuerzo y gritos de dolor logró sacar hocico de la rueda.
Alcancé a ver que le faltaban al menos 2 dientes entre ellos el colmillo inferior izquierdo y unas muelas flojas que parecía estaban a poco de salirse, su lengua estaba afuera y de la boca, no paraba de sangrar. Con todo eso el perro camino rápido alejándose de esa esquina y subiendo una calle empedrada y no sin dejar puntos rojos en el asfalto y acera por donde caminaba.
Le perseguimos con el conductor por esa calle y luego en una avenida. Siendo un perro grande y peludo se lo veía a lo lejos sin mucho esfuerzo, como se alejaba mas lejos el conductor dijo que iría por su auto y yo le dije que teníamos que llevarlo al veterinario porque los dientes podrían infectarse. Mientras iba por su auto, llegaron 2 mujeres preguntando por el perro y poco después volvió el conductor y salió del taxi junto con una mujer joven. El perro paró al lado de una tienda de abarrotes y ahí nos conseguimos acercar, también. Lo vimos sangrar con la lengua afuera y sabíamos que lo importante era agarrarlo para llevarle al veterinario.
El conductor se acercó al perro haciendo ese sonido que hacemos para llamar con cariño a los perros, algo parecido a un "tic" pero de modo en que se presiona la punta de la lengua en el paladar y luego se suelta la presión dirigiendo la lengua hacia adelante (o hacia atrás), haciendo eso seguidas veces y rápido suena como "tic tic tic tic ...". Alzó la mano y la puso en la cabeza del perro que bajo un poco las orejas y parecía relajarse por unos 3 segundos. Pero el conductor se precipitó y subió la mano hasta la nuca del perro tal vez para tratar de agarrarle la piel del cuello y poder así meterlo en el auto, el perro se asustó, se safó rápidamente y comenzó a alejarse más lejos y rápido hacia arriba. Mientras se alejaba se hablaba sobre el perro y la mujer acompañante (o tal vez pasajera) del taxista decía con **a mi parecer** un tono de reclamo, que ¡fue un accidente! y que el conductor no tenía la culpa.
![perrito Lastimado](/static/imgs/posts/2023/perritoLastimado1.jpg)
Nos pusimos a seguirle, pero eso hacía que el perro se alejara y me adelanté un poco trotando. Esperaba que el conductor esté tras de mí y cuando se acerque decirle que a pesar de que fue un accidente, estas cosas pasan y no podía dejar al perro así y que yo iba a ayudarle a cubrir con los gastos de curación. Lo que recuerdo es que él se quedó atrás con el taxi junto con la mujer joven, no lo volví a ver y no pude decirle nada de eso.
Poco después llegó mi pareja corriendo preguntando como estaba todo y le dije que perdimos de vista al perro. Preguntábamos a dueñas de las tiendas si conocían al perro y decían que parecía que no tenía dueño, les dije que en lo posible intenten conseguir una correa o manta para una vez nos acerquemos lo podamos agarrar. Una chica joven vio al perro y sintió que tenía que ayudarlo y se puso también a seguirlo al trote, pero él más se alejaba. Poco a poco las demás personas también lo iban a buscar, nos separamos, y mi pareja escribió a un grupo de whatsapp de cuidado de animales callejeros de Bajo Llojeta (que es una zona grande en la ciudad de La Paz). En el grupo luego de mandar fotos del perro, decían que era callejero y no tenía dueño pero que había un dueño de un lavado de trajes en la avenida que le daba comida. Fui al lavado mientras las demás buscaban al perro.
Vi que estaba abierto y me acerqué, vi a un señor descansando sentado y luego de saludarle le conté sobre el perro y le mostré fotos, su sonrisa inicial iba desapareciendo de a poco hasta ponerse más serio. Me dijo que conocía al perro y que junto con otros 4 o 5 perros, les daba comida y agua, pero que no era su perro y que solo sabía que andaba con ese grupo de perros por estas calles que parecían ser "sus dominios", que él no vivía aquí y tampoco conocía si el perro tenía o no dueño. Nos pasamos los números de teléfonos y también me dio el teléfono del administrador de un grupo de WhatsApp de la zona que ayudaban a perros de la calle. Con esos datos, le dije que me avise si lo vuelve a ver y decidí volver a salir a buscarlo y bajando un poco me volví a encontrar con mi pareja.
Así con ella intentamos caminar tras el perro y vimos que se metió por una calle que bajaba a por un costado de la avenida principal, lo seguimos y una señora nos acompañó por esas calles empedradas, empinadas y con cruces perpendiculares aún más empinados. Mi pareja aprovechó en tomar algunas fotos del perro y escribir por ayuda a [animales S.O.S](https://animalessos.org/), un grupo de voluntarios que trabaja varias décadas ayudando a animales desprotegidos. Le dijeron que intentemos atrapar al perro o apaciguarlo para que puedan ir hasta el lugar para atenderlo, les mandó la ubicación y otros detalles, pero el perro se movía por un barranco.
![thumb_bajoLLojetaBarranco1.jpg](/static/imgs/posts/2023/thumb_bajoLlojetaBarranco1.jpg)
[Original](/static/imgs/posts/2023/bajoLlojetaBarranco1.jpg)
La señora se separó y nos dijo que intentaría traer una correa para agarrar al perro y seguimos tras el perro, finalmente llegamos al final de la calle donde solo había un barranco que iba a un camino a las faldas de un cerro donde no se veían casas, solo un tractor inmóvil. Dos señoras que subían por el barranco, vieron al perro y a nosotros en la meseta y gritaron si era nuestro perro. Yo grité que no, y que queríamos agarrarlo para rescatarlo y se acercaron. Nos dijeron que lucía mal y que no lo vieron antes mientras también lo buscaban con la vista.
Mientras las señoras se quedaron mirando el barranco otra vez perdimos de vista al perro, lo último que vimos fue al perro que bajó el barranco y tras escuchar el ladrido de otro perro, empezó a huir bajando ese camino que era difícil para nosotros. El sol de la tarde, el polvo y la caminata ya empezaban a fatigar y decidimos rodear el barranco por otras calles adyacentes. Eran calles vacías de personas, pero si se veía alguno que otro perro echado y que nos miraba fijamente y a veces ladraba, habían casas pequeñas de ladrillo, gradas y pasillos que conectaban calles similares. Así mientras andábamos mandé un mensaje al dueño del grupo de whatsapp que me pasó el señor del lavado. Al no recibir respuesta, mandé un audio intentando explicar la situación y pidiendo ayuda a los vecinos para agarrar o encontrar al perro. Al no recibir respuesta le llamé, me contestó y le dije lo mismo, quizá por la frustración o el cansancio, no expliqué bien los detalles o elaboré un plan concreto para rescatarlo y su respuesta solo fue un "Está bien, gracias por comunicarlo vamos a avisar en el grupo".
![thumb_bajoLLojetaBarranco1.jpg](/static/imgs/posts/2023/thumb_bajoLlojetaCamino2.jpg)
[Original](/static/imgs/posts/2023/bajoLlojetaCamino2.jpg)
Caminamos muchas calles y noté más nuestro cansancio y frustración, volvimos de regreso a la meseta y poco antes de llegar las dos señoras que se quedaron vigilando nos gritaron señalando una de las calles por las que vieron al perro recientemente, agradecimos y fuimos tras él. Al llegar a la avenida nos volvimos a separar y mi pareja dijo iría a conseguir algo de comida y *tromadol* porque los de animales SOS le recomendaron que se lo administremos al perro para calmar el dolor que podría estar sintiendo.
Volví a subir la avenida y poco después vi al perro sentado en un pequeño montón de pasto en la acera cerca del lavado donde le daban comida. Le vi con la lengua afuera y me acercaba lentamente sin estar muy seguro de cómo agarrarlo. El perro me vio a lo lejos, me reconoció y de nuevo empezó a alejarse. Así iba por calles de subida y bajada, se detenía para descansar, yo me acercaba con cautela y a veces él también, pero acababa de forma similar. Parecía que el perro ya asociaba mi figura con el accidente y el miedo y el dolor que sentía hacía que se aleje. Lo perdí de vista como unas 3 veces y volvía a encontrarlo casi por los alrededores o en la avenida misma. **No tenía un buen plan para atraparlo**, tan solo esperaba a que él me empezara a tolerar y deje que me acerque, tristemente él solo se alejaba de mí cuando me percibía muy cerca.
Volví a encontrarme con mi pareja y ella trajo algo de pollo para intentar darle al perro junto con una pastilla y como se dio cuenta que el perro volvía a donde estaba la tienda de lavado, fuimos a hablar con el dueño. Ella le pidió que en cuanto lo viese le dé la comida e intente hacer que coma las pastilla también, él dijo que dentro de poco tenía que cerrar la tienda, pero que esperaría un poco más. Mientras hablaba otra perrita se acercó y el dueño le dio unas croquetas que ella empezó a comer con muchas ansias. Recuerdo vi en su cabeza una mancha de sangre con saliva, entonces temí que el perro se encontró con ella y trato de defenderse. O a lo mejor era su amiga, se acercó a olerlo y ahí goteó en su cabeza la sangre del perro.
Dejando a los demás en el lavado salí de nuevo a buscarlo para no perderle el rastro, pero esta vez ya no pude encontrarlo por las calles donde lo ví antes, aunque sí lo vi ir hacia el barranco y parecía mirar hacia atrás como buscando si alguien —en este caso yo, lo estaba siguiendo. Decidí dejarle de perseguir, para que deje de huir y no se estrese más de lo que ya vivió.
Poco después volví al lavado y hablamos con mi pareja de que quizá no encontraremos al perro de nuevo en ese día y que dejemos a personas o dueños de tiendas la comida con pastillas para que se la dieran al perrito si se acercaba. Como el señor del lavado dijo que cerraba la tienda, le pedimos el plato de comida y que nos avise si se enterara de algo. Inmediatamente después que salimos de la tienda el señor la cerró.
Finalmente dejamos el plato de comida a la dueña de una tienda de abarrotes al frente de lavado, no habían más tiendas cerca y tampoco respuestas en los grupos de WhatsApp, entonces nos fuimos.
No dormí tranquilo esa noche pensando en el dolor intenso que podría estar sintiendo el perro. ¿Se le habrán infectado las heridas? ¿Cuanta sangre habrá perdido? ¿Habrá comido algo? Si a lo mejor tenía dueño o consiguió volver, ¿su dueño le atendió al ver su estado?.
Al día siguiente salí a buscar al perro, esta vez con más comida y más pastillas, quizá esta vez si estaría más tranquilo y con el hambre suficiente como para aceptar un plato de comida y así yo o pudiera atraparlo para en lo posible hacer que lo curen. Como era domingo, el señor del lavado no abrió, estaba todo más vació y por la avenida no vi al perro. Volví a la tienda de abarrotes y la señora me dijo que no volvió a ver al perro, me devolvió el plato de comida y decidí buscarlo hasta más lejos.
![thumb_bajoLLojetaBarranco1.jpg](/static/imgs/posts/2023/thumb_perroEscoltaBajoLlojeta1.jpg)
[Original](/static/imgs/posts/2023/perroEscoltaBajoLlojeta1.jpg)
Bajé por las calles empedradas con mucha calma y mirando a todos lados. A pesar de que me acercaba en silencio, otros perros me vieron a mí y al plato de comida que tenía en mis manos y me empezaron a seguir. Con "amabilidad" les decía que se alejen pero uno de ellos muy pacientemente me siguió varias calles. Su mirada hambrienta e insistencia hizo que le lanzara un poco de la comida de rato en rato. Él iba a comer y volvía y así hasta que llegamos cerca al barranco. Pareció reconocer los límites de su territorio o el comienzo del territorio de otros perros y se quedó en una esquina inmóvil y de a poco se empezó a alejar. Y era cierto, otros dos perros se me acercaron y me miraban igual. Uno de ellos era grande, peludo, viejo y tenía un ojo lastimado. El otro era mediano delgado y mucho más joven.
Mientras seguía buscando, estos dos compañeros me escoltaron hasta el barranco mientras yo aproveché en darles parte de la comida que comieron con mucho gusto. Me quedé mirando el barranco por varios minutos y de a poco a bajarlo. Mientras más bajaba, menos me seguían los perros.
![thumb_bajoLLojetaBarranco1.jpg](/static/imgs/posts/2023/thumb_perrosEscoltaBajoLlojeta1.jpg)
[Original](/static/imgs/posts/2023/perrosEscoltaBajoLlojeta1.jpg)
Llegué casi al camino y tras varios minutos de inspeccionar, no encontré ningún rastro del perro. Noté un niño a lo lejos que jugaba con una vara de madera y se contemplaba el atardecer por el cerro. Poco después me vio también parado y se iba acercando de a poco. A una distancia prudente dudó en seguir acercándose y volvió a su casa a jugar con su vara y mirar el paisaje.
![thumb_bajoLLojetaBarranco1.jpg](/static/imgs/posts/2023/thumb_bajoLlojetaBarranco2.jpg)
[Original](/static/imgs/posts/2023/bajoLlojetaBarranco2.jpg)
![thumb_bajoLLojetaBarranco1.jpg](/static/imgs/posts/2023/thumb_bajoLlojetaCerro1.jpg)
[Tamaño original](/static/imgs/posts/2023/bajoLlojetaCerro1.jpg)
Volví a subir el barranco triste por no haber encontrado al perro preguntándome si seguía vivo, o su cuerpo yacía en algún rincón inmóvil luego de que esta su vida de perro hubiese terminado. Al subir de vuelta volví a ver a mis anteriores perros escolta y les di más de los restos de comida. Luego me escoltaron amablemente de vuelta y en una de las calles se detuvieron al frente de una puerta luego de escuchar que se abría. Salió una señora de la tercera edad, una "viejita" que tenía una olla de una sopa parecida a ["lawa" de perro](http://marlosymazorcas.blogspot.com/2017/11/lawa.html) que poco a poco iba vaciando en un plato al lado de su puerta. Los perros esperaban pacientes a que terminara y luego fueron a comer. Ahí me ajelé y algunas migajas que tenía todavía, se las daba a otros perros del camino que también las recibían agradecidos.
Dos días después, me comuniqué con el dueño del lavado preguntando sobre el perro y me dijo que no lo volvió a ver, pero que me escribiría en otro caso. Una semana después pasé de nuevo por la avenida y no vi rastros del perrito que vivió su vida entre las calles de Bajo Llojeta, zona donde socializaba, caminaba, dormía, contemplaba con la nariz y los ojos lo que aparecía y donde algunas personas le daban comida, y quizá algunas le acariciaban la cabeza.
## Pequeña Reflexión
Si acabó la vida de un perro hambriento, es fácil darse cuenta de que hay muchas otras más. La ciudad de La Paz en Bolivia tiene **una gran cantidad de perros** y la tenencia irresponsable de animales es uno de sus más grandes problemas.
Algunas estimaciones indican que hay más de 250000 animales en condición de calle, sin embargo pocas acciones se toman para mejorar su situación. Hay gran diversidad de causas y cada causa tiene su efecto, las consecuencias como lo que le pasó al perrito de esta historia no son casos aislados, están ocurriendo a diario.
Una cadena con [muchísimos eslabones](https://animalessos.org/frenar-la-sobrepoblacion-perros/) que puede empezar con personas irresponsables que descuidan a sus mascotas dejándolas en la calle donde pueden enfermarse, pelear, hambrear, sufrir accidentes, perderse o ser víctimas de agresiones o delincuencia. Además pueden también procrear y multiplicar la cantidad de perros callejeros o abandonados, empeorando la situación.
Una ciudad donde las autoridades se jactan de proyectos de "modernizaciones" inconclusas y uso de la tecnología como un fin en sí misma y [reducen los recursos](https://www.noticiasfides.com/la-paz/la-paz-denuncian-que-alcaldia-reduce-recursos-y-competencias-para-atencion-de-mascotas-414813) e importancia a la vida y buen vivir de los omnipresentes y **nobles animales** que la habitan. Esos incorrectamente llamados "seres inferiores" que fielmente ofrecen sus vidas para hacer compañía a personas como nosotros, que soportan maltratos y sufren las consecuencias de la irresponsabilidad colectiva e individual.
Pero como siempre, cambiar esta situación esta a nuestro alcance. Justo la misma noche de lo ocurrido con el perro, pude ver a otra viejita que con su compañero alimentaban a —al menos cuatro perros callejeros. Con sus pasos lentos cerca de las 11 de la noche.
![thumb_comiendolawa.jpg](/static/imgs/posts/2023/comiendoLawa1.jpg)
Eso muestra que hay voluntad y amor para mejorar la vida de los animales por parte de muchas personas que saben que más allá de las riquezas económicas, [el trato a los animales](/posts/Sobre-el-trato-a-los-animales) es una riqueza más importante.
Actuando, exigiendo y esforzándonos podemos hacer de esta y otras ciudades un lugar próspero de verdad.