# -*- coding: utf-8 -*- """Servidor de FEEDBACK anonimo dos materiais de SSM. So biblioteca-padrao -> portavel (corre no Barbosa, Ryzen ou Ubuntu com Python 3.8+ e nada mais instalado). DESENHO PARA ANONIMATO (o codigo e a prova): - Sem login, sem conta, sem email, sem numero de aluno. - Sem cookies (nunca se envia Set-Cookie). - Sem registo de IP: o log_message por defeito imprimiria o endereco do cliente; esta sobreposto para nao imprimir nada, e o client_address NUNCA e lido nem gravado. - Guarda-se apenas: a ferramenta, a nota (1-5, opcional) e o comentario (opcional), com a DATA so ao dia (sem hora), para nao permitir correlacao temporal. - Funciona sem JavaScript (formulario HTML simples por POST). python servidor_feedback.py # usa config.json ao lado """ import os, json, html, datetime from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from urllib.parse import urlparse, parse_qs HERE = os.path.dirname(os.path.abspath(__file__)) SELF_SRC = os.path.abspath(__file__) # o proprio ficheiro, servido em /codigo ESTADO = os.path.join(HERE, "estado") os.makedirs(ESTADO, exist_ok=True) CFG_PATH = os.path.join(HERE, "config.json") FEEDBACK_LOG = os.path.join(ESTADO, "feedback.jsonl") DEFAULT_CFG = { "port": 8079, "titulo": "Feedback dos materiais de SSM", "subtitulo": "Sistemas e Sinais Mecatrónicos", "docente_key": "", # tambem pode vir de env SSM_FEEDBACK_KEY "ferramentas": [ "Questionários de laboratório (quizzes)", "Exames e testes resolvidos", "Revisão de provas online", "Tutor virtual (Tutor SSM)", "Comentário geral / desabafo (o que quiseres)", "Outro / não listado", ], "comentario_max": 4000, } def load_cfg(): cfg = dict(DEFAULT_CFG) if os.path.exists(CFG_PATH): try: cfg.update(json.load(open(CFG_PATH, encoding="utf-8"))) except Exception as e: print("AVISO: config.json invalido, uso defaults:", e) return cfg CFG = load_cfg() def _base(): """Prefixo sob o qual a pagina e servida. O Tailscale Funnel com --set-path RETIRA o prefixo antes de entregar o pedido, por isso as ROTAS aqui dentro sao sempre sem prefixo; o que precisa do prefixo sao os URLs que a pagina ESCREVE, senao o browser sai para a raiz da porta, que e outro servico. Vazio = servido na raiz (uso local). """ b = (os.environ.get("SSM_FEEDBACK_BASE") or CFG.get("base_path", "") or "").strip() b = b.rstrip("/") if b and not b.startswith("/"): b = "/" + b return b BASE = _base() def _grupos(): """Secoes do formulario. Cada uma tem titulo, legenda da escala e itens. Ha duas: as ferramentas de apoio ao estudo e o MODELO DE EXAME (caixas de resposta, cotacao parcial pelo raciocinio, versao por aluno). Sao escalas diferentes -- util/inutil nao serve para julgar um modelo de avaliacao -- por isso cada grupo traz a sua legenda. """ gs = CFG.get("grupos") if not gs: # retrocompatibilidade com o config antigo, de lista unica gs = [{"titulo": "", "legenda": "1 = nada útil, 5 = muito útil", "itens": CFG.get("ferramentas", [])}] return gs GRUPOS = _grupos() ITENS = [i for g in GRUPOS for i in g["itens"]] # indice global, usado nos campos def _sem_base(caminho): # defensivo: se algum dia o prefixo NAO for retirado, tiramo-lo aqui. if BASE and (caminho == BASE or caminho.startswith(BASE + "/")): return caminho[len(BASE):] or "/" return caminho def docente_key(): return (os.environ.get("SSM_FEEDBACK_KEY") or CFG.get("docente_key", "")).strip() def append_jsonl(obj): with open(FEEDBACK_LOG, "a", encoding="utf-8") as f: f.write(json.dumps(obj, ensure_ascii=False) + "\n") f.flush(); os.fsync(f.fileno()) def ler_feedback(): out = [] if os.path.exists(FEEDBACK_LOG): for ln in open(FEEDBACK_LOG, encoding="utf-8"): ln = ln.strip() if ln: try: out.append(json.loads(ln)) except Exception: pass return out # ---------------------------------------------------------------- paginas BASE_CSS = """ :root{--ink:#20303a;--mut:#5b6b75;--line:#dde3e8;--ist:#009DE0} *{box-sizing:border-box} body{font-family:'Segoe UI',system-ui,sans-serif;color:var(--ink);background:#f6f8fa;margin:0} .box{max-width:580px;margin:6vh auto;background:#fff;border:1px solid var(--line);border-radius:12px;padding:26px 28px} h1{font-size:21px;margin:0 0 4px} .sub{color:var(--mut);font-size:14px;margin-bottom:16px} label{display:block;font-size:14.5px;margin:18px 0 6px;color:#33424c;font-weight:600} select,textarea{width:100%;padding:11px 12px;font-size:16px;border:1px solid #c4ced5;border-radius:8px;font-family:inherit} textarea{min-height:120px;resize:vertical} .notas{display:flex;gap:8px;margin-top:6px;flex-wrap:wrap} .notas label{font-weight:400;margin:0;display:flex;align-items:center;gap:5px;border:1px solid #c4ced5;border-radius:8px;padding:8px 12px;cursor:pointer} .notas input{width:auto;margin:0} button{margin-top:22px;width:100%;padding:12px;font-size:15px;font-weight:600;border:0;border-radius:8px;background:var(--ist);color:#fff;cursor:pointer} button:hover{filter:brightness(0.94)} .err{color:#b3352c;font-size:14px;margin-top:10px} .fine{color:#8a96a0;font-size:12.5px;margin-top:18px;line-height:1.55} .priv{background:#f3f8fb;border-left:4px solid var(--ist);border-radius:6px;padding:13px 16px;margin:18px 0 4px;font-size:13px;line-height:1.55} .priv b{color:#20303a} table{border-collapse:collapse;width:100%;font-size:13.5px;margin-top:12px} th,td{border-bottom:1px solid var(--line);padding:7px 8px;text-align:left;vertical-align:top} .k{color:var(--mut);font-size:12px} .ferr{border:1px solid var(--line);border-radius:10px;padding:12px 14px;margin-top:12px} .ferr h3{margin:0 0 8px;font-size:15px;font-weight:600;color:#33424c} .ferr .notas{margin:0} .ferr .notas label{padding:6px 10px;font-size:14px} .ferr input[type=text]{width:100%;margin-top:9px;padding:9px 11px;font-size:15px;border:1px solid #c4ced5;border-radius:8px;font-family:inherit} .naousei{color:var(--mut)} h2.grupo{font-size:16px;margin:26px 0 2px;padding-bottom:5px;border-bottom:2px solid var(--ist);color:#20303a} .legenda{color:var(--mut);font-size:13px;margin:6px 0 0} """ def _hidden_honeypot(): # campo-armadilha: humanos nao o veem; bots preenchem-no e a submissao e ignorada. return ('') def _bloco_ferramenta(i, nome, sem="não usei"): """Uma linha por assunto: sem-resposta + 1..5 + comentario curto. A escolha e mostrar TODOS ao mesmo tempo em vez de uma lista pendente: com a lista, quem chegava preenchia o primeiro e saia, sem perceber que havia mais. O rotulo do sem-resposta muda por grupo ("nao usei" nas ferramentas, "sem opiniao" no modelo de exame). """ op = "".join( f'' for n in range(1, 6)) return f"""

