first commit

This commit is contained in:
2026-02-18 20:34:33 -03:00
parent 2f819da943
commit 60f0dcaac4
50 changed files with 8099 additions and 1471 deletions

View File

@@ -0,0 +1,160 @@
from flask import Blueprint, render_template, request, jsonify, redirect, flash
from modules.core.security import requires_admin_auth
from modules.core.audit import audit_log
import threading
from modules.core.audit import audit_log
from oci_genai_llm_graphrag_rerank_rfp import (
search_chunks_for_invalidation,
revoke_chunk_by_hash,
get_chunk_metadata,
add_manual_knowledge_entry,
reload_all
)
admin_bp = Blueprint("admin", __name__)
# =========================
# ADMIN HOME (invalidate UI)
# =========================
@admin_bp.route("/")
@requires_admin_auth
def admin_home():
return render_template("admin_menu.html")
@admin_bp.route("/invalidate")
@requires_admin_auth
def invalidate_page():
return render_template(
"invalidate.html",
results=[],
statement=""
)
# =========================
# SEARCH CHUNKS
# =========================
@admin_bp.route("/search", methods=["POST"])
@requires_admin_auth
def search_for_invalidation():
statement = request.form["statement"]
docs = search_chunks_for_invalidation(statement)
hashes = [d.metadata.get("chunk_hash") for d in docs if d.metadata.get("chunk_hash")]
meta = get_chunk_metadata(hashes)
results = []
for d in docs:
h = d.metadata.get("chunk_hash")
m = meta.get(h, {})
results.append({
"chunk_hash": h,
"source": d.metadata.get("source"),
"text": d.page_content,
"origin": m.get("origin"),
"status": m.get("status")
})
return render_template(
"invalidate.html",
statement=statement,
results=results
)
# =========================
# REVOKE
# =========================
@admin_bp.route("/revoke", methods=["POST"])
@requires_admin_auth
def revoke_chunk_ui():
data = request.get_json()
chunk_hash = str(data["chunk_hash"])
reason = str(data.get("reason", "Manual revoke"))
audit_log("INVALIDATE", f"chunk_hash={chunk_hash}")
print("chunk_hash", chunk_hash)
print("reason", reason)
revoke_chunk_by_hash(chunk_hash, reason)
return {"status": "ok", "chunk_hash": chunk_hash}
# =========================
# ADD MANUAL KNOWLEDGE
# =========================
@admin_bp.route("/add-knowledge", methods=["POST"])
@requires_admin_auth
def add_manual_knowledge():
data = request.get_json(force=True)
chunk_hash = add_manual_knowledge_entry(
text=data["text"],
author="ADMIN",
reason=data.get("reason"),
source="MANUAL_INPUT",
origin="MANUAL",
also_update_graph=True
)
audit_log("ADD_KNOWLEDGE", f"chunk_hash={chunk_hash}")
return jsonify({
"status": "OK",
"chunk_hash": chunk_hash
})
# =========================
# UPDATE CHUNK
# =========================
@admin_bp.route("/update-chunk", methods=["POST"])
@requires_admin_auth
def update_chunk():
data = request.get_json() or {}
chunk_hash = str(data.get("chunk_hash", "")).strip()
text = str(data.get("text", "")).strip()
print("chunk_hash", chunk_hash)
print("text", text)
if not chunk_hash:
return {"status": "error", "message": "missing hash"}, 400
reason = str(data.get("reason", "Manual change"))
revoke_chunk_by_hash(chunk_hash, reason=reason)
chunk_hash = add_manual_knowledge_entry(
text=text,
author="ADMIN",
reason=reason,
source="MANUAL_INPUT",
origin="MANUAL",
also_update_graph=True
)
audit_log("UPDATE CHUNK", f"chunk_hash={chunk_hash}")
return jsonify({
"status": "OK",
"chunk_hash": chunk_hash
})
@admin_bp.route("/reboot", methods=["POST"])
@requires_admin_auth
def reboot_service():
# roda em background pra não travar request
threading.Thread(target=reload_all, daemon=True).start()
return jsonify({
"status": "ok",
"message": "Knowledge reload started"
})

View File

