Reservas de Salas

Por Marco Aurélio Japiassú Em 19/12/25 04:25 Atualizada em 19/12/25 05:06
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
Reserva de Salas (PPGQ/UFG) - 100% Python (arquivo único)
- UI web simples (sem frameworks)
- SQLite para persistência
- Checagem de conflitos por sala + intervalo de datas
- Gera link do Gmail (compose) + Google Calendar (TEMPLATE) com campos preenchidos
- Exporta .ics do evento (opcional/extra)
"""

from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs, quote_plus
import sqlite3
import json
import os
import html
from datetime import datetime

# =========================
# CONFIGURAÇÕES
# =========================
APP_TITLE = "Reserva de Salas — PPGQ/UFG"
TIMEZONE = "America/Sao_Paulo"
EMAIL_DOMAIN_REQUIRED = "@ufg.br"

NOTIFY_EMAILS = [
    "coordenacao.ppgq@ufg.br",
    # "marco.japiassu@ufg.br",  # se quiser, descomente / ajuste
]

ROOMS = [
    "Laboratório de Informática — Sala 203",
    "Anfiteatro IQ1 — Andar T",
    "Sala de Reuniões — 205",
    "Auditório 1 — IQ2 (1º andar)",
    "Auditório 2 — IQ2 (1º andar)",
    "Auditório 3 — IQ2 (1º andar)",
    "Anfiteatro IQ2 — Andar T",
]

DB_PATH = os.path.join(os.path.dirname(__file__), "reservas.db")
HOST = "0.0.0.0"
PORT = 8000


# =========================
# BANCO DE DADOS
# =========================
def db_connect():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn

def db_init():
    conn = db_connect()
    cur = conn.cursor()
    cur.execute("""
        CREATE TABLE IF NOT EXISTS reservas (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            created_at TEXT NOT NULL,
            nome TEXT NOT NULL,
            email TEXT NOT NULL,
            sala TEXT NOT NULL,
            titulo TEXT NOT NULL,
            inicio TEXT NOT NULL,
            fim TEXT NOT NULL,
            descricao TEXT
        )
    """)
    cur.execute("CREATE INDEX IF NOT EXISTS idx_sala_inicio_fim ON reservas (sala, inicio, fim)")
    cur.execute("CREATE INDEX IF NOT EXISTS idx_email ON reservas (email)")
    conn.commit()
    conn.close()


# =========================
# UTILITÁRIOS
# =========================
def now_iso():
    return datetime.now().isoformat(timespec="seconds")

def parse_dt_local(dt_str: str) -> datetime:
    # Espera: "YYYY-MM-DDTHH:MM"
    return datetime.strptime(dt_str, "%Y-%m-%dT%H:%M")

def overlaps(a_start: datetime, a_end: datetime, b_start: datetime, b_end: datetime) -> bool:
    return (a_start < b_end) and (b_start < a_end)

def is_valid_email(email: str) -> bool:
    e = (email or "").strip().lower()
    return ("@" in e) and e.endswith(EMAIL_DOMAIN_REQUIRED)

def gcal_dt(dt_local: datetime) -> str:
    # Google Calendar TEMPLATE usa: YYYYMMDDTHHMMSS
    return dt_local.strftime("%Y%m%dT%H%M%S")

def build_gmail_link(to_list, subject, body):
    to = ",".join([t for t in to_list if t])
    return (
        "https://mail.google.com/mail/?view=cm&fs=1"
        f"&to={quote_plus(to)}"
        f"&su={quote_plus(subject)}"
        f"&body={quote_plus(body)}"
    )

def build_calendar_link(title, details, location, start_local: datetime, end_local: datetime, guests):
    dates = f"{gcal_dt(start_local)}/{gcal_dt(end_local)}"
    add = ",".join([g for g in guests if g])
    return (
        "https://calendar.google.com/calendar/render?action=TEMPLATE"
        f"&text={quote_plus(title)}"
        f"&dates={quote_plus(dates)}"
        f"&details={quote_plus(details)}"
        f"&location={quote_plus(location)}"
        f"&ctz={quote_plus(TIMEZONE)}"
        f"&add={quote_plus(add)}"
    )

def ics_escape(s: str) -> str:
    # Escape básico conforme iCalendar (bem enxuto)
    s = (s or "").replace("\\", "\\\\").replace("\n", "\\n").replace("\r", "")
    s = s.replace(",", "\\,").replace(";", "\\;")
    return s

def build_ics(title, description, location, start_local: datetime, end_local: datetime):
    # ICS sem timezone avançado: usa "local time" como floating.
    # Para ambiente UFG interno funciona bem; se quiser TZ full, dá pra evoluir.
    dtstamp = datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
    dtstart = start_local.strftime("%Y%m%dT%H%M%S")
    dtend = end_local.strftime("%Y%m%dT%H%M%S")

    ics = "\r\n".join([
        "BEGIN:VCALENDAR",
        "VERSION:2.0",
        "PRODID:-//PPGQ UFG//ReservaSalas//PT-BR",
        "CALSCALE:GREGORIAN",
        "METHOD:PUBLISH",
        "BEGIN:VEVENT",
        f"DTSTAMP:{dtstamp}",
        f"DTSTART:{dtstart}",
        f"DTEND:{dtend}",
        f"SUMMARY:{ics_escape(title)}",
        f"DESCRIPTION:{ics_escape(description)}",
        f"LOCATION:{ics_escape(location)}",
        "END:VEVENT",
        "END:VCALENDAR",
        ""
    ])
    return ics.encode("utf-8")


def find_conflicts(sala: str, inicio: datetime, fim: datetime):
    conn = db_connect()
    cur = conn.cursor()
    cur.execute("""
        SELECT * FROM reservas
        WHERE sala = ?
    """, (sala,))
    rows = cur.fetchall()
    conn.close()

    conflicts = []
    for r in rows:
        r_inicio = parse_dt_local(r["inicio"])
        r_fim = parse_dt_local(r["fim"])
        if overlaps(inicio, fim, r_inicio, r_fim):
            conflicts.append(r)
    return conflicts


# =========================
# HTML (gerado em Python)
# =========================
def page_template(content_html: str) -> str:
    # UI simples porém “executiva”: limpa, clara, sem frescura
    rooms_options = "\n".join([f'<option value="{html.escape(r)}">{html.escape(r)}</option>' for r in ROOMS])

    return f"""<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>{html.escape(APP_TITLE)}</title>