{html.escape(nome)}

{op}
""" def pag_home(err="", ok=False, resumo=""): seccoes = [] i = 0 for g in GRUPOS: blocos = "" for nome in g["itens"]: blocos += _bloco_ferramenta(i, nome, g.get("sem_opiniao", "não usei")) i += 1 titulo = (f'

{html.escape(g["titulo"])}

' if g.get("titulo") else "") seccoes.append(f'{titulo}

Que nota dás a cada uma? ' f'{html.escape(g.get("legenda",""))}; deixa em «não usei» ' f'ou «sem opinião» as que não queiras responder.

{blocos}') e = f'
{html.escape(err)}
' if err else "" obrigado = "" if ok: extra = f" {html.escape(resumo)}" if resumo else "" obrigado = ('
' f'Obrigado.{extra} Ficou registado de forma anónima. Se te lembrares de ' 'mais alguma coisa, podes voltar a usar o formulário em baixo.
') return f""" {html.escape(CFG['titulo'])}

{html.escape(CFG['titulo'])}

{html.escape(CFG['subtitulo'])}
{obrigado}
É anónimo, a sério. Esta página não pede login nem conta, não usa cookies e não regista o teu IP. Guarda apenas: o assunto, a tua nota e o teu comentário, com a data só ao dia. Não guarda nome, número, email nem hora. Funciona sem JavaScript. Queres confirmar? Nas ferramentas de programador do browser não vais ver cookies nem pedidos a outros sítios, e podes ler o código desta página.
{_hidden_honeypot()} {''.join(seccoes)}

{html.escape(CFG.get('rotulo_geral', 'Comentário geral'))}

{e}

Voluntário, e não tem qualquer efeito nas classificações. Podes responder a uma linha, a várias, ou só deixar o comentário. Recolhemos isto só para melhorar os materiais e a forma de avaliar, e para perceber o que resulta.

""" def pag_docente_login(err=""): e = f'
{html.escape(err)}
' if err else "" return f""" Feedback · docente

Feedback recolhido