@@ -0,0 +1,83 @@
from flask import Blueprint, request, jsonify
import uuid
import json
from pathlib import Path
from modules.core.audit import audit_log
from modules.core.security import requires_app_auth
from .service import start_architecture_job
from .store import ARCH_JOBS, ARCH_LOCK
architecture_bp = Blueprint("architecture", __name__)
ARCH_FOLDER = Path("architecture")
@architecture_bp.route("/architecture/start", methods=["POST"])
@requires_app_auth
def architecture_start():
data = request.get_json(force=True) or {}
question = (data.get("question") or "").strip()
if not question:
return jsonify({"error": "Empty question"}), 400
job_id = str(uuid.uuid4())
audit_log("ARCHITECTURE", f"job_id={job_id}")
with ARCH_LOCK:
ARCH_JOBS[job_id] = {
"status": "RUNNING",
"logs": []
}
start_architecture_job(job_id, question)
return jsonify({"job_id": job_id})
@architecture_bp.route("/architecture/<job_id>/status", methods=["GET"])
@requires_app_auth
def architecture_status(job_id):
job_dir = ARCH_FOLDER / job_id
status_file = job_dir / "status.json"
# fallback 1: status persistido
if status_file.exists():
try:
return jsonify(json.loads(status_file.read_text(encoding="utf-8")))
except Exception:
return jsonify({"status": "ERROR", "detail": "Invalid status file"}), 500
# fallback 2: status em memória
with ARCH_LOCK:
job = ARCH_JOBS.get(job_id)
if job:
return jsonify({"status": job.get("status", "PROCESSING")})
return jsonify({"status": "NOT_FOUND"}), 404
@architecture_bp.route("/architecture/<job_id>/logs", methods=["GET"])
@requires_app_auth
def architecture_logs(job_id):
with ARCH_LOCK:
job = ARCH_JOBS.get(job_id, {})
return jsonify({"logs": job.get("logs", [])})
@architecture_bp.route("/architecture/<job_id>/result", methods=["GET"])
@requires_app_auth
def architecture_result(job_id):
job_dir = ARCH_FOLDER / job_id
result_file = job_dir / "architecture.json"
# ainda não terminou
if not result_file.exists():
return jsonify({"error": "not ready"}), 404
try:
raw = result_file.read_text(encoding="utf-8")
plan = json.loads(raw)
return jsonify(plan)
except Exception as e:
return jsonify({"error": str(e)}), 500

View File

@@ -0,0 +1,56 @@
import threading
import json
from pathlib import Path
from .store import ARCH_JOBS, ARCH_LOCK
from oci_genai_llm_graphrag_rerank_rfp import call_architecture_planner, architecture_to_mermaid
ARCH_FOLDER = Path("architecture")
ARCH_FOLDER.mkdir(exist_ok=True)
def make_job_logger(job_id: str):
def _log(msg):
with ARCH_LOCK:
job = ARCH_JOBS.get(job_id)
if job:
job["logs"].append(str(msg))
return _log
def start_architecture_job(job_id: str, question: str):
job_dir = ARCH_FOLDER / job_id
job_dir.mkdir(parents=True, exist_ok=True)
status_file = job_dir / "status.json"
result_file = job_dir / "architecture.json"
def write_status(state: str, detail: str | None = None):
payload = {"status": state}
if detail:
payload["detail"] = detail
status_file.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
with ARCH_LOCK:
if job_id in ARCH_JOBS:
ARCH_JOBS[job_id]["status"] = state
if detail:
ARCH_JOBS[job_id]["detail"] = detail
write_status("PROCESSING")
def background():
try:
logger = make_job_logger(job_id)
plan = call_architecture_planner(question, log=logger)
if not isinstance(plan, dict):
raise TypeError(f"Planner returned {type(plan)}")
plan["mermaid"] = architecture_to_mermaid(plan)
result_file.write_text(json.dumps(plan, ensure_ascii=False, indent=2), encoding="utf-8")
write_status("DONE")
except Exception as e:
write_status("ERROR", str(e))
threading.Thread(target=background, daemon=True).start()

View File

@@ -0,0 +1,4 @@
from threading import Lock
ARCH_LOCK = Lock()
ARCH_JOBS = {}

View File

@@ -0,0 +1,64 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, session
from modules.users.service import signup_user
from config_loader import load_config
from modules.users.service import create_user, authenticate_user
auth_bp = Blueprint(
"auth",
__name__,
template_folder="../../templates/users"
)
config = load_config()
@auth_bp.route("/signup", methods=["GET", "POST"])
def signup():
if request.method == "POST":
email = request.form.get("email")
name = request.form.get("name")
try:
link = signup_user(email, name)
if link and config.dev_mode == 1:
flash(f"DEV MODE: password link → {link}", "success")
else:
flash("User created and email sent", "success")
except Exception as e:
flash(str(e), "danger")
return redirect(url_for("auth.signup"))
return render_template("users/signup.html")
@auth_bp.route("/register", methods=["POST"])
def register():
data = request.json
create_user(data["username"], data["password"])
return jsonify({"status": "ok"})
@auth_bp.route("/login", methods=["POST"])
def login():
email = request.form.get("username")
password = request.form.get("password")
ok = authenticate_user(email, password)
if not ok:
flash("Invalid credentials")
return redirect("/login")
session["user_email"] = email
return redirect("/")
@auth_bp.route("/login", methods=["GET"])
def login_page():
return render_template("users/login.html")
@auth_bp.route("/logout")
def logout():
session.clear() # remove tudo da sessão
return redirect("/login")

