|
from __future__ import annotations |
|
import os |
|
import logging |
|
import pandas as pd |
|
from flask import Flask, jsonify, request, render_template_string |
|
from unidecode import unidecode |
|
from typing import Dict, List, Optional |
|
|
|
|
|
CSV_PATH = "equiparazioni.csv" |
|
if os.path.exists(CSV_PATH): |
|
MAP = pd.read_csv(CSV_PATH) |
|
else: |
|
MAP = pd.DataFrame([ |
|
{"col_1_dl": "Ingegneria informatica", "col_2_ls": "35/S – Ingegneria informatica", "col_3_lm": "LM-32 – Ingegneria informatica"}, |
|
{"col_1_dl": "Scienze dell’economia", "col_2_ls": "64/S – Scienze dell’economia", "col_3_lm": "LM-56 – Scienze dell’economia"}, |
|
]) |
|
|
|
|
|
def _norm(txt: str | None) -> str: |
|
if txt is None or pd.isna(txt): |
|
return "" |
|
return "".join(c for c in unidecode(str(txt)).lower() if c.isalnum()) |
|
|
|
def find_triplet(title: str) -> Optional[Dict[str, str]]: |
|
key = _norm(title) |
|
if not key: |
|
return None |
|
for _, row in MAP.iterrows(): |
|
if key in {_norm(row.col_1_dl), _norm(row.col_2_ls), _norm(row.col_3_lm)}: |
|
return {k: v if pd.notna(v) else "" for k, v in row.to_dict().items()} |
|
return None |
|
|
|
def satisfies_once(candidate_title: str, required_title: str) -> bool: |
|
cand_triplet = find_triplet(candidate_title) |
|
req_triplet = find_triplet(required_title) |
|
if cand_triplet is None or req_triplet is None: |
|
return False |
|
return cand_triplet == req_triplet |
|
|
|
def satisfies(candidate_title: str, required_titles: List[str]) -> bool: |
|
if not required_titles: |
|
return True |
|
return any(satisfies_once(candidate_title, r) for r in required_titles) |
|
|
|
|
|
app = Flask(__name__) |
|
|
|
@app.route("/titles") |
|
def all_titles(): |
|
titles = ( |
|
MAP["col_1_dl"].dropna().tolist() + |
|
MAP["col_2_ls"].dropna().tolist() + |
|
MAP["col_3_lm"].dropna().tolist() |
|
) |
|
return jsonify(sorted(set(titles))) |
|
|
|
@app.route("/check", methods=["POST"]) |
|
def check(): |
|
data = request.get_json(force=True) |
|
candidate = data.get("candidate", "") |
|
required = data.get("required", []) |
|
approved = satisfies(candidate, required) |
|
mapping = find_triplet(candidate) or {} |
|
return jsonify({"approved": approved, "mapping": mapping}) |
|
|
|
HTML_PAGE = """ |
|
<!doctype html> |
|
<html lang="it"> |
|
<head> |
|
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"> |
|
<title>Validatore Titoli – HR</title> |
|
<style> |
|
:root { --main-bg: #fff; --text-color: #333; --border-color: #ccc; --badge-bg: #eef; --green: #4CAF50; --red: #F44336; } |
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; margin:2em; background:var(--main-bg); color:var(--text-color); line-height:1.6; } |
|
h1,h3 { color:#0056b3; } |
|
.grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(250px,1fr)); gap:24px; } |
|
select,button { width:100%; padding:10px; border-radius:5px; border:1px solid var(--border-color); font-size:1rem; } |
|
select { height:250px; } |
|
button { background:#007bff; color:#fff; cursor:pointer; border:none; margin-top:8px; transition:background .2s; } |
|
button:hover { background:#0056b3; } |
|
#requiredList { min-height:100px; border:1px solid var(--border-color); padding:8px; overflow-y:auto; border-radius:5px; background:#f9f9f9; } |
|
.badge { display:inline-flex; align-items:center; padding:5px 10px; margin:4px; background:var(--badge-bg); border-radius:15px; font-size:.9em; cursor:pointer; } |
|
.badge:hover::after { content:' ❌'; color:var(--red); } |
|
.result { padding:15px; text-align:center; font-weight:bold; color:#fff; border-radius:5px; transition:background .3s; } |
|
.approved { background:var(--green); } |
|
.rejected { background:var(--red); } |
|
#mapping { font-size:.9em; border:1px solid #ddd; padding:12px; border-radius:5px; background:#f9f9f9; } |
|
#mapping strong { color:#0056b3; } |
|
</style> |
|
</head> |
|
<body> |
|
<h1>Validatore Titoli di Studio</h1> |
|
<div class="grid"> |
|
<div> |
|
<h3>1. Titolo del Candidato</h3> |
|
<select id="candidateSelect" size="15"></select> |
|
</div> |
|
<div> |
|
<h3>2. Requisiti del Bando</h3> |
|
<select id="requiredSelect" size="10"></select> |
|
<button id="addBtn">Aggiungi Requisito ➜</button> |
|
</div> |
|
<div> |
|
<h3>Titoli Richiesti (OR)</h3> |
|
<div id="requiredList"></div> |
|
<h3 style="margin-top:20px;">Mapping del Candidato</h3> |
|
<div id="mapping">(selezionare un candidato)</div> |
|
</div> |
|
<div> |
|
<h3>4. Esito</h3> |
|
<div id="resultBox" class="result">...</div> |
|
</div> |
|
</div> |
|
<script> |
|
let allTitles = [], requiredTitles = new Set(); |
|
const candidateSelect = document.getElementById('candidateSelect'), |
|
requiredSelect = document.getElementById('requiredSelect'), |
|
addBtn = document.getElementById('addBtn'), |
|
requiredListDiv = document.getElementById('requiredList'), |
|
resultBox = document.getElementById('resultBox'), |
|
mappingDiv = document.getElementById('mapping'); |
|
|
|
async function populateTitles() { |
|
const r = await fetch('/titles'); |
|
allTitles = await r.json(); |
|
const opts = allTitles.map(t => `<option value="${t}">${t}</option>`).join(''); |
|
candidateSelect.innerHTML = opts; |
|
requiredSelect.innerHTML = opts; |
|
candidateSelect.onchange = runCheck; |
|
addBtn.onclick = () => { |
|
const val = requiredSelect.value; |
|
if (val && !requiredTitles.has(val)) { |
|
requiredTitles.add(val); |
|
renderRequiredList(); |
|
runCheck(); |
|
} |
|
}; |
|
} |
|
|
|
function renderRequiredList() { |
|
requiredListDiv.innerHTML = ''; |
|
requiredTitles.forEach(title => { |
|
const span = document.createElement('span'); |
|
span.textContent = title; |
|
span.className = 'badge'; |
|
span.onclick = () => { |
|
requiredTitles.delete(title); |
|
renderRequiredList(); |
|
runCheck(); |
|
}; |
|
requiredListDiv.appendChild(span); |
|
}); |
|
} |
|
|
|
async function runCheck() { |
|
const cand = candidateSelect.value; |
|
if (!cand) return; |
|
const res = await (await fetch('/check', { |
|
method: 'POST', |
|
headers: {'Content-Type':'application/json'}, |
|
body: JSON.stringify({ candidate: cand, required: Array.from(requiredTitles) }) |
|
})).json(); |
|
|
|
if (res.mapping && Object.keys(res.mapping).length) { |
|
mappingDiv.innerHTML = |
|
`<strong>Vecchio Ord. (DL):</strong> ${res.mapping.col_1_dl || 'N/A'}<br>` + |
|
`<strong>Specialistica (LS):</strong> ${res.mapping.col_2_ls || 'N/A'}<br>` + |
|
`<strong>Magistrale (LM):</strong> ${res.mapping.col_3_lm || 'N/A'}`; |
|
} else { |
|
mappingDiv.textContent = 'Titolo non riconosciuto.'; |
|
} |
|
|
|
if (!requiredTitles.size) { |
|
resultBox.className = 'result'; |
|
resultBox.textContent = 'Aggiungere requisiti'; |
|
} else { |
|
resultBox.className = res.approved ? 'result approved' : 'result rejected'; |
|
resultBox.textContent = res.approved ? 'APPROVATO' : 'NON APPROVATO'; |
|
} |
|
} |
|
|
|
populateTitles(); |
|
</script> |
|
</body> |
|
</html> |
|
""" |
|
|
|
@app.route("/") |
|
def index(): |
|
return render_template_string(HTML_PAGE) |
|
|
|
|
|
if __name__ == '__main__': |
|
app.run(host='0.0.0.0', debug=True) |