diff --git a/.gitignore b/.gitignore index b823e313b4..caad338b9e 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ coverage.xml # Django stuff: *.log local_settings.py +decide/*/migrations # Flask stuff: instance/ @@ -101,3 +102,6 @@ ENV/ .mypy_cache/ .vagrant + +# Visual Studio Code settings +.vscode \ No newline at end of file diff --git a/decide/decide/settings.py b/decide/decide/settings.py index d67add849e..005681e78d 100644 --- a/decide/decide/settings.py +++ b/decide/decide/settings.py @@ -167,6 +167,9 @@ STATIC_URL = '/static/' +#temporary link to visualizer page for bots (until hosted) +VISUALIZER_VIEW="https://decide-full-tortuga-2.herokuapp.com/visualizer/" + # number of bits for the key, all auths should use the same number of bits KEYBITS = 256 diff --git a/decide/visualizer/admin.py b/decide/visualizer/admin.py index 8c38f3f3da..ea7783f5eb 100644 --- a/decide/visualizer/admin.py +++ b/decide/visualizer/admin.py @@ -1,3 +1,12 @@ from django.contrib import admin +from django.http.response import HttpResponseRedirect +from .models import TelegramBot +from .urls import urlpatterns -# Register your models here. +class TelegramBotAdmin(admin.ModelAdmin): + list_display=('user_id', 'auto_msg') + list_filter=('auto_msg',) + ordering=('user_id', 'auto_msg') + change_list_template = "visualizer/telegram_admin.html" + +admin.site.register(TelegramBot, TelegramBotAdmin) \ No newline at end of file diff --git a/decide/visualizer/migrations/0001_initial.py b/decide/visualizer/migrations/0001_initial.py new file mode 100644 index 0000000000..394118c9a1 --- /dev/null +++ b/decide/visualizer/migrations/0001_initial.py @@ -0,0 +1,21 @@ +# Generated by Django 2.0 on 2021-12-03 12:31 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='TelegramBot', + fields=[ + ('user_id', models.BigIntegerField(primary_key=True, serialize=False)), + ('auto_msg', models.BooleanField(default=False)), + ], + ), + ] diff --git a/decide/visualizer/migrations/0002_auto_20211210_1800.py b/decide/visualizer/migrations/0002_auto_20211210_1800.py new file mode 100644 index 0000000000..b772f6bc7a --- /dev/null +++ b/decide/visualizer/migrations/0002_auto_20211210_1800.py @@ -0,0 +1,17 @@ +# Generated by Django 2.0 on 2021-12-10 18:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('visualizer', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='telegrambot', + options={'verbose_name': 'Telegram user'}, + ), + ] diff --git a/decide/visualizer/models.py b/decide/visualizer/models.py index 71a8362390..1c622f6e5a 100644 --- a/decide/visualizer/models.py +++ b/decide/visualizer/models.py @@ -1,3 +1,11 @@ from django.db import models -# Create your models here. +class TelegramBot(models.Model): + user_id=models.BigIntegerField(primary_key=True) + auto_msg=models.BooleanField(default=False) + + def __str__(self): + return '{}'.format(self.auto_msg) + + class Meta: + verbose_name = 'Telegram user' \ No newline at end of file diff --git a/decide/visualizer/static/visualizer.css b/decide/visualizer/static/visualizer.css new file mode 100644 index 0000000000..135d61f328 --- /dev/null +++ b/decide/visualizer/static/visualizer.css @@ -0,0 +1,31 @@ +#bots-footer { + position: fixed; + width: 100%; + bottom : 0px; + height : 60px; + text-align: center; + vertical-align: middle; + padding-top: 0.5 rem; + padding-bottom: 0.5 rem; + background-color: rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity)); + color: #fff; +} + +#start-tlg { + border: #79aec8; + border-radius: 12px; + font-size: 14px; + color: #fff; + text-transform: uppercase; + background-color: #79aec8; + letter-spacing: 0.5px; + padding: 8px; + -webkit-border-radius: 12px; + -moz-border-radius: 12px; + -ms-border-radius: 12px; + -o-border-radius: 12px; +} + +#start-tlg:hover{ + background-color: #417690; +} \ No newline at end of file diff --git a/decide/visualizer/telegramBot.py b/decide/visualizer/telegramBot.py new file mode 100644 index 0000000000..f9e2fef73f --- /dev/null +++ b/decide/visualizer/telegramBot.py @@ -0,0 +1,198 @@ +from django.conf import settings +from voting import models +from store import models as stmodels +from telegram import InputMediaPhoto, Bot +from telegram.ext import Updater, CommandHandler, MessageHandler, Filters, CallbackQueryHandler +from telegram.inline.inlinekeyboardbutton import InlineKeyboardButton +from telegram.inline.inlinekeyboardmarkup import InlineKeyboardMarkup +import logging, os, sys +from .website_scrapping import get_graphs +from .models import TelegramBot +from threading import Thread + + +#auth and front-end for '@VotitosBot' +UPDATER = Updater(os.environ.get('TELEGRAM_TOKEN'), + use_context=True) + +BOT=Bot(token=os.environ.get('TELEGRAM_TOKEN')) + +#configures and activate '@VotitosBot' to receive any messages from users +def init_bot(): + + #logging + logging.basicConfig(level=logging.ERROR, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') + setup_commands(UPDATER) + updates_setting() + #starts the bot + UPDATER.start_polling() + +#configures commands and handlers for the bot +def setup_commands(votitos): + + dp=votitos.dispatcher + dp.add_handler(CommandHandler('start', start)) + dp.add_handler(CommandHandler('relaunch', relaunch, Filters.user(user_id=1931864468))) + dp.add_handler(CommandHandler('stop', stop, Filters.user(user_id=1931864468))) + dp.add_handler(CommandHandler('results', show_results)) + dp.add_handler(CommandHandler('details', show_details)) + dp.add_handler(CommandHandler('auto', change_auto_status)) + dp.add_handler(CommandHandler('help', help)) + dp.add_handler(MessageHandler(Filters.command, unknown_command)) + dp.add_handler(CallbackQueryHandler(results_query_handler, pattern="^[1-9][0-9]*$")) + dp.add_handler(CallbackQueryHandler(details_query_handler, pattern="^d[1-9][0-9]*$")) + dp.add_handler(CallbackQueryHandler(auto_query_handler, pattern="(^True$|^False$)")) + +#set bot configuration not to reply to old messages +def updates_setting(): + updates=BOT.get_updates() + if updates: + last_update=updates[-1].update_id + BOT.get_updates(offset=last_update+1) + +#gives the users a warming welcome +def start(update, context): + name=update.message.from_user.first_name + id=update.message.chat.id + context.bot.send_message(chat_id=id, text="Hola {}, a las buenas tardes. ¿En qué puedo ayudarte?".format(name)) + TelegramBot.objects.get_or_create(user_id=id) + help(update) + +# relaunch the bot and also the whole project (limited to admin) +def relaunch(update, context): + Thread(target=stop_restart).start() + +# aux for relaunch +def stop_restart(): + UPDATER.stop() + os.execl(sys.executable, sys.executable, *sys.argv) + +#shut down the bot +def stop(update, context): + UPDATER.stop() + +#list of available commands +def help(update): + + update.message.reply_text("""Esta es mi lista de comandos: + /start - Inicia la interacción conmigo + /results - Muestra los resultados de las votaciones cerradas + /details - Proporciona detalles de todas las votaciones + /auto - Permite activar o desactivar las notificaciones automáticas para nuevas votaciones + """) + +#replies to invalid command inputs +def unknown_command(update, context): + update.message.reply_text("Lo siento, no sé qué es '%s'. Revisa que has escrito bien el comando o bien revisa mi lista de comandos, puedes hacerlo con\n/help" % update.message.text) + +#allows you to select a closed voting and show its results +def show_results(update, context): + update.message.reply_text("Aquí tienes la lista de votaciones finalizadas.") + finished_votings=models.Voting.objects.exclude(start_date__isnull=True).exclude(end_date__isnull=True) + keyboard_buttons=[] + for v in finished_votings: + keyboard_buttons.append(InlineKeyboardButton(text=str(v.name), callback_data=str(v.id))) + keyboard=InlineKeyboardMarkup(build_keyboard_menu(keyboard_buttons,2)) + context.bot.send_message(chat_id=update.message.chat.id, text= "Elige por favor:", reply_markup=keyboard) + +#handler for '/results' command +def results_query_handler(update, context): + + query=update.callback_query + query.answer("¡A la orden!") + results_graph(query.data, update.callback_query.message.chat_id, context) + +#allows you to select an active or closed voting and show its details +def show_details(update, context): + update.message.reply_text("Selecciona la votación de la que desea ver sus detalles") + votings=models.Voting.objects.exclude(start_date__isnull=True) + keyboard_buttons=[InlineKeyboardButton(text=str(v.name), callback_data="d"+str(v.id)) for v in votings] + keyboard=InlineKeyboardMarkup(build_keyboard_menu(keyboard_buttons,2)) + context.bot.send_message(chat_id=update.message.chat.id, text="Seleccione una por favor:", reply_markup=keyboard) + +#constructs menu for inline buttons +def build_keyboard_menu(buttons, n_cols): + return [buttons[b:(b + n_cols)] for b in range(0, len(buttons), n_cols)] + +#handler for '/details' command +def details_query_handler(update, context): + + query=update.callback_query + query.answer("¡A la orden!") + vot_id=query.data[1] + voting=models.Voting.objects.exclude(start_date__isnull=True).get(id=vot_id) + msg=aux_message_builder(voting) + context.bot.send_message(chat_id=query.message.chat_id,text=msg, parse_mode="HTML") + +#opt-in and opt-out for auto notifications +def change_auto_status(update, context): + id=update.message.chat.id + status_user=TelegramBot.objects.get(user_id=id) + if status_user.auto_msg: + choose_msg="Actualmente las notificaciones automáticas se encuentran activadas.\n¿Desea desactivarlas?" + keyboard_buttons=[[InlineKeyboardButton(text="Sí", callback_data="False")], + [InlineKeyboardButton(text="No", callback_data="True")]] + else: + choose_msg="Actualmente las notificaciones automáticas se encuentran desactivadas.\n¿Desea activarlas?" + keyboard_buttons=[[InlineKeyboardButton(text="Sí", callback_data="True")], + [InlineKeyboardButton(text="No", callback_data="False")]] + keyboard=InlineKeyboardMarkup(keyboard_buttons) + context.bot.send_message(chat_id=id, text=choose_msg, reply_markup=keyboard) + +#handler for '/auto' command +def auto_query_handler(update, context): + query=update.callback_query + u_id=update.callback_query.message.chat_id + msg_id=update.callback_query.message.message_id + for id in range(msg_id-2, msg_id+1): + context.bot.delete_message(chat_id=u_id, message_id=id) + TelegramBot.objects.filter(user_id=u_id).update(auto_msg=query.data) + query.answer("¡Listo! He actualizado tus preferencias") + +# =================== +# AUXILIARY METHODS +# =================== + +#auxiliary message to print details from votings +def aux_message_builder(voting): + + options=list(voting.question.options.values_list('option', flat=True)) + tally=stmodels.Vote.objects.filter(voting_id=voting.id).values('voter_id').distinct().count() #gets unique votes for a voting + start_d=voting.start_date.strftime('%d-%m-%Y %H:%M:%S')+"\n" + end_d="Por decidir\n" + + if voting.end_date is not None: + end_d=voting.end_date.strftime('%d-%m-%Y %H:%M:%S')+"\n" + elif tally is None: + tally="Desconocido por el momento" + + opt_msg="" + for i,o in enumerate(options,1): + opt_msg+=" " + str(i)+". " + o+"\n" + + msg="{}\n\nDescripción: {}\nPregunta: {}\n".format(str(voting.name).upper(), voting.desc, str(voting.question)) + msg+="Opciones:\n{}\n".format(opt_msg) + msg+="Fecha de incio: {}Fecha de finalización: {}Conteo actual: {}".format(start_d, end_d, tally) + + return msg + +#extracts graph's images from website selected voting and sends them to the user +def results_graph(id, chat_identifier, context): + url=settings.VISUALIZER_VIEW+ str(id) + images=get_graphs(url) + if images: + media_group=[InputMediaPhoto(media=i) for i in images] + context.bot.sendMediaGroup(chat_id=chat_identifier, media=media_group) + else: + context.bot.send_message(chat_id=chat_identifier, + text="Upss! Parece que aún no hay ningún gráfico asociado a esta votación.\nInténtalo de nuevo en otro momento.") + +#sends notifications when a new voting is created +def auto_notifications(voting): + users_id_enabled=list(TelegramBot.objects.values_list('user_id', flat=True).exclude(auto_msg=False)) + msg=aux_message_builder(voting) + for id in users_id_enabled: + BOT.send_message(chat_id=id, text=msg, parse_mode="HTML") + + diff --git a/decide/visualizer/templates/visualizer/telegram_admin.html b/decide/visualizer/templates/visualizer/telegram_admin.html new file mode 100644 index 0000000000..661aa1b85e --- /dev/null +++ b/decide/visualizer/templates/visualizer/telegram_admin.html @@ -0,0 +1,13 @@ +{% extends 'admin/change_list.html' %} +{% load i18n static %} + +{% block extrahead %} + +{% endblock %} + +{% block content %} +
+ Start Telegram Bot +