View File

@@ -0,0 +1,82 @@
import json
from flask import Blueprint, request, jsonify
from modules.core.security import requires_app_auth
from oci_genai_llm_graphrag_rerank_rfp import answer_question, search_active_chunks
from modules.core.audit import audit_log
from .service import start_chat_job
from .store import CHAT_JOBS, CHAT_LOCK
chat_bp = Blueprint("chat", __name__)
def parse_llm_json(raw: str) -> dict:
try:
if not isinstance(raw, str):
return {"answer": "ERROR", "justification": "LLM output is not a string", "raw_output": str(raw)}
raw = raw.replace("```json", "").replace("```", "").strip()
return json.loads(raw)
except Exception:
return {"answer": "ERROR", "justification": "LLM returned invalid JSON", "raw_output": raw}
@chat_bp.route("/chat", methods=["POST"])
@requires_app_auth
def chat():
data = request.get_json(force=True) or {}
question = (data.get("question") or "").strip()
if not question:
return jsonify({"error": "Empty question"}), 400
raw_answer = answer_question(question)
parsed_answer = parse_llm_json(raw_answer)
audit_log("RFP_QUESTION", f"question={question}")
# (opcional) manter comportamento antigo de evidence/full_text se você quiser
# docs = search_active_chunks(question)
return jsonify({
"question": question,
"result": parsed_answer
})
@chat_bp.post("/chat/start")
def start():
question = request.json["question"]
job_id = start_chat_job(question)
return jsonify({"job_id": job_id})
@chat_bp.get("/chat/<job_id>/status")
def status(job_id):
with CHAT_LOCK:
job = CHAT_JOBS.get(job_id)
if not job:
return jsonify({"error": "not found"}), 404
return jsonify({"status": job["status"]})
@chat_bp.get("/chat/<job_id>/result")
def result(job_id):
with CHAT_LOCK:
job = CHAT_JOBS.get(job_id)
if not job:
return jsonify({"error": "not found"}), 404
return jsonify({
"result": parse_llm_json(job["result"]),
"error": job["error"]
})
@chat_bp.get("/chat/<job_id>/logs")
def logs(job_id):
with CHAT_LOCK:
job = CHAT_JOBS.get(job_id)
return jsonify({"logs": job["logs"]})

View File

@@ -0,0 +1,44 @@
import threading
import uuid
from .store import CHAT_JOBS, CHAT_LOCK
from oci_genai_llm_graphrag_rerank_rfp import answer_question
def start_chat_job(question: str):
job_id = str(uuid.uuid4())
with CHAT_LOCK:
CHAT_JOBS[job_id] = {
"status": "PROCESSING",
"result": None,
"error": None,
"logs": []
}
def log(msg):
with CHAT_LOCK:
CHAT_JOBS[job_id]["logs"].append(str(msg))
def background():
try:
log("Starting answer_question()")
result = answer_question(question)
with CHAT_LOCK:
CHAT_JOBS[job_id]["result"] = result
CHAT_JOBS[job_id]["status"] = "DONE"
log("DONE")
except Exception as e:
with CHAT_LOCK:
CHAT_JOBS[job_id]["error"] = str(e)
CHAT_JOBS[job_id]["status"] = "ERROR"
log(f"ERROR: {e}")
threading.Thread(target=background, daemon=True).start()
return job_id

View File

@@ -0,0 +1,4 @@
import threading
CHAT_JOBS = {}
CHAT_LOCK = threading.Lock()

View File

@@ -0,0 +1,11 @@
from flask import session, request
from datetime import datetime
def audit_log(action: str, detail: str = ""):
email = session.get("user_email", "anonymous")
ip = request.remote_addr
line = f"{datetime.utcnow().isoformat()} | {email} | {ip} | {action} | {detail}\n"
with open("audit.log", "a", encoding="utf-8") as f:
f.write(line)

View File

