# -*- 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"""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'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.
| Ferramenta | Respostas | Média |
|---|