+ {{ block.super }} +{% endblock %} \ No newline at end of file diff --git a/decide/visualizer/templates/visualizer/visualizer.html b/decide/visualizer/templates/visualizer/visualizer.html index 4fcf5fb537..6ded3da310 100644 --- a/decide/visualizer/templates/visualizer/visualizer.html +++ b/decide/visualizer/templates/visualizer/visualizer.html @@ -7,6 +7,7 @@ + {% endblock %} {% block content %} @@ -49,8 +50,15 @@

Resultados:

{% endif %} + {% endblock %} + {% block extrabody %} diff --git a/decide/visualizer/urls.py b/decide/visualizer/urls.py index 4baef5f2b9..1b58b8b407 100644 --- a/decide/visualizer/urls.py +++ b/decide/visualizer/urls.py @@ -1,7 +1,8 @@ from django.urls import path -from .views import VisualizerView +from .views import VisualizerView, initialize urlpatterns = [ path('/', VisualizerView.as_view()), + path('startTelegram/', initialize, name="start_telegram") ] diff --git a/decide/visualizer/views.py b/decide/visualizer/views.py index 86d55dcaa9..7734fd2f14 100644 --- a/decide/visualizer/views.py +++ b/decide/visualizer/views.py @@ -1,16 +1,17 @@ import json +from django.http.response import HttpResponse, HttpResponseRedirect from django.views.generic import TemplateView from django.conf import settings from django.http import Http404 import ast from base import mods from collections import OrderedDict - +from .telegramBot import init_bot class VisualizerView(TemplateView): - template_name = 'visualizer/visualizer.html' - + template_name = 'visualizer/visualizer' + def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) vid = kwargs.get('voting_id', 0) @@ -26,3 +27,10 @@ def get_context_data(self, **kwargs): raise Http404 return context + +def initialize(request): + #call to initalize telegram bot + init_bot() + return HttpResponseRedirect(request.META.get('HTTP_REFERER')) + + diff --git a/decide/visualizer/website_scrapping.py b/decide/visualizer/website_scrapping.py new file mode 100644 index 0000000000..4fb5f62c7c --- /dev/null +++ b/decide/visualizer/website_scrapping.py @@ -0,0 +1,17 @@ +from bs4 import BeautifulSoup as bs +import urllib.request as request +from urllib.parse import urlparse + +#returns images of a voting +def get_graphs(link): + file=request.urlopen(link) + s=bs(file, "lxml") + images=s.find_all("img") + urls=[] + for img in images: + img_url=img.attrs.get("src") + if not img_url: + continue + if len(urls) == 0: + urls=False + return urls \ No newline at end of file diff --git a/decide/voting/admin.py b/decide/voting/admin.py index 8b97b68e00..e94f9cfc38 100644 --- a/decide/voting/admin.py +++ b/decide/voting/admin.py @@ -4,7 +4,7 @@ from .models import QuestionOption from .models import Question from .models import Voting - +from visualizer.telegramBot import auto_notifications from .filters import StartedFilter @@ -13,6 +13,8 @@ def start(modeladmin, request, queryset): v.create_pubkey() v.start_date = timezone.now() v.save() + #for users who have auto notifications enabled + auto_notifications(v) def stop(ModelAdmin, request, queryset): diff --git a/requirements.txt b/requirements.txt index 543abc4890..074faa31fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,4 +14,6 @@ djongo==1.3.6 pymongo==3.12.1 six==1.16.0 sqlparse==0.2.4 +python-telegram-bot==13.9.0 +bs4