@@ -0,0 +1,92 @@
from functools import wraps
from flask import request, Response, url_for, session, redirect
from werkzeug.security import check_password_hash
from modules.core.audit import audit_log
from modules.users.db import get_pool
# =========================
# Base authentication
# =========================
def authenticate():
return redirect(url_for("auth.login_page"))
def get_current_user():
email = session.get("user_email")
if not email:
return None
sql = """
SELECT id, username, email, user_role, active
FROM app_users
WHERE email = :1 \
"""
pool = get_pool()
with pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute(sql, [email])
row = cur.fetchone()
if not row:
return None
return {
"id": row[0],
"username": row[1],
"email": row[2],
"role": row[3],
"active": row[4]
}
# =========================
# Decorators
# =========================
def requires_login(f):
@wraps(f)
def wrapper(*args, **kwargs):
user = get_current_user()
if not user:
return authenticate()
return f(*args, **kwargs)
return wrapper
def requires_app_auth(f):
@wraps(f)
def wrapper(*args, **kwargs):
user = get_current_user()
if not user:
return authenticate()
role = (user.get("role") or "").strip().lower()
if role not in ["user", "admin"]:
return authenticate()
audit_log("LOGIN_SUCCESS", f"user={user}")
return f(*args, **kwargs)
return wrapper
def requires_admin_auth(f):
@wraps(f)
def wrapper(*args, **kwargs):
user = get_current_user()
if not user:
return authenticate()
if user.get("role") != "admin":
return authenticate()
audit_log("LOGIN_ADMIN_SUCCESS", f"user={user}")
return f(*args, **kwargs)
return wrapper

View File

@@ -0,0 +1,113 @@
from queue import Queue
import threading
import logging
logger = logging.getLogger(__name__)
# =========================================
# CONFIG
# =========================================
MAX_CONCURRENT_EXCEL = 10
# =========================================
# STATE
# =========================================
EXCEL_QUEUE = Queue()
EXCEL_LOCK = threading.Lock()
ACTIVE_JOBS = set() # jobs em execução
# =========================================
# Helpers
# =========================================
def get_queue_position(job_id: str) -> int:
"""
Retorna:
0 = já está executando
1..N = posição na fila
-1 = não encontrado
"""
with EXCEL_LOCK:
if job_id in ACTIVE_JOBS:
return 0
items = list(EXCEL_QUEUE.queue)
for i, item in enumerate(items):
if item["job_id"] == job_id:
return i + 1
return -1
# =========================================
# WORKER
# =========================================
def _worker(worker_id: int):
logger.info(f"🟢 Excel worker-{worker_id} started")
while True:
job = EXCEL_QUEUE.get()
job_id = job["job_id"]
try:
with EXCEL_LOCK:
ACTIVE_JOBS.add(job_id)
logger.info(f"🚀 [worker-{worker_id}] Processing {job_id}")
job["fn"](*job["args"], **job["kwargs"])
logger.info(f"✅ [worker-{worker_id}] Finished {job_id}")
except Exception as e:
logger.exception(f"❌ [worker-{worker_id}] Failed {job_id}: {e}")
finally:
with EXCEL_LOCK:
ACTIVE_JOBS.discard(job_id)
EXCEL_QUEUE.task_done()
# =========================================
# START POOL
# =========================================
def start_excel_worker():
"""
Inicia pool com N workers simultâneos
"""
for i in range(MAX_CONCURRENT_EXCEL):
threading.Thread(
target=_worker,
args=(i + 1,),
daemon=True
).start()
logger.info(f"🔥 Excel queue started with {MAX_CONCURRENT_EXCEL} workers")
# =========================================
# ENQUEUE
# =========================================
def enqueue_excel_job(job_id, fn, *args, **kwargs):
job = {
"job_id": job_id,
"fn": fn,
"args": args,
"kwargs": kwargs
}
with EXCEL_LOCK:
EXCEL_QUEUE.put(job)
position = EXCEL_QUEUE.qsize()
return position

View File

@@ -0,0 +1,110 @@
from flask import Blueprint, request, jsonify, send_file, render_template
from pathlib import Path
import uuid
import json
from config_loader import load_config
from modules.core.audit import audit_log
from modules.core.security import get_current_user
from modules.core.security import requires_app_auth
from .service import start_excel_job
from .store import EXCEL_JOBS, EXCEL_LOCK
excel_bp = Blueprint("excel", __name__)
config = load_config()
API_BASE_URL = f"{config.app_base}:{config.service_port}"
UPLOAD_FOLDER = Path("./uploads")
UPLOAD_FOLDER.mkdir(exist_ok=True)
ALLOWED_EXTENSIONS = {"xlsx"}
API_URL = API_BASE_URL + "/chat"
def allowed_file(filename):
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
# =========================
# Upload + start processing
# =========================
@excel_bp.route("/upload/excel", methods=["POST"])
@requires_app_auth
def upload_excel():
file = request.files.get("file")
email = request.form.get("email")
if not file or not email:
return jsonify({"error": "file and email required"}), 400
if not allowed_file(file.filename):
return jsonify({"error": "invalid file type"}), 400
job_id = str(uuid.uuid4())
audit_log("UPLOAD_EXCEL", f"job_id={job_id}")
job_dir = UPLOAD_FOLDER / job_id
job_dir.mkdir(parents=True, exist_ok=True)
input_path = job_dir / "input.xlsx"
file.save(input_path)
with EXCEL_LOCK:
EXCEL_JOBS[job_id] = {"status": "RUNNING"}
user = get_current_user()
start_excel_job(
job_id=job_id,
input_path=input_path,
email=email,
auth_user=None,
auth_pass=None,
api_url=API_URL
)
return jsonify({"status": "STARTED", "job_id": job_id})
# =========================
# Status
# =========================
@excel_bp.route("/job/<job_id>/status")
@requires_app_auth
def job_status(job_id):
status_file = UPLOAD_FOLDER / job_id / "status.json"
if not status_file.exists():
return jsonify({"status": "PROCESSING"})
return jsonify(json.loads(status_file.read_text()))
# =========================
# Download result
# =========================
@excel_bp.route("/download/<job_id>")
@requires_app_auth
def download(job_id):
result_file = UPLOAD_FOLDER / job_id / "result.xlsx"
if not result_file.exists():
return jsonify({"error": "not ready"}), 404
return send_file(
result_file,
as_attachment=True,
download_name=f"RFP_result_{job_id}.xlsx"
)
@excel_bp.route("/job/<job_id>/logs", methods=["GET"])
@requires_app_auth
def excel_logs(job_id):
with EXCEL_LOCK:
job = EXCEL_JOBS.get(job_id, {})
return jsonify({"logs": job.get("logs", [])})
@excel_bp.route("/excel/job/<job_id>")
@requires_app_auth
def job_page(job_id):
return render_template("excel/job_status.html", job_id=job_id)

