268 lines
10 KiB
Python
268 lines
10 KiB
Python
"""
|
||
Jednoduchý HTTP server — generuje HTML report léků z MySQL a servíruje ho na portu 8765.
|
||
"""
|
||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||
import pymysql, pymysql.cursors
|
||
|
||
DB = dict(host="192.168.1.76", user="root", password="Vlado9674+",
|
||
database="medicus", charset="utf8mb4",
|
||
cursorclass=pymysql.cursors.DictCursor)
|
||
|
||
ATC_POPIS = {
|
||
"N06AX": "Jiná antidepresiva",
|
||
"M04AA": "Léky proti dně (urikostatika)",
|
||
"C10BA": "Statiny v kombinaci",
|
||
"C10AA": "Statiny",
|
||
"N05BA": "Benzodiazepinová anxiolytika",
|
||
"R01BA": "Systémová dekongestiva",
|
||
"A02BC": "Inhibitory protonové pumpy",
|
||
"G04BE": "Léky na erektilní dysfunkci",
|
||
"C10AX": "Jiná hypolipidemika (ezetimib)",
|
||
"C09AA": "ACE inhibitory",
|
||
"N05BX": "Jiná anxiolytika",
|
||
"N02AJ": "Opioidy + neopioidy",
|
||
"R01AD": "Nosní kortikosteroidy",
|
||
"R06AX": "Antihistaminika",
|
||
"J01FA": "Makrolidová antibiotika",
|
||
"M03BX": "Centrální myorelaxancia",
|
||
"A10BX": "Inkretiny (GLP-1 agonisté)",
|
||
"R05CB": "Mukolytika",
|
||
"N05CF": "Z-hypnotika",
|
||
"J01CE": "Penicilinová antibiotika",
|
||
}
|
||
|
||
# Klíčová slova v navodu naznačující PRN (dle potřeby)
|
||
PRN_SLOVA = ("dle potřeby", "dle potreby", "p.p.", "d.p.", "pp ", " pp",
|
||
"při bolesti", "pri bolesti", "dle potreb", "podle potřeby",
|
||
"podle potreby", "podle potreby")
|
||
|
||
|
||
def query():
|
||
conn = pymysql.connect(**DB)
|
||
with conn.cursor() as cur:
|
||
cur.execute("""
|
||
SELECT
|
||
LEFT(p.atc, 5) AS atc_skupina,
|
||
MIN(p.nazev) AS nazev_leku,
|
||
COUNT(DISTINCT p.id) AS pocet_predpisu,
|
||
COUNT(DISTINCT v.id) AS pocet_vydani,
|
||
SUM(p.mnozstvi) AS celkove_mnozstvi,
|
||
AVG(p.mnozstvi) AS avg_mnozstvi,
|
||
MIN(p.datum_vystaveni) AS prvni_predpis,
|
||
MAX(p.datum_vystaveni) AS posledni_predpis,
|
||
DATEDIFF(MAX(p.datum_vystaveni),
|
||
MIN(p.datum_vystaveni)) AS rozpeti_dni,
|
||
MAX(p.opakovani) AS opakovani,
|
||
GROUP_CONCAT(p.navod ORDER BY p.datum_vystaveni SEPARATOR '|') AS navody,
|
||
GROUP_CONCAT(DISTINCT vp.odbornost
|
||
ORDER BY vp.odbornost) AS odbornosti,
|
||
GROUP_CONCAT(DISTINCT vp.nazev_pracoviste
|
||
ORDER BY vp.odbornost) AS pracoviste
|
||
FROM predpis p
|
||
LEFT JOIN vydej v
|
||
ON v.id_lp_predpis = p.id_lp_predpis
|
||
LEFT JOIN predepisujici pre
|
||
ON pre.lekar_kod = p.kod_predepisujiciho
|
||
LEFT JOIN vzp_pracoviste vp
|
||
ON vp.icp = pre.icp
|
||
AND vp.id = (
|
||
SELECT id FROM vzp_pracoviste
|
||
WHERE icp = pre.icp
|
||
ORDER BY platnost_od DESC LIMIT 1
|
||
)
|
||
WHERE p.atc IS NOT NULL
|
||
GROUP BY LEFT(p.atc, 5)
|
||
HAVING pocet_predpisu >= 2 OR MAX(p.opakovani) IS NOT NULL
|
||
""")
|
||
rows = cur.fetchall()
|
||
conn.close()
|
||
|
||
# Vypočítej pravidelnost v Pythonu a seřaď
|
||
for r in rows:
|
||
r["pravidelnost"], r["norm_interval"] = vypocitej_pravidelnost(r)
|
||
|
||
rows.sort(key=lambda r: (
|
||
["pravidelna", "mozna", "nepravidelna", "prn"].index(r["pravidelnost"]),
|
||
-(r["pocet_vydani"] or 0)
|
||
))
|
||
return rows
|
||
|
||
|
||
def vypocitej_pravidelnost(r):
|
||
"""Vrací (kategorie, normalizovany_interval_dni)."""
|
||
pocet = r["pocet_predpisu"] or 1
|
||
rozpeti = r["rozpeti_dni"] or 0
|
||
avg_mnoz = float(r["avg_mnozstvi"] or 1)
|
||
|
||
# PRN detekce — pokud většina návodů obsahuje PRN klíčová slova
|
||
navody = (r["navody"] or "").lower()
|
||
navod_list = navody.split("|")
|
||
prn_pocet = sum(1 for n in navod_list if any(s in n for s in PRN_SLOVA))
|
||
if prn_pocet > len(navod_list) / 2:
|
||
return "prn", None
|
||
|
||
if pocet < 2 or rozpeti == 0:
|
||
return "nepravidelna", None
|
||
|
||
avg_interval = rozpeti / (pocet - 1) # dny mezi předpisy
|
||
norm_interval = avg_interval / avg_mnoz # normalizováno na 1 balení
|
||
|
||
if norm_interval <= 40:
|
||
return "pravidelna", round(norm_interval)
|
||
elif norm_interval <= 100:
|
||
return "pravidelna", round(norm_interval)
|
||
elif norm_interval <= 185:
|
||
return "mozna", round(norm_interval)
|
||
else:
|
||
return "nepravidelna", round(norm_interval)
|
||
|
||
|
||
def badge_pravidelnost(pravidelnost, norm_interval):
|
||
if pravidelnost == "prn":
|
||
return '<span class="prav prav-prn">dle potřeby</span>'
|
||
if norm_interval is None:
|
||
return '<span class="prav prav-ne">?</span>'
|
||
if pravidelnost == "pravidelna":
|
||
return f'<span class="prav prav-ano">pravidelný · ~{norm_interval} dní/bal.</span>'
|
||
if pravidelnost == "mozna":
|
||
return f'<span class="prav prav-mozna">možná · ~{norm_interval} dní/bal.</span>'
|
||
return f'<span class="prav prav-ne">epizodický · ~{norm_interval} dní/bal.</span>'
|
||
|
||
|
||
def badge_odbornost(odbornosti, pracoviste):
|
||
if not odbornosti:
|
||
return '<span class="badge unknown">neznámá</span>'
|
||
kody = odbornosti.split(",")
|
||
nazvy = pracoviste.split(",") if pracoviste else []
|
||
parts = []
|
||
for i, kod in enumerate(kody):
|
||
nazev = nazvy[i].strip() if i < len(nazvy) else kod
|
||
cls = "gp" if kod.strip() in ("001", "002") else "spec"
|
||
parts.append(f'<span class="badge {cls}" title="{nazev}">{kod.strip()}</span>')
|
||
return " ".join(parts)
|
||
|
||
|
||
def bar(val, max_val, color):
|
||
pct = min(int(val / max_val * 100), 100)
|
||
return (f'<div class="bar-wrap">'
|
||
f'<div class="bar" style="width:{pct}%;background:{color}"></div>'
|
||
f'<span>{val}</span></div>')
|
||
|
||
|
||
def generate_html(rows):
|
||
max_vydani = max((r["pocet_vydani"] or 0 for r in rows), default=1)
|
||
|
||
radky = []
|
||
for r in rows:
|
||
atc = r["atc_skupina"] or ""
|
||
popis = ATC_POPIS.get(atc, "")
|
||
pr = r["pravidelnost"]
|
||
row_cls = {"pravidelna": "row-ano", "mozna": "row-mozna",
|
||
"prn": "row-prn", "nepravidelna": "row-ne"}.get(pr, "")
|
||
|
||
radky.append(f"""
|
||
<tr class="{row_cls}">
|
||
<td><strong>{atc}</strong><br><small>{popis}</small></td>
|
||
<td>{r['nazev_leku'] or ''}</td>
|
||
<td>{badge_pravidelnost(r['pravidelnost'], r['norm_interval'])}</td>
|
||
<td>{bar(r['pocet_predpisu'], max_vydani, '#4f8ef7')}</td>
|
||
<td>{bar(r['pocet_vydani'], max_vydani, '#34c97a')}</td>
|
||
<td style="font-size:0.82em;white-space:nowrap">
|
||
{r['prvni_predpis']}<br>→ {r['posledni_predpis']}
|
||
</td>
|
||
<td>{badge_odbornost(r['odbornosti'], r['pracoviste'])}</td>
|
||
</tr>""")
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="cs">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Lékový záznam — pravidelná medikace</title>
|
||
<style>
|
||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||
body {{ font-family: system-ui, sans-serif; background: #f4f6fb; color: #222; padding: 24px; }}
|
||
h1 {{ font-size: 1.4rem; margin-bottom: 4px; color: #1a2a4a; }}
|
||
.subtitle {{ color: #666; font-size: 0.9rem; margin-bottom: 20px; }}
|
||
table {{ width: 100%; border-collapse: collapse; background: #fff;
|
||
border-radius: 10px; overflow: hidden;
|
||
box-shadow: 0 2px 12px rgba(0,0,0,0.08); }}
|
||
th {{ background: #1a2a4a; color: #fff; padding: 10px 12px;
|
||
text-align: left; font-size: 0.82rem; white-space: nowrap; }}
|
||
td {{ padding: 9px 12px; font-size: 0.88rem; border-bottom: 1px solid #eef0f5;
|
||
vertical-align: middle; }}
|
||
tr:last-child td {{ border-bottom: none; }}
|
||
tr.row-ano td {{ background: #f0fdf4; }}
|
||
tr.row-mozna td {{ background: #fffbeb; }}
|
||
tr.row-ne td {{ background: #fafafa; }}
|
||
tr.row-prn td {{ background: #fef2f2; }}
|
||
tr:hover td {{ filter: brightness(0.96); }}
|
||
.bar-wrap {{ display: flex; align-items: center; gap: 6px; }}
|
||
.bar {{ height: 8px; border-radius: 4px; min-width: 2px; }}
|
||
.bar-wrap span {{ font-size: 0.82rem; color: #444; min-width: 20px; }}
|
||
.badge {{ display: inline-block; padding: 2px 7px; border-radius: 10px;
|
||
font-size: 0.75rem; font-weight: 600; cursor: default; }}
|
||
.badge.gp {{ background: #d1fae5; color: #065f46; }}
|
||
.badge.spec {{ background: #dbeafe; color: #1e40af; }}
|
||
.badge.unknown {{ background: #f3f4f6; color: #9ca3af; }}
|
||
.prav {{ display: inline-block; padding: 3px 9px; border-radius: 12px;
|
||
font-size: 0.78rem; font-weight: 600; white-space: nowrap; }}
|
||
.prav-ano {{ background: #dcfce7; color: #166534; }}
|
||
.prav-mozna {{ background: #fef9c3; color: #854d0e; }}
|
||
.prav-ne {{ background: #f3f4f6; color: #6b7280; }}
|
||
.prav-prn {{ background: #fee2e2; color: #991b1b; }}
|
||
.legend {{ margin-top: 16px; font-size: 0.8rem; color: #555;
|
||
display: flex; gap: 20px; flex-wrap: wrap; }}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>Lékový záznam — pravidelná medikace</h1>
|
||
<p class="subtitle">
|
||
Skupiny ATC (5. místo) · seřazeno dle pravidelnosti ·
|
||
normalizovaný interval = průměrný počet dní na 1 balení
|
||
</p>
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>ATC skupina</th>
|
||
<th>Lék (příklad)</th>
|
||
<th>Pravidelnost</th>
|
||
<th>Předpisů</th>
|
||
<th>Vydání</th>
|
||
<th>Rozsah</th>
|
||
<th>Odbornost</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{''.join(radky)}
|
||
</tbody>
|
||
</table>
|
||
<div class="legend">
|
||
<span><span class="prav prav-ano">pravidelný</span> norm. interval ≤ 100 dní/bal.</span>
|
||
<span><span class="prav prav-mozna">možná</span> 100–185 dní/bal.</span>
|
||
<span><span class="prav prav-ne">epizodický</span> >185 dní/bal.</span>
|
||
<span><span class="prav prav-prn">dle potřeby</span> navod obsahuje PRN</span>
|
||
<span><span class="badge gp">001</span> Praktický lékař
|
||
<span class="badge spec">xxx</span> Specialista</span>
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
def do_GET(self):
|
||
html = generate_html(query()).encode("utf-8")
|
||
self.send_response(200)
|
||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||
self.send_header("Content-Length", len(html))
|
||
self.end_headers()
|
||
self.wfile.write(html)
|
||
|
||
def log_message(self, fmt, *args):
|
||
pass
|
||
|
||
|
||
if __name__ == "__main__":
|
||
server = HTTPServer(("localhost", 8765), Handler)
|
||
print("Report server bezi na http://localhost:8765")
|
||
server.serve_forever()
|