<style>
  :root {{
    --bg: #0b1220;
    --card: rgba(255,255,255,0.06);
    --card2: rgba(255,255,255,0.10);
    --text: #e5e7eb;
    --muted: #a3a3a3;
    --ok: #22c55e;
    --warn: #f59e0b;
    --bad: #ef4444;
    --brand: #667eea;
    --brand2: #764ba2;
  }}
  body {{
    margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial;
    background: radial-gradient(1200px 800px at 20% 0%, rgba(102,126,234,0.25), transparent 60%),
                radial-gradient(1000px 700px at 100% 20%, rgba(118,75,162,0.25), transparent 55%),
                var(--bg);
    color: var(--text);
  }}
  .wrap {{ max-width: 1100px; margin: 0 auto; padding: 28px 18px 60px; }}
  .top {{
    display:flex; align-items:center; justify-content:space-between; gap:12px;
    margin-bottom: 18px;
  }}
  .brand {{
    font-weight: 900; letter-spacing: .3px;
    background: linear-gradient(135deg, var(--brand), var(--brand2));
    -webkit-background-clip: text; background-clip: text; color: transparent;
    font-size: 20px;
  }}
  .pill {{
    display:inline-flex; gap:8px; align-items:center;
    padding: 8px 12px; border-radius: 999px;
    background: rgba(255,255,255,0.08);
    border: 1px solid rgba(255,255,255,0.12);
    color: var(--muted); font-weight: 700; font-size: 12px;
  }}
  .grid {{
    display:grid; grid-template-columns: 1.3fr 0.7fr; gap: 14px;
  }}
  @media (max-width: 920px) {{ .grid {{ grid-template-columns: 1fr; }} }}
  .card {{
    background: var(--card);
    border: 1px solid rgba(255,255,255,0.12);
    border-radius: 18px;
    padding: 16px;
    box-shadow: 0 18px 60px rgba(0,0,0,0.35);
    backdrop-filter: blur(10px);
  }}
  h1 {{ margin: 0 0 8px; font-size: 22px; }}
  p {{ margin: 0 0 12px; color: var(--muted); }}
  label {{ display:block; margin: 10px 0 6px; font-weight: 800; font-size: 12px; color: #cbd5e1; letter-spacing: .6px; text-transform: uppercase; }}
  input, select, textarea {{
    width: 100%;
    padding: 11px 12px;
    border-radius: 12px;
    border: 1px solid rgba(255,255,255,0.14);
    background: rgba(0,0,0,0.25);
    color: var(--text);
    outline: none;
  }}
  textarea {{ min-height: 110px; resize: vertical; }}
  .row {{ display:grid; grid-template-columns: 1fr 1fr; gap: 10px; }}
  @media (max-width: 620px) {{ .row {{ grid-template-columns: 1fr; }} }}
  .actions {{
    display:flex; flex-wrap:wrap; gap: 10px; margin-top: 14px;
  }}
  .btn {{
    cursor:pointer; border:0;
    padding: 11px 12px; border-radius: 12px;
    font-weight: 900; letter-spacing: .3px;
    transition: transform .15s ease, filter .15s ease, box-shadow .15s ease;
  }}
  .btn:hover {{ transform: translateY(-2px); filter: brightness(1.05); box-shadow: 0 16px 40px rgba(0,0,0,0.35); }}
  .btn.primary {{
    background: linear-gradient(135deg, var(--brand), var(--brand2));
    color: white;
  }}
  .btn.ghost {{
    background: rgba(255,255,255,0.08);
    color: var(--text);
    border: 1px solid rgba(255,255,255,0.12);
  }}
  .status {{
    margin-top: 12px; padding: 12px;
    border-radius: 14px;
    border: 1px solid rgba(255,255,255,0.12);
    background: rgba(255,255,255,0.06);
  }}
  .status.ok {{ border-color: rgba(34,197,94,0.35); background: rgba(34,197,94,0.10); }}
  .status.warn {{ border-color: rgba(245,158,11,0.35); background: rgba(245,158,11,0.10); }}
  .status.bad {{ border-color: rgba(239,68,68,0.35); background: rgba(239,68,68,0.10); }}
  .kpi {{
    display:grid; gap:10px;
  }}
  .kpi .item {{
    padding: 12px; border-radius: 14px;
    background: rgba(255,255,255,0.06);
    border: 1px solid rgba(255,255,255,0.12);
  }}
  .kpi b {{ display:block; font-size: 12px; color: #cbd5e1; letter-spacing: .6px; text-transform: uppercase; }}
  .kpi span {{ display:block; margin-top: 6px; color: var(--muted); font-size: 13px; }}
  .link {{
    display:inline-flex; gap:8px; align-items:center;
    margin-top: 10px;
    padding: 10px 12px;
    border-radius: 12px;
    background: rgba(0,0,0,0.35);
    border: 1px solid rgba(255,255,255,0.12);
    color: var(--text);
    text-decoration: none;
    font-weight: 900;
  }}
  .link:hover {{ transform: translateY(-2px); }}
  .mini {{
    font-size: 12px; color: var(--muted);
  }}
  .footer {{
    margin-top: 14px; color: var(--muted); font-size: 12px;
  }}
</style>
</head>
<body>
<div class="wrap">
  <div class="top">
    <div class="brand">{html.escape(APP_TITLE)}</div>
    <div class="pill">🕒 Fuso: {html.escape(TIMEZONE)} · ✅ Domínio: {html.escape(EMAIL_DOMAIN_REQUIRED)}</div>
  </div>

  <div class="grid">
    <div class="card">
      {content_html}
    </div>

    <div class="card">
      <div class="kpi">
        <div class="item">
          <b>Como funciona (sem teatro)</b>
          <span>Você cria a reserva → o sistema gera <strong>Gmail</strong> + <strong>Google Calendar</strong> preenchidos. A pessoa só finaliza com 2 cliques.</span>
        </div>
        <div class="item">
          <b>Checagem de conflito</b>
          <span>Conflito é checado no banco SQLite deste sistema. Se ele rodar num servidor único, vira “verdade oficial” do programa.</span>
        </div>
        <div class="item">
          <b>Upgrade possível</b>
          <span>Para checar a agenda real do Google (FreeBusy) precisa OAuth/Calendar API. Dá pra evoluir, mas aqui já resolve o operacional sem travar.</span>
        </div>
      </div>

      <div class="footer">
        Dica: na Weby, coloque um botão apontando para este sistema (URL do servidor).
      </div>
    </div>
  </div>

  <div class="footer">© PPGQ/UFG — Reserva de Salas (Python single-file)</div>
</div>

<script>
async function checarDisponibilidade() {{
  const sala = document.getElementById('sala').value;
  const inicio = document.getElementById('inicio').value;
  const fim = document.getElementById('fim').value;

  const status = document.getElementById('statusClient');
  status.className = 'status warn';
  status.textContent = 'Checando disponibilidade...';
  status.style.display = 'block';

  if (!sala || !inicio || !fim) {{
    status.className = 'status warn';
    status.textContent = 'Preencha Sala + Início + Fim para checar.';
    return;
  }}

  const url = `/api/check?sala=${{encodeURIComponent(sala)}}&inicio=${{encodeURIComponent(inicio)}}&fim=${{encodeURIComponent(fim)}}`;
  const res = await fetch(url);
  const data = await res.json();

  if (!data.ok) {{
    status.className = 'status bad';
    status.textContent = data.error || 'Erro ao checar.';
    return;
  }}

  if (data.conflicts && data.conflicts.length) {{
    status.className = 'status warn';
    status.textContent = `⚠️ Conflito encontrado: ${{data.conflicts.length}} reserva(s) sobreposta(s).`;
  }} else {{
    status.className = 'status ok';
    status.textContent = '✅ Sem conflito no banco deste sistema.';
  }}
}}
</script>
</body>
</html>"""

def home_form(status_html=""):
    rooms_options = "\n".join([f'<option value="{html.escape(r)}">{html.escape(r)}</option>' for r in ROOMS])

    return f"""
      <h1>📅 Criar Reserva</h1>
      <p>Preencha os dados. O sistema valida, grava e gera links do Gmail/Calendar.</p>

      <form method="POST" action="/reserve">
        <label>Nome completo *</label>
        <input name="nome" required placeholder="Seu nome"/>

        <label>Email institucional *</label>
        <input name="email" type="email" required placeholder="seu.nome@ufg.br"/>

        <label>Sala / Local *</label>
        <select id="sala" name="sala" required>
          <option value="">Selecione...</option>
          {rooms_options}
        </select>

        <label>Título da reserva *</label>
        <input name="titulo" required placeholder="Ex.: Reunião, Defesa, Aula, Seminário..."/>

        <div class="row">
          <div>
            <label>Início *</label>
            <input id="inicio" name="inicio" type="datetime-local" required/>
          </div>
          <div>
            <label>Fim *</label>
            <input id="fim" name="fim" type="datetime-local" required/>
          </div>
        </div>

        <label>Descrição (opcional)</label>
        <textarea name="descricao" placeholder="Pauta, público, links, observações..."></textarea>

        <div class="actions">
          <button class="btn primary" type="submit">✅ Criar reserva</button>
          <button class="btn ghost" type="button" onclick="checarDisponibilidade()">🔎 Checar disponibilidade</button>
          <a class="btn ghost" style="text-decoration:none; display:inline-flex; align-items:center;"
             href="/minhas">📌 Minhas reservas</a>
        </div>

        <div id="statusClient" class="status warn" style="display:none;"></div>
      </form>

      {status_html}
    """

def success_block(gmail_url, cal_url, ics_url, info):
    return f"""
      <div class="status ok">
        <b>✅ Reserva criada!</b>
        <div class="mini">{html.escape(info)}</div>
        <div style="margin-top:10px; display:flex; flex-wrap:wrap; gap:10px;">
          <a class="link" href="{html.escape(gmail_url)}" target="_blank" rel="noopener">📨 Abrir Gmail (enviar)</a>
          <a class="link" href="{html.escape(cal_url)}" target="_blank" rel="noopener">🗓️ Abrir Google Calendar (salvar)</a>
          <a class="link" href="{html.escape(ics_url)}">⬇️ Baixar .ics</a>
        </div>
      </div>
    """

def error_block(msg):
    return f"""<div class="status bad"><b>❌ Erro:</b> {html.escape(msg)}</div>"""

def warn_block(msg):
    return f"""<div class="status warn"><b>⚠️ Atenção:</b> {html.escape(msg)}</div>"""

def minhas_page(email="", items_html=""):
    return f"""
      <h1>📌 Minhas Reservas</h1>
      <p>Busca por e-mail no banco do sistema.</p>

      <form method="GET" action="/minhas">
        <label>Email</label>
        <input name="email" type="email" value="{html.escape(email)}" placeholder="seu.nome@ufg.br"/>
        <div class="actions">
          <button class="btn primary" type="submit">Buscar</button>
          <a class="btn ghost" style="text-decoration:none; display:inline-flex; align-items:center;"
             href="/">← Voltar</a>
        </div>
      </form>

      {items_html}
    """

def reservas_list_html(rows):
    if not rows:
        return warn_block("Nenhuma reserva encontrada para este e-mail.")
    parts = ['<div class="status ok"><b>Resultados</b><div class="mini">Reservas encontradas no sistema.</div></div>']
    for r in rows:
        parts.append(f"""
          <div class="status" style="margin-top:10px;">
            <b>{html.escape(r['titulo'])}</b><br/>
            <span class="mini">Sala: {html.escape(r['sala'])}</span><br/>
            <span class="mini">🕒 {html.escape(r['inicio'])} → {html.escape(r['fim'])}</span><br/>
            <span class="mini">Solicitante: {html.escape(r['nome'])} ({html.escape(r['email'])})</span><br/>
            <a class="link" href="/ics?id={r['id']}">⬇️ .ics</a>
          </div>
        """)
    return "\n".join(parts)


# =========================
# HANDLER HTTP
# =========================
class Handler(BaseHTTPRequestHandler):
    def _send(self, status=200, content_type="text/html; charset=utf-8", body=b""):
        self.send_response(status)
        self.send_header("Content-Type", content_type)
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def _send_json(self, obj, status=200):
        payload = json.dumps(obj, ensure_ascii=False).encode("utf-8")
        self._send(status=status, content_type="application/json; charset=utf-8", body=payload)

    def do_GET(self):
        parsed = urlparse(self.path)
        path = parsed.path
        qs = parse_qs(parsed.query)

        try:
            if path == "/":
                html_out = page_template(home_form(status_html=""))
                self._send(200, body=html_out.encode("utf-8"))
                return

            if path == "/minhas":
                email = (qs.get("email", [""])[0] or "").strip()
                items_html = ""
                if email:
                    conn = db_connect()
                    cur = conn.cursor()
                    cur.execute("SELECT * FROM reservas WHERE lower(email)=lower(?) ORDER BY inicio ASC", (email,))
                    rows = cur.fetchall()
                    conn.close()
                    items_html = reservas_list_html(rows)
                html_out = page_template(minhas_page(email=email, items_html=items_html))
                self._send(200, body=html_out.encode("utf-8"))
                return

            if path == "/api/check":
                sala = (qs.get("sala", [""])[0] or "").strip()
                inicio_s = (qs.get("inicio", [""])[0] or "").strip()
                fim_s = (qs.get("fim", [""])[0] or "").strip()

                if not sala or not inicio_s or not fim_s:
                    self._send_json({"ok": False, "error": "Informe sala, inicio e fim."}, status=400)
                    return

                inicio = parse_dt_local(inicio_s)
                fim = parse_dt_local(fim_s)
                if fim <= inicio:
                    self._send_json({"ok": False, "error": "Fim deve ser maior que início."}, status=400)
                    return

                conflicts = find_conflicts(sala, inicio, fim)
                # devolve um resumo (sem expor tudo demais)
                out = [{
                    "id": r["id"],
                    "titulo": r["titulo"],
                    "inicio": r["inicio"],
                    "fim": r["fim"],
                    "email": r["email"],
                } for r in conflicts]

                self._send_json({"ok": True, "conflicts": out})
                return

            if path == "/ics":
                rid = (qs.get("id", [""])[0] or "").strip()
                if not rid.isdigit():
                    self._send(400, body=b"Bad request")
                    return

                conn = db_connect()
                cur = conn.cursor()
                cur.execute("SELECT * FROM reservas WHERE id=?", (int(rid),))
                r = cur.fetchone()
                conn.close()

                if not r:
                    self._send(404, body=b"Not found")
                    return

                start_local = parse_dt_local(r["inicio"])
                end_local = parse_dt_local(r["fim"])
                title = f"{r['titulo']} — {r['sala']}"
                desc = (r["descricao"] or "").strip()
                description = f"Solicitante: {r['nome']} ({r['email']})\\n\\n{desc}".strip()
                ics_bytes = build_ics(title=title, description=description, location=r["sala"],
                                     start_local=start_local, end_local=end_local)

                self.send_response(200)
                self.send_header("Content-Type", "text/calendar; charset=utf-8")
                self.send_header("Content-Disposition", f'attachment; filename="reserva_{rid}.ics"')
                self.send_header("Cache-Control", "no-store")
                self.end_headers()
                self.wfile.write(ics_bytes)
                return

            self._send(404, body=b"Not found")
        except Exception as ex:
            self._send(500, body=str(ex).encode("utf-8"))

    def do_POST(self):
        parsed = urlparse(self.path)
        path = parsed.path

        try:
            if path != "/reserve":
                self._send(404, body=b"Not found")
                return

            length = int(self.headers.get("Content-Length", "0"))
            raw = self.rfile.read(length).decode("utf-8", errors="replace")

            # parse x-www-form-urlencoded
            data = parse_qs(raw)

            nome = (data.get("nome", [""])[0] or "").strip()
            email = (data.get("email", [""])[0] or "").strip()
            sala = (data.get("sala", [""])[0] or "").strip()
            titulo = (data.get("titulo", [""])[0] or "").strip()
            inicio_s = (data.get("inicio", [""])[0] or "").strip()
            fim_s = (data.get("fim", [""])[0] or "").strip()
            descricao = (data.get("descricao", [""])[0] or "").strip()

            status_html = ""

            # validações
            if not (nome and email and sala and titulo and inicio_s and fim_s):
                status_html = error_block("Preencha todos os campos obrigatórios (*).")
                html_out = page_template(home_form(status_html=status_html))
                self._send(400, body=html_out.encode("utf-8"))
                return

            if not is_valid_email(email):
                status_html = error_block(f"Email precisa terminar com {EMAIL_DOMAIN_REQUIRED}.")
                html_out = page_template(home_form(status_html=status_html))
                self._send(400, body=html_out.encode("utf-8"))
                return

            inicio = parse_dt_local(inicio_s)
            fim = parse_dt_local(fim_s)
            if fim <= inicio:
                status_html = error_block("Fim deve ser maior que o início.")
                html_out = page_template(home_form(status_html=status_html))
                self._send(400, body=html_out.encode("utf-8"))
                return

            # conflito?
            conflicts = find_conflicts(sala, inicio, fim)
            if conflicts:
                # Não bloqueia por decreto? Você decide.
                # Aqui: bloqueia para evitar “double booking”.
                status_html = error_block(f"Conflito detectado para esta sala e horário ({len(conflicts)} reserva(s) sobreposta(s)).")
                html_out = page_template(home_form(status_html=status_html))
                self._send(409, body=html_out.encode("utf-8"))
                return

            # grava no banco
            conn = db_connect()
            cur = conn.cursor()
            cur.execute("""
                INSERT INTO reservas (created_at, nome, email, sala, titulo, inicio, fim, descricao)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?)
            """, (now_iso(), nome, email, sala, titulo, inicio_s, fim_s, descricao))
            rid = cur.lastrowid
            conn.commit()
            conn.close()

            # links Gmail + Calendar
            subject = f"[Reserva de Sala] {titulo} — {sala}"
            body = (
                f"Olá,\n\n"
                f"Segue solicitação de RESERVA DE SALA (PPGQ/UFG):\n\n"
                f"• Solicitante: {nome}\n"
                f"• Email: {email}\n"
                f"• Sala/Local: {sala}\n"
                f"• Início: {inicio_s}\n"
                f"• Término: {fim_s}\n"
                f"• Título: {titulo}\n\n"
                f"Descrição:\n{descricao or '(sem descrição)'}\n\n"
                f"Registro interno do sistema de reservas.\n"
            )

            gmail_url = build_gmail_link(
                to_list=[email] + NOTIFY_EMAILS,
                subject=subject,
                body=body
            )

            details = (
                f"PPGQ/UFG\n\n"
                f"Solicitante: {nome} ({email})\n"
                f"Sala/Local: {sala}\n\n"
                f"{descricao or ''}"
            ).strip()

            cal_url = build_calendar_link(
                title=f"{titulo} — {sala}",
                details=details,
                location=sala,
                start_local=inicio,
                end_local=fim,
                guests=[email] + NOTIFY_EMAILS
            )

            ics_url = f"/ics?id={rid}"
            info = f"Reserva #{rid} registrada. Agora finalize pelo Gmail/Calendar."

            status_html = success_block(gmail_url, cal_url, ics_url, info)
            html_out = page_template(home_form(status_html=status_html))
            self._send(200, body=html_out.encode("utf-8"))
            return

        except Exception as ex:
            self._send(500, body=str(ex).encode("utf-8"))


def main():
    db_init()
    server = HTTPServer((HOST, PORT), Handler)
    print(f"✅ Servidor rodando em: http://{HOST}:{PORT}")
    print(f"📦 Banco SQLite: {DB_PATH}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()
        print("🛑 Servidor encerrado.")


if __name__ == "__main__":
    main()