View File

@@ -0,0 +1,115 @@
import threading
import json
from pathlib import Path
from datetime import datetime
from flask import jsonify
from .storage import upload_file, generate_download_url
from rfp_process import process_excel_rfp
from .store import EXCEL_JOBS, EXCEL_LOCK
from modules.users.email_service import send_completion_email
from modules.excel.queue_manager import enqueue_excel_job
EXECUTION_METHOD = "QUEUE" # THREAD OR QUEUE
UPLOAD_FOLDER = Path("uploads")
UPLOAD_FOLDER.mkdir(exist_ok=True)
def make_job_logger(job_id: str):
"""Logger simples: guarda logs na memória (igual ao arquiteto)."""
def _log(msg):
with EXCEL_LOCK:
job = EXCEL_JOBS.get(job_id)
if job is not None:
job.setdefault("logs", []).append(str(msg))
return _log
def start_excel_job(job_id: str, input_path: Path, email: str, auth_user: str, auth_pass: str, api_url: str):
job_dir = UPLOAD_FOLDER / job_id
job_dir.mkdir(parents=True, exist_ok=True)
output_path = job_dir / "result.xlsx"
status_file = job_dir / "status.json"
object_name = f"{job_id}/result.xlsx"
logger = make_job_logger(job_id)
def write_status(state: str, detail: str | None = None):
payload = {
"status": state,
"updated_at": datetime.utcnow().isoformat(),
}
if detail:
payload["detail"] = detail
status_file.write_text(
json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8"
)
with EXCEL_LOCK:
job = EXCEL_JOBS.get(job_id)
if job is not None:
job["status"] = state
if detail:
job["detail"] = detail
# garante estrutura do job na memória
with EXCEL_LOCK:
EXCEL_JOBS.setdefault(job_id, {})
EXCEL_JOBS[job_id].setdefault("logs", [])
EXCEL_JOBS[job_id]["status"] = "PROCESSING"
write_status("PROCESSING")
logger(f"Starting Excel job {job_id}")
logger(f"Input: {input_path}")
logger(f"Output: {output_path}")
def background():
try:
# processamento principal
process_excel_rfp(
input_excel=input_path,
output_excel=output_path,
api_url=api_url,
auth_user=auth_user,
auth_pass=auth_pass,
)
write_status("DONE")
logger("Excel processing DONE")
upload_file(output_path, object_name)
download_url = generate_download_url(object_name)
write_status("DONE", download_url)
# email / dev message
dev_message = send_completion_email(email, download_url, job_id)
if dev_message:
logger(f"DEV email message/link: {dev_message}")
except Exception as e:
logger(f"ERROR: {e}")
write_status("ERROR", str(e))
try:
dev_message = send_completion_email(
email=email,
download_url=download_url,
job_id=job_id,
status="ERROR",
detail=str(e)
)
if dev_message:
logger(f"DEV email error message/link: {dev_message}")
except Exception as mail_err:
logger(f"EMAIL ERROR: {mail_err}")
if EXECUTION_METHOD == "THREAD":
threading.Thread(target=background, daemon=True).start()
else:
enqueue_excel_job(job_id, background)

View File

@@ -0,0 +1,67 @@
import oci
from datetime import datetime, timedelta
from config_loader import load_config
from oci.object_storage.models import CreatePreauthenticatedRequestDetails
config = load_config()
oci_config = oci.config.from_file(
file_location="~/.oci/config",
profile_name=config.bucket_profile
)
object_storage = oci.object_storage.ObjectStorageClient(oci_config)
def _namespace():
if config.oci_namespace != "auto":
return config.oci_namespace
return object_storage.get_namespace().data
# =========================
# Upload file
# =========================
def upload_file(local_path: str, object_name: str):
with open(local_path, "rb") as f:
object_storage.put_object(
namespace_name=_namespace(),
bucket_name=config.oci_bucket,
object_name=object_name,
put_object_body=f
)
print(f"SUCCESS on Upload {object_name}")
# =========================
# Pre-authenticated download URL
# =========================
def generate_download_url(object_name: str, hours=24):
expire = datetime.utcnow() + timedelta(hours=hours)
details = CreatePreauthenticatedRequestDetails(
name=f"job-{object_name}",
access_type="ObjectRead",
object_name=object_name,
time_expires=expire
)
response = object_storage.create_preauthenticated_request(
namespace_name=_namespace(),
bucket_name=config.oci_bucket,
create_preauthenticated_request_details=details
)
par = response.data
download_link = (
f"https://objectstorage.{oci_config['region']}.oraclecloud.com{par.access_uri}"
)
print("PAR CREATED OK")
print(download_link)
return download_link

View File

@@ -0,0 +1,4 @@
from threading import Lock
EXCEL_JOBS = {}
EXCEL_LOCK = Lock()

View File

@@ -0,0 +1,8 @@
from flask import Blueprint, jsonify
health_bp = Blueprint("health", __name__)
@health_bp.route("/health")
def health():
return jsonify({"status": "UP"})

View File

@@ -0,0 +1,16 @@
from flask import Blueprint, render_template
from modules.core.security import requires_app_auth
from config_loader import load_config
home_bp = Blueprint("home", __name__)
config = load_config()
API_BASE_URL = f"{config.app_base}:{config.service_port}"
@home_bp.route("/")
@requires_app_auth
def index():
return render_template(
"index.html",
api_base_url=API_BASE_URL,
config=config
)

View File

@@ -0,0 +1,29 @@
from flask import Blueprint, request, jsonify
from modules.rest.security import rest_auth_required
from modules.chat.service import answer_question # reutiliza lógica
rest_bp = Blueprint("rest", __name__, url_prefix="/rest")
import json
@rest_bp.route("/chat", methods=["POST"])
@rest_auth_required
def rest_chat():
data = request.get_json(force=True) or {}
question = (data.get("question") or "").strip()
if not question:
return jsonify({"error": "question required"}), 400
raw_result = answer_question(question)
try:
parsed = json.loads(raw_result)
except Exception:
return jsonify({
"error": "invalid LLM response",
"raw": raw_result
}), 500
return json.dumps(parsed)

View File

@@ -0,0 +1,30 @@
import base64
from flask import request, jsonify
from functools import wraps
from modules.users.service import authenticate_user
def rest_auth_required(f):
@wraps(f)
def wrapper(*args, **kwargs):
auth = request.headers.get("Authorization")
if not auth or not auth.startswith("Basic "):
return jsonify({"error": "authorization required"}), 401
try:
decoded = base64.b64decode(auth.split(" ")[1]).decode()
username, password = decoded.split(":", 1)
except Exception:
return jsonify({"error": "invalid authorization header"}), 401
user = authenticate_user(username, password)
if not user:
return jsonify({"error": "invalid credentials"}), 401
# opcional: passar user adiante
request.rest_user = user
return f(*args, **kwargs)
return wrapper

View File

@@ -0,0 +1,4 @@
from .routes import users_bp
from .model import db
__all__ = ["users_bp", "db"]

50
files/modules/users/db.py Normal file
View File

@@ -0,0 +1,50 @@
from pathlib import Path
import os
import re
import oracledb
import json
import base64
import hashlib
from datetime import datetime
import requests
import textwrap
import unicodedata
from typing import Optional
from collections import deque
from config_loader import load_config
def chunk_hash(text: str) -> str:
return hashlib.sha256(text.encode("utf-8")).hexdigest()
config = load_config()
# =========================
# Oracle Autonomous Configuration
# =========================
WALLET_PATH = config.wallet_path
DB_ALIAS = config.db_alias
USERNAME = config.username
PASSWORD = config.password
os.environ["TNS_ADMIN"] = WALLET_PATH
_pool = None
def get_pool():
global _pool
if _pool:
return _pool
_pool = oracledb.create_pool(
user=USERNAME,
password=PASSWORD,
dsn=DB_ALIAS,
config_dir=WALLET_PATH,
wallet_location=WALLET_PATH,
wallet_password=PASSWORD,
min=2,
max=8,
increment=1
)
return _pool

View File

