#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Plataforma de votacao - Lista M (Movimento Transparencia e Participacao) Assembleia Estatutaria da ULisboa. TESTE. Stack: Python SO-STDLIB (sem dependencias). Guardado em SQLite. Modelo (boletim editavel e anonimo): - Cada eleitor tem UM boletim, identificado por um codigo aleatorio. No teste, esse codigo e um cookie do navegador. Na versao real vem no link magico enviado por email (o email so serve para autenticar). - O voto e guardado contra o codigo aleatorio, NUNCA contra o email. Por isso as respostas sao anonimas (ver a tabela 'respostas': nao ha coluna de email nem de identidade). - Reenviar o formulario reescreve o mesmo boletim (upsert): da para mudar respostas, deixar em branco e acrescentar comentarios as vezes que se quiser; conta sempre so a ultima versao (um codigo = um boletim). Portais: / -> votar / editar o boletim (Sim / Nao / Indeciso / Sem resposta) /resultados -> contagens, % e justificacoes /codigo -> este codigo-fonte, aberto para verificacao Corre em 127.0.0.1:8080 e e exposto ao mundo pelo Tailscale Funnel. """ import http.server import socketserver import sqlite3 import os import html import datetime import secrets from http.cookies import SimpleCookie from urllib.parse import parse_qs BASE = os.path.dirname(os.path.abspath(__file__)) DB = os.path.join(BASE, "votos.db") HOST = "127.0.0.1" PORT = 8080 # Propostas de teste: a de Paulo+Carlos (Diamond Access, no fim) e as do Bicudo # desdobradas em pontos votaveis um a um (vinham em bloco, em formato livre). PROPOSTAS = [ {"id": "p2", "autor": "Pedro Bicudo", "principio": "Igualdade", "titulo": "Cargos de gestão acessíveis a qualquer categoria?", "descricao": "Os cargos de gestão da ULisboa devem poder ser exercidos por docentes e " "investigadores de qualquer categoria, sem os reservar à categoria mais elevada."}, {"id": "p3", "autor": "Pedro Bicudo", "principio": "Igualdade", "titulo": "Estatutos uniformes entre as escolas da ULisboa?", "descricao": "As escolas da ULisboa devem reger-se por estatutos uniformes nos princípios " "essenciais, evitando assimetrias entre unidades orgânicas."}, {"id": "p4", "autor": "Pedro Bicudo", "principio": "Transparência", "titulo": "Acesso às atas e difusão dos plenários?", "descricao": "Garantir o acesso da comunidade às atas dos órgãos de governo e a difusão " "(transmissão e gravação) dos plenários."}, {"id": "p5", "autor": "Pedro Bicudo", "principio": "Participação", "titulo": "Referendos internos vinculativos com limiares definidos?", "descricao": "Permitir referendos internos vinculativos, com limiares: alienação de imóveis " "exige mais de 67%, tal como a alteração de estatutos; iniciativa de referendo " "por 3% de subscritores."}, {"id": "p6", "autor": "Pedro Bicudo", "principio": "Participação", "titulo": "A ULisboa deve manter-se pública, sem adotar o modelo de Fundação?", "descricao": "A ULisboa deve manter-se como instituição pública, não adotando o regime " "fundacional."}, {"id": "p7", "autor": "Pedro Bicudo", "principio": "Participação", "titulo": "Destituição de reitor e diretores por maioria?", "descricao": "Prever mecanismos de destituição do reitor e dos diretores das escolas por " "maioria (50%) dos órgãos competentes."}, {"id": "p8", "autor": "Pedro Bicudo", "principio": "Participação", "titulo": "Orçamento participativo de 3%?", "descricao": "Afetar 3% do orçamento a um processo de orçamento participativo decidido pela " "comunidade."}, {"id": "p1", "autor": "Paulo Oliveira e Carlos Cardeira", "principio": "Diamond Access", "titulo": "A ULisboa deve adotar e financiar o Diamond Access?", "descricao": "Diamond Access (acesso aberto diamante): adotar e financiar os canais de " "publicação sem custos para quem publica (sem APCs) nem para quem lê, geridos " "pela comunidade académica. Exemplo que prova a viabilidade: o Journal of " "Artificial Intelligence Research (JAIR), criado em 1993 pela comunidade de IA, " "Q1 e indexado há mais de 30 anos, sem cobrar a autores nem a leitores, ao " "contrário dos APCs de milhares de euros."}, ] ESCOLHAS = ["Sim", "Não", "Indeciso"] # opcoes que contam OPCOES_UI = [("", "Sem resposta")] + [(e, e) for e in ESCOLHAS] COR = {"Sim": "#1f9d55", "Não": "#d64545", "Indeciso": "#8a7a54"} def conn(): c = sqlite3.connect(DB) c.execute("""CREATE TABLE IF NOT EXISTS respostas ( ballot TEXT NOT NULL, proposta TEXT NOT NULL, escolha TEXT, justificacao TEXT, atualizado_em TEXT NOT NULL, PRIMARY KEY (ballot, proposta))""") return c CSS = """ :root{--azul:#0a6ebd;--tinta:#1f2933;--cinza:#616e7c;--linha:#e4e7eb;--fundo:#f7f9fb;} *{box-sizing:border-box} body{margin:0;background:var(--fundo);color:var(--tinta); font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;line-height:1.5} header{background:var(--azul);color:#fff;padding:18px 20px} header .wrap,main{max-width:820px;margin:0 auto} header h1{margin:0;font-size:20px;font-weight:700} header p{margin:4px 0 0;opacity:.9;font-size:14px} nav{max-width:820px;margin:0 auto;padding:10px 20px;display:flex;gap:16px} nav a{color:var(--azul);text-decoration:none;font-weight:600} nav a:hover{text-decoration:underline} main{padding:8px 20px 60px} .aviso{background:#fff7e6;border:1px solid #f0d58c;color:#7a5c00; padding:10px 14px;border-radius:8px;font-size:14px;margin:14px 0} .info{background:#eef4fb;border:1px solid #bcd4ee;color:#274b6d; padding:10px 14px;border-radius:8px;font-size:14px;margin:14px 0} .info a,.aviso a,footer a{color:inherit;font-weight:700} .proposta{background:#fff;border:1px solid var(--linha);border-radius:12px; padding:18px 20px;margin:16px 0;box-shadow:0 1px 2px rgba(0,0,0,.04)} .tag{display:inline-block;font-size:12px;font-weight:700;letter-spacing:.03em; text-transform:uppercase;color:var(--azul);background:#eaf3fb;border-radius:20px; padding:3px 10px;margin-bottom:8px} .autor{color:var(--cinza);font-size:13px;margin-left:8px} .proposta h2{font-size:18px;margin:.2em 0 .4em} .proposta .desc{color:#3e4c59;font-size:15px;margin:0 0 14px} .opcoes{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:12px} .opcoes label{flex:1;min-width:110px;border:1.5px solid var(--linha);border-radius:10px; padding:10px 14px;cursor:pointer;font-weight:600;text-align:center;user-select:none} .opcoes input{margin-right:8px} .opcoes label:hover{border-color:var(--azul)} .opcoes input:checked+span{color:var(--azul)} .opcoes label:has(input:checked){border-color:var(--azul);background:#eaf3fb} textarea{width:100%;border:1.5px solid var(--linha);border-radius:10px;padding:10px 12px; font:inherit;resize:vertical;min-height:96px} textarea:focus,.opcoes label:focus-within{outline:none;border-color:var(--azul)} .barra-botao{position:sticky;bottom:0;background:linear-gradient(#f7f9fbaa,#f7f9fb); padding:16px 0;margin-top:8px} button{background:var(--azul);color:#fff;border:0;border-radius:10px;padding:14px 22px; font-size:16px;font-weight:700;cursor:pointer} button:hover{filter:brightness(1.05)} .res-num{display:flex;justify-content:space-between;font-size:14px;color:var(--cinza);margin:2px 0} .bar{display:flex;height:16px;border-radius:8px;overflow:hidden;background:#eef1f4;margin:6px 0 4px} .bar span{display:block} .legenda{font-size:13px;color:var(--cinza);margin-bottom:10px} .just{border-top:1px solid var(--linha);margin-top:10px;padding-top:10px} .just h4{margin:0 0 6px;font-size:13px;color:var(--cinza);text-transform:uppercase;letter-spacing:.03em} .just li{list-style:none;margin:6px 0;padding:8px 10px;background:#fafbfc;border-radius:8px} .chip{display:inline-block;font-size:12px;font-weight:700;color:#fff;border-radius:20px; padding:1px 9px;margin-right:8px} .vazio{color:var(--cinza);font-size:14px;font-style:italic} footer{max-width:820px;margin:0 auto;padding:20px;color:var(--cinza);font-size:13px} .total{font-weight:700;color:var(--tinta)} .ok{background:#e8f6ee;border:1px solid #9fd8b6;color:#1a6b3c;padding:16px 18px;border-radius:12px;margin:18px 0} """ def page(titulo, corpo, ativo=""): def nav_link(href, texto, chave): marca = ' style="text-decoration:underline"' if chave == ativo else "" return f'{texto}' return ( "
" '' f"Votação de propostas · Assembleia Estatutária da ULisboa
Ver resultados · Voltar ao boletim
') return page("Guardado - Lista M", corpo) def pagina_resultados(): c = conn() corpo = ['Cada eleitor conta uma vez por proposta (a sua última resposta). ' 'A regra da lista é escolher as propostas com mais de 50% de votos Sim.
'] for p in PROPOSTAS: cont = {e: 0 for e in ESCOLHAS} for esc, n in c.execute("SELECT escolha, COUNT(*) FROM respostas WHERE proposta=? " "AND escolha IN ('Sim','Não','Indeciso') GROUP BY escolha", (p["id"],)): cont[esc] = n total = sum(cont.values()) segs = "" nums = "" for e in ESCOLHAS: pct = (100.0 * cont[e] / total) if total else 0 segs += f'' nums += f'{html.escape(e)}: {cont[e]} ({pct:.0f}%) ' pct_sim = (100.0 * cont["Sim"] / total) if total else 0 veredicto = "" if total: veredicto = (' · %s' % (COR["Sim"] if pct_sim > 50 else COR["Não"], "passa (>50% Sim)" if pct_sim > 50 else "não passa")) just_rows = c.execute( "SELECT escolha, justificacao FROM respostas WHERE proposta=? " "AND justificacao<>'' ORDER BY atualizado_em DESC", (p["id"],)).fetchall() if just_rows: itens = "" for esc, txt in just_rows: cor = COR.get(esc, "#8a94a6") etiqueta = esc if esc else "Sem resposta" itens += (f'Sem justificações ainda.
Página não encontrada. " 'Ir votar.
'), code=404) def do_POST(self): if self.path.rstrip("/") != "/votar": self._send(b"", code=404) return bid, novo = self._ballot() n = int(self.headers.get("Content-Length", 0)) dados = parse_qs(self.rfile.read(n).decode("utf-8")) c = conn() agora = datetime.datetime.now().isoformat(timespec="seconds") for p in PROPOSTAS: escolha = (dados.get(f"vote_{p['id']}", [""])[0] or "").strip() just = (dados.get(f"just_{p['id']}", [""])[0] or "").strip() if escolha not in ESCOLHAS: escolha = "" # "Sem resposta" / em branco if escolha or just: c.execute( "INSERT INTO respostas (ballot, proposta, escolha, justificacao, atualizado_em) " "VALUES (?,?,?,?,?) ON CONFLICT(ballot, proposta) DO UPDATE SET " "escolha=excluded.escolha, justificacao=excluded.justificacao, " "atualizado_em=excluded.atualizado_em", (bid, p["id"], escolha, just, agora)) else: # deixou tudo em branco -> retira a resposta anterior (abstem-se) c.execute("DELETE FROM respostas WHERE ballot=? AND proposta=?", (bid, p["id"])) c.commit() c.close() self._send(pagina_obrigado(), set_bid=(bid if novo else None)) def log_message(self, *a): pass # silencio class Servidor(socketserver.ThreadingMixIn, http.server.HTTPServer): daemon_threads = True allow_reuse_address = True if __name__ == "__main__": conn().close() # cria a BD se preciso print(f"Plataforma de votacao Lista M a ouvir em http://{HOST}:{PORT}") Servidor((HOST, PORT), Handler).serve_forever()