mirror of
https://github.com/hoshikawa2/rfp_response_automation.git
synced 2026-03-06 18:21:02 +00:00
first commit
This commit is contained in:
160
files/modules/admin/routes.py
Normal file
160
files/modules/admin/routes.py
Normal 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"
|
||||
})
|
||||
83
files/modules/architecture/routes.py
Normal file
83
files/modules/architecture/routes.py
Normal 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
|
||||
56
files/modules/architecture/service.py
Normal file
56
files/modules/architecture/service.py
Normal 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()
|
||||
4
files/modules/architecture/store.py
Normal file
4
files/modules/architecture/store.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from threading import Lock
|
||||
|
||||
ARCH_LOCK = Lock()
|
||||
ARCH_JOBS = {}
|
||||
64
files/modules/auth/routes.py
Normal file
64
files/modules/auth/routes.py
Normal 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")
|
||||
82
files/modules/chat/routes.py
Normal file
82
files/modules/chat/routes.py
Normal 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"]})
|
||||
|
||||
44
files/modules/chat/service.py
Normal file
44
files/modules/chat/service.py
Normal 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
|
||||
4
files/modules/chat/store.py
Normal file
4
files/modules/chat/store.py
Normal file
@@ -0,0 +1,4 @@
|
||||
import threading
|
||||
|
||||
CHAT_JOBS = {}
|
||||
CHAT_LOCK = threading.Lock()
|
||||
11
files/modules/core/audit.py
Normal file
11
files/modules/core/audit.py
Normal 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)
|
||||
92
files/modules/core/security.py
Normal file
92
files/modules/core/security.py
Normal 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
|
||||
113
files/modules/excel/queue_manager.py
Normal file
113
files/modules/excel/queue_manager.py
Normal 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
|
||||
110
files/modules/excel/routes.py
Normal file
110
files/modules/excel/routes.py
Normal 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)
|
||||
115
files/modules/excel/service.py
Normal file
115
files/modules/excel/service.py
Normal 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)
|
||||
67
files/modules/excel/storage.py
Normal file
67
files/modules/excel/storage.py
Normal 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
|
||||
4
files/modules/excel/store.py
Normal file
4
files/modules/excel/store.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from threading import Lock
|
||||
|
||||
EXCEL_JOBS = {}
|
||||
EXCEL_LOCK = Lock()
|
||||
8
files/modules/health/routes.py
Normal file
8
files/modules/health/routes.py
Normal 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"})
|
||||
16
files/modules/home/routes.py
Normal file
16
files/modules/home/routes.py
Normal 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
|
||||
)
|
||||
29
files/modules/rest/routes.py
Normal file
29
files/modules/rest/routes.py
Normal 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)
|
||||
30
files/modules/rest/security.py
Normal file
30
files/modules/rest/security.py
Normal 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
|
||||
4
files/modules/users/__init__.py
Normal file
4
files/modules/users/__init__.py
Normal 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
50
files/modules/users/db.py
Normal 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
|
||||
72
files/modules/users/email_service.py
Normal file
72
files/modules/users/email_service.py
Normal 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
|
||||
27
files/modules/users/model.py
Normal file
27
files/modules/users/model.py
Normal 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}>"
|
||||
157
files/modules/users/routes.py
Normal file
157
files/modules/users/routes.py
Normal 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)
|
||||
204
files/modules/users/service.py
Normal file
204
files/modules/users/service.py
Normal 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()
|
||||
|
||||
14
files/modules/users/token_service.py
Normal file
14
files/modules/users/token_service.py
Normal 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()
|
||||
Reference in New Issue
Block a user