@@ -0,0 +1,72 @@
import os
import smtplib
from email.message import EmailMessage
from flask import current_app
from config_loader import load_config
config = load_config()
API_BASE_URL = f"{config.app_base}:{config.service_port}"
def send_user_created_email(email, link, name=""):
"""
DEV -> return link only
PROD -> send real email
"""
if config.dev_mode == 1:
return link # 👈 só devolve o link
host = os.getenv("RFP_SMTP_HOST", "localhost")
port = int(os.getenv("RFP_SMTP_PORT", 25))
msg = EmailMessage()
msg["Subject"] = "Your account has been created"
msg["From"] = "noreply@rfp.local"
msg["To"] = email
msg.set_content(f"""
Hello {name or email},
Your account was created.
Set your password here:
{link}
""")
with smtplib.SMTP(host, port) as s:
s.send_message(msg)
return link
def send_completion_email(email, download_url, job_id):
"""
DEV -> return download link
PROD -> send real email
"""
if config.dev_mode == 1:
return download_url # 👈 só devolve o link no DEV
host = os.getenv("RFP_SMTP_HOST", "localhost")
port = int(os.getenv("RFP_SMTP_PORT", 25))
msg = EmailMessage()
msg["Subject"] = "Your RFP processing is complete"
msg["From"] = "noreply@rfp.local"
msg["To"] = email
msg.set_content(f"""
Hello,
Your RFP Excel processing has finished successfully.
Download your file here:
{download_url}
Job ID: {job_id}
""")
with smtplib.SMTP(host, port) as s:
s.send_message(msg)
return None

View File

@@ -0,0 +1,27 @@
from datetime import datetime
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
class User(db.Model):
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
email = db.Column(db.String(160), unique=True, nullable=False, index=True)
role = db.Column(db.String(50), default="app") # app | admin
active = db.Column(db.Boolean, default=True)
password_hash = db.Column(db.String(255))
must_change_password = db.Column(db.Boolean, default=True)
reset_token = db.Column(db.String(255))
reset_expire = db.Column(db.DateTime)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
def __repr__(self):
return f"<User {self.email}>"

View File

@@ -0,0 +1,157 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
from modules.core.security import requires_admin_auth
from .service import (
signup_user,
list_users as svc_list_users,
create_user,
update_user,
delete_user as svc_delete_user,
get_user_by_token,
set_password_service
)
from .token_service import generate_token, expiration, is_expired
from .email_service import send_user_created_email
from config_loader import load_config
users_bp = Blueprint(
"users",
__name__,
template_folder="../../templates/users"
)
config = load_config()
# =========================
# LIST USERS (Oracle)
# =========================
@users_bp.route("/")
@requires_admin_auth
def list_users():
users = svc_list_users()
return render_template("list.html", users=users)
# =========================
# PUBLIC SIGNUP (Oracle)
# =========================
@users_bp.route("/signup", methods=["GET", "POST"])
def signup():
if request.method == "POST":
email = request.form.get("email", "").strip()
name = request.form.get("name", "").strip()
try:
link = signup_user(email=email, name=name)
except Exception as e:
flash(str(e), "danger")
return render_template("users/signup.html")
if link and config.dev_mode == 1:
flash(f"DEV MODE: password link → {link}", "success")
else:
flash("User created and email sent", "success")
return redirect(url_for("users.signup"))
return render_template("users/signup.html")
# =========================
# CREATE USER (Oracle)
# =========================
@users_bp.route("/new", methods=["GET", "POST"])
@requires_admin_auth
def new_user():
if request.method == "POST":
token = generate_token()
create_user(
name=request.form["name"],
email=request.form["email"],
role=request.form["role"],
active="active" in request.form,
token=token
)
link = url_for("users.set_password", token=token, _external=True)
dev_link = send_user_created_email(
request.form["email"],
link,
request.form["name"]
)
flash("User created and email sent", "success")
return redirect(url_for("users.list_users"))
return render_template("form.html", user=None)
# =========================
# EDIT USER (Oracle)
# =========================
@users_bp.route("/edit/<int:user_id>", methods=["GET", "POST"])
@requires_admin_auth
def edit_user(user_id):
if request.method == "POST":
update_user(
user_id=user_id,
name=request.form["name"],
email=request.form["email"],
role=request.form["role"],
active="active" in request.form
)
return redirect(url_for("users.list_users"))
# busca lista inteira e filtra (simples e funciona bem)
users = svc_list_users()
user = next((u for u in users if u["id"] == user_id), None)
return render_template("form.html", user=user)
# =========================
# DELETE USER (Oracle)
# =========================
@users_bp.route("/delete/<int:user_id>")
@requires_admin_auth
def delete_user(user_id):
svc_delete_user(user_id)
return redirect(url_for("users.list_users"))
# =========================
# SET PASSWORD (Oracle)
# =========================
@users_bp.route("/set-password/<token>", methods=["GET", "POST"])
def set_password(token):
user = get_user_by_token(token)
if not user or is_expired(user["expire"]):
return render_template("set_password.html", expired=True)
if request.method == "POST":
pwd = request.form["password"]
pwd2 = request.form["password2"]
if pwd != pwd2:
flash("Passwords do not match")
return render_template("set_password.html", expired=False)
set_password_service(user["id"], pwd)
flash("Password updated successfully")
return redirect("/")
return render_template("set_password.html", expired=False)