Área do docente
{e}
""" def pag_docente(dados): from collections import defaultdict n = len(dados) porf = defaultdict(list) for d in dados: porf[d.get("ferramenta", "?")].append(d) linhas_stats = [] for f, itens in sorted(porf.items(), key=lambda kv: -len(kv[1])): notas = [i["nota"] for i in itens if isinstance(i.get("nota"), int)] media = f"{sum(notas)/len(notas):.1f}" if notas else "—" linhas_stats.append(f"{html.escape(f)}{len(itens)}" f"{media} ({len(notas)} notas)") linhas_com = [] for d in reversed(dados): if (d.get("comentario") or "").strip(): nota = d.get("nota"); nota_s = f"[{nota}] " if isinstance(nota, int) else "" linhas_com.append( f"{html.escape(d.get('data',''))}
{html.escape(d.get('ferramenta',''))}" f"{nota_s}{html.escape(d.get('comentario',''))}") corpo_com = ("".join(linhas_com)) or "Ainda sem comentários." return f""" Feedback · docente

Feedback recolhido

{n} respostas no total.
{''.join(linhas_stats)}
FerramentaRespostasMédia

Comentários

{corpo_com}
""" def csv_de(dados): import io, csv buf = io.StringIO() w = csv.writer(buf) w.writerow(["data", "ferramenta", "nota", "comentario"]) for d in dados: w.writerow([d.get("data", ""), d.get("ferramenta", ""), d.get("nota", ""), d.get("comentario", "")]) return buf.getvalue() # ---------------------------------------------------------------- servidor class H(BaseHTTPRequestHandler): def log_message(self, *a): # NAO registar nada (evita IPs nos logs) pass def _send(self, body, ctype="text/html; charset=utf-8", code=200, extra=None): data = body.encode("utf-8") if isinstance(body, str) else body self.send_response(code) self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(data))) if extra: for k, v in extra.items(): self.send_header(k, v) self.end_headers() self.wfile.write(data) def _post_fields(self): n = int(self.headers.get("Content-Length") or 0) raw = self.rfile.read(n).decode("utf-8", "replace") if n else "" return {k: v[0] for k, v in parse_qs(raw, keep_blank_values=True).items()} def do_GET(self): p = _sem_base(urlparse(self.path).path) if p in ("/", "/index.html"): self._send(pag_home()) elif p == "/codigo": try: src = open(SELF_SRC, encoding="utf-8").read() except Exception: src = "(nao foi possivel ler o codigo)" self._send(src, ctype="text/plain; charset=utf-8") elif p == "/docente": self._send(pag_docente_login()) else: self._send(pag_home(), code=404) def do_POST(self): p = _sem_base(urlparse(self.path).path) f = self._post_fields() if p == "/enviar": if (f.get("website") or "").strip(): # honeypot preenchido -> bot, ignora em silencio self._send(pag_home(ok=True)); return maxc = CFG["comentario_max"] registos = [] for i, nome in enumerate(ITENS): nota = None try: nv = int(f.get(f"nota_{i}") or "") if 1 <= nv <= 5: nota = nv except ValueError: pass com = (f.get(f"com_{i}") or "").strip()[:600] if nota is None and not com: continue # "nao usei" e sem comentario: nao se inventa registo registos.append({"ferramenta": nome, "nota": nota, "comentario": com}) geral = (f.get("comentario_geral") or "").strip()[:maxc] if geral: registos.append({"ferramenta": CFG.get("rotulo_geral", "Comentário geral"), "nota": None, "comentario": geral}) if not registos: self._send(pag_home(err="Dá pelo menos uma nota, ou escreve um comentário.")) return hoje = datetime.date.today().isoformat() # so o dia, sem hora for r in registos: append_jsonl({"data": hoje, "ferramenta": r["ferramenta"], "nota": r["nota"], "comentario": r["comentario"]}) n = len(registos) resumo = "Uma resposta." if n == 1 else f"{n} respostas." self._send(pag_home(ok=True, resumo=resumo)) elif p == "/docente": key = (f.get("key") or "").strip() if not docente_key() or key != docente_key(): self._send(pag_docente_login(err="Chave incorreta.")); return dados = ler_feedback() if (f.get("formato") or "") == "csv": self._send(csv_de(dados), ctype="text/csv; charset=utf-8", extra={"Content-Disposition": "attachment; filename=feedback_ssm.csv"}) else: self._send(pag_docente(dados)) else: self._send(pag_home(), code=404) class Servidor(ThreadingHTTPServer): daemon_threads = True allow_reuse_address = True if __name__ == "__main__": port = int(CFG["port"]) print(f"Feedback SSM em http://0.0.0.0:{port}{BASE or '/'} (Ctrl+C para parar)") if BASE: print(f"Servido debaixo do prefixo {BASE} (SSM_FEEDBACK_BASE / config.base_path).") if not docente_key(): print("AVISO: sem chave de docente definida (config.docente_key ou env SSM_FEEDBACK_KEY);") print(" a area /docente fica inacessivel ate definires uma.") Servidor(("0.0.0.0", port), H).serve_forever()