View File

@@ -0,0 +1,204 @@
#from .model import db, User
from .token_service import generate_token, expiration
from .email_service import send_user_created_email
from config_loader import load_config
from .db import get_pool
import bcrypt
from werkzeug.security import generate_password_hash, check_password_hash
config = load_config()
def authenticate_user(username: str, password: str):
print("LOGIN TRY:", username, password)
sql = """
SELECT password_hash
FROM app_users
WHERE email = :1 \
"""
pool = get_pool()
with pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute(sql, [username])
row = cur.fetchone()
# print("ROW:", row)
if not row:
# print("USER NOT FOUND")
return False
stored_hash = row[0]
# print("HASH:", stored_hash)
ok = check_password_hash(stored_hash, password)
# print("MATCH:", ok)
return ok
def create_user(username: str, password: str):
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
sql = """
INSERT INTO app_users (username, password_hash)
VALUES (:1, :2) \
"""
pool = get_pool()
with pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute(sql, [username, hashed])
conn.commit()
def _default_name(email: str) -> str:
return (email or "").split("@")[0]
def signup_user(email: str, name: str = ""):
if not email:
raise ValueError("Email required")
email = email.lower().strip()
name = name or email.split("@")[0]
token = generate_token()
pool = get_pool()
sql_check = """
SELECT id
FROM app_users
WHERE email = :1 \
"""
sql_insert = """
INSERT INTO app_users
(name,email,user_role,active,reset_token,reset_expire,must_change_password)
VALUES (:1,:2,'user',1,:3,:4,1) \
"""
sql_update = """
UPDATE app_users
SET reset_token=:1,
reset_expire=:2,
must_change_password=1
WHERE email=:3 \
"""
with pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute(sql_check, [email])
row = cur.fetchone()
if not row:
cur.execute(sql_insert, [name, email, token, expiration()])
else:
cur.execute(sql_update, [token, expiration(), email])
conn.commit()
link = f"{config.app_base}:{config.service_port}/admin/users/set-password/{token}"
dev_link = send_user_created_email(email, link, name)
return dev_link or link
def list_users():
sql = """
SELECT id, name, email, user_role, active
FROM app_users
ORDER BY name \
"""
pool = get_pool()
with pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute(sql)
cols = [c[0].lower() for c in cur.description]
return [dict(zip(cols, r)) for r in cur.fetchall()]
def create_user(name, email, role, active, token):
sql = """
INSERT INTO app_users
(name,email,user_role,active,reset_token,reset_expire,must_change_password)
VALUES (:1,:2,:3,:4,:5,SYSTIMESTAMP + INTERVAL '1' DAY,1) \
"""
pool = get_pool()
with pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute(sql, [name, email, role, active, token])
conn.commit()
def update_user(user_id, name, email, role, active):
sql = """
UPDATE app_users
SET name=:1, email=:2, user_role=:3, active=:4
WHERE id=:5 \
"""
pool = get_pool()
with pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute(sql, [name, email, role, active, user_id])
conn.commit()
def delete_user(user_id):
sql = "DELETE FROM app_users WHERE id=:1"
pool = get_pool()
with pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute(sql, [user_id])
conn.commit()
def get_user_by_token(token):
sql = """
SELECT id, reset_expire
FROM app_users
WHERE reset_token=:1 \
"""
pool = get_pool()
with pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute(sql, [token])
row = cur.fetchone()
if not row:
return None
return {"id": row[0], "expire": row[1]}
def set_password_service(user_id, pwd):
hashed = generate_password_hash(pwd)
sql = """
UPDATE app_users
SET password_hash=:1,
must_change_password=0,
reset_token=NULL,
reset_expire=NULL
WHERE id=:2 \
"""
pool = get_pool()
with pool.acquire() as conn:
with conn.cursor() as cur:
cur.execute(sql, [hashed, user_id])
conn.commit()

View File

@@ -0,0 +1,14 @@
import secrets
from datetime import datetime, timedelta
def generate_token():
return secrets.token_urlsafe(48)
def expiration(hours=24):
return datetime.utcnow() + timedelta(hours=hours)
def is_expired(expire_dt):
return not expire_dt or expire_dt < datetime.utcnow()