first commit

This commit is contained in:
hubaceks
2026-04-10 22:42:48 +02:00
commit bcf628a17b
3 changed files with 564 additions and 0 deletions

546
app.py Normal file
View File

@@ -0,0 +1,546 @@
from flask import Flask, request, redirect, url_for, render_template_string, flash, Response
import pymysql
from pymysql.cursors import DictCursor
import os
import ipaddress
import json
app = Flask(__name__)
app.secret_key = os.environ.get("SECRET_KEY", "zmenit-na-vlastni-tajne-heslo")
DB_HOST = os.environ.get("DB_HOST", "localhost")
DB_PORT = int(os.environ.get("DB_PORT", "3306"))
DB_USER = os.environ.get("DB_USER", "ipam_user")
DB_PASSWORD = os.environ.get("DB_PASSWORD", "")
DB_NAME = os.environ.get("DB_NAME", "ipam")
def get_db_connection():
return pymysql.connect(
host=DB_HOST,
port=DB_PORT,
user=DB_USER,
password=DB_PASSWORD,
database=DB_NAME,
cursorclass=DictCursor,
autocommit=True,
charset="utf8mb4"
)
def validate_ipv4(ip_text: str) -> bool:
try:
ipaddress.IPv4Address(ip_text)
return True
except Exception:
return False
INDEX_TEMPLATE = """
<!doctype html>
<html lang="cs">
<head>
<meta charset="utf-8">
<title>IPplan</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {
font-family: Arial, sans-serif;
margin: 30px;
background: #f7f7f7;
color: #222;
}
h1 {
margin-bottom: 12px;
}
.container {
max-width: 1200px;
margin: auto;
background: white;
padding: 24px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
}
table {
border-collapse: collapse;
width: 100%;
background: white;
}
th, td {
border-bottom: 1px solid #ddd;
padding: 10px 12px;
text-align: left;
vertical-align: middle;
}
th {
background: #f0f0f0;
}
tr:hover {
background: #fafafa;
}
.btn {
display: inline-block;
padding: 8px 12px;
text-decoration: none;
border-radius: 6px;
background: #2563eb;
color: white;
border: none;
cursor: pointer;
font-size: 14px;
line-height: 1.2;
}
.btn:hover {
opacity: 0.95;
}
.btn-secondary {
background: #666;
}
.btn-export {
background: #7c3aed;
}
.btn-danger {
background: #dc2626;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: #0f766e;
color: white;
cursor: pointer;
font-size: 15px;
padding: 0;
}
.icon-btn:hover {
opacity: 0.95;
}
.flash {
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 15px;
}
.flash.success {
background: #dcfce7;
color: #166534;
}
.flash.error {
background: #fee2e2;
color: #991b1b;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 18px;
gap: 12px;
flex-wrap: wrap;
}
.topbar-right {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.muted {
color: #666;
font-size: 14px;
}
input[type=text] {
width: 100%;
padding: 8px 10px;
border: 1px solid #ccc;
border-radius: 6px;
box-sizing: border-box;
}
.form-card {
max-width: 700px;
}
.form-row {
margin-bottom: 14px;
}
label {
display: block;
margin-bottom: 6px;
font-weight: bold;
}
.actions {
white-space: nowrap;
display: flex;
gap: 8px;
align-items: center;
}
.search-form {
margin-bottom: 18px;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
}
.search-input {
flex: 1;
min-width: 260px;
}
.ip-cell {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.result-info {
margin-bottom: 12px;
color: #555;
font-size: 14px;
}
.empty-state {
padding: 24px 0;
color: #666;
}
.inline-form {
display: inline;
margin: 0;
}
</style>
<script>
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
} catch (err) {
prompt("Kopírování se nepovedlo automaticky, zkopíruj ručně:", text);
}
}
function confirmDelete(ip) {
return confirm("Opravdu smazat záznam pro IP " + ip + "?");
}
</script>
</head>
<body>
<div class="container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if mode == 'list' %}
<div class="topbar">
<div>
<h1>IPplan</h1>
<div class="muted">Řazeno podle IP adresy</div>
</div>
<div class="topbar-right">
<a class="btn btn-export" href="{{ url_for('export_json', q=query or '') }}">Export JSON</a>
<a class="btn" href="{{ url_for('add_item') }}">+ Přidat IP</a>
</div>
</div>
<form method="get" action="{{ url_for('index') }}" class="search-form">
<input
class="search-input"
type="text"
name="q"
placeholder="Hledat v IP, FQDN nebo účelu"
value="{{ query or '' }}"
>
<button class="btn" type="submit">Hledat</button>
{% if query %}
<a class="btn btn-secondary" href="{{ url_for('index') }}">Zrušit filtr</a>
{% endif %}
</form>
<div class="result-info">
{% if query %}
Nalezeno záznamů: <strong>{{ rows|length }}</strong> pro výraz <strong>{{ query }}</strong>
{% else %}
Celkem záznamů: <strong>{{ rows|length }}</strong>
{% endif %}
</div>
{% if rows %}
<table>
<thead>
<tr>
<th>IP adresa</th>
<th>FQDN</th>
<th>Účel</th>
<th>Akce</th>
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td>
<div class="ip-cell">
<span>{{ row.ipaddress }}</span>
<button
class="icon-btn"
type="button"
title="Kopírovat IP adresu"
aria-label="Kopírovat IP adresu"
onclick="copyToClipboard('{{ row.ipaddress }}')"
>⧉</button>
</div>
</td>
<td>{{ row.fqdn or '' }}</td>
<td>{{ row.purpose or '' }}</td>
<td class="actions">
<a class="btn" href="{{ url_for('edit_item', item_id=row.id) }}">Upravit</a>
<form
method="post"
action="{{ url_for('delete_item', item_id=row.id) }}"
class="inline-form"
onsubmit="return confirmDelete('{{ row.ipaddress }}')"
>
<button class="btn btn-danger" type="submit">Smazat</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-state">Žádné záznamy nebyly nalezeny.</div>
{% endif %}
{% elif mode == 'edit' %}
<div class="form-card">
<h1>Upravit záznam</h1>
<form method="post">
<div class="form-row">
<label for="ipaddress">IP adresa</label>
<input type="text" id="ipaddress" name="ipaddress" value="{{ item.ipaddress }}" required>
</div>
<div class="form-row">
<label for="fqdn">FQDN</label>
<input type="text" id="fqdn" name="fqdn" value="{{ item.fqdn or '' }}">
</div>
<div class="form-row">
<label for="purpose">Účel</label>
<input type="text" id="purpose" name="purpose" value="{{ item.purpose or '' }}">
</div>
<button class="btn" type="submit">Uložit</button>
<a class="btn btn-secondary" href="{{ url_for('index') }}">Zpět</a>
</form>
</div>
{% elif mode == 'add' %}
<div class="form-card">
<h1>Přidat záznam</h1>
<form method="post">
<div class="form-row">
<label for="ipaddress">IP adresa</label>
<input type="text" id="ipaddress" name="ipaddress" value="{{ form_data.ipaddress or '' }}" required>
</div>
<div class="form-row">
<label for="fqdn">FQDN</label>
<input type="text" id="fqdn" name="fqdn" value="{{ form_data.fqdn or '' }}">
</div>
<div class="form-row">
<label for="purpose">Účel</label>
<input type="text" id="purpose" name="purpose" value="{{ form_data.purpose or '' }}">
</div>
<button class="btn" type="submit">Uložit</button>
<a class="btn btn-secondary" href="{{ url_for('index') }}">Zpět</a>
</form>
</div>
{% endif %}
</div>
</body>
</html>
"""
@app.route("/")
def index():
query = request.args.get("q", "").strip()
conn = get_db_connection()
try:
with conn.cursor() as cur:
if query:
like_value = f"%{query}%"
cur.execute("""
SELECT id, ipaddress, fqdn, purpose
FROM iplist
WHERE ipaddress LIKE %s
OR fqdn LIKE %s
OR purpose LIKE %s
ORDER BY INET_ATON(ipaddress) ASC
""", (like_value, like_value, like_value))
else:
cur.execute("""
SELECT id, ipaddress, fqdn, purpose
FROM iplist
ORDER BY INET_ATON(ipaddress) ASC
""")
rows = cur.fetchall()
finally:
conn.close()
return render_template_string(
INDEX_TEMPLATE,
mode="list",
rows=rows,
query=query
)
@app.route("/export/json")
def export_json():
query = request.args.get("q", "").strip()
conn = get_db_connection()
try:
with conn.cursor() as cur:
if query:
like_value = f"%{query}%"
cur.execute("""
SELECT ipaddress, fqdn, purpose
FROM iplist
WHERE ipaddress LIKE %s
OR fqdn LIKE %s
OR purpose LIKE %s
ORDER BY INET_ATON(ipaddress) ASC
""", (like_value, like_value, like_value))
else:
cur.execute("""
SELECT ipaddress, fqdn, purpose
FROM iplist
ORDER BY INET_ATON(ipaddress) ASC
""")
rows = cur.fetchall()
finally:
conn.close()
json_data = json.dumps(rows, ensure_ascii=False, indent=2)
return Response(
json_data,
mimetype="application/json",
headers={
"Content-Disposition": "attachment; filename=iplist.json"
}
)
@app.route("/add", methods=["GET", "POST"])
def add_item():
form_data = {
"ipaddress": "",
"fqdn": "",
"purpose": ""
}
if request.method == "POST":
form_data["ipaddress"] = request.form.get("ipaddress", "").strip()
form_data["fqdn"] = request.form.get("fqdn", "").strip()
form_data["purpose"] = request.form.get("purpose", "").strip()
if not validate_ipv4(form_data["ipaddress"]):
flash("Neplatná IPv4 adresa.", "error")
return render_template_string(INDEX_TEMPLATE, mode="add", form_data=form_data)
conn = get_db_connection()
try:
with conn.cursor() as cur:
try:
cur.execute("""
INSERT INTO iplist (ipaddress, fqdn, purpose)
VALUES (%s, %s, %s)
""", (
form_data["ipaddress"],
form_data["fqdn"] or None,
form_data["purpose"] or None
))
flash("Záznam byl přidán.", "success")
return redirect(url_for("index"))
except pymysql.err.IntegrityError:
flash("Tahle IP adresa už v databázi existuje.", "error")
finally:
conn.close()
return render_template_string(INDEX_TEMPLATE, mode="add", form_data=form_data)
@app.route("/edit/<int:item_id>", methods=["GET", "POST"])
def edit_item(item_id):
conn = get_db_connection()
try:
with conn.cursor() as cur:
if request.method == "POST":
ipaddress_value = request.form.get("ipaddress", "").strip()
fqdn_value = request.form.get("fqdn", "").strip()
purpose_value = request.form.get("purpose", "").strip()
if not validate_ipv4(ipaddress_value):
flash("Neplatná IPv4 adresa.", "error")
else:
try:
cur.execute("""
UPDATE iplist
SET ipaddress = %s,
fqdn = %s,
purpose = %s
WHERE id = %s
""", (
ipaddress_value,
fqdn_value or None,
purpose_value or None,
item_id
))
flash("Záznam byl uložen.", "success")
return redirect(url_for("index"))
except pymysql.err.IntegrityError:
flash("Tahle IP adresa už v databázi existuje.", "error")
cur.execute("""
SELECT id, ipaddress, fqdn, purpose
FROM iplist
WHERE id = %s
""", (item_id,))
item = cur.fetchone()
if not item:
flash("Záznam nebyl nalezen.", "error")
return redirect(url_for("index"))
finally:
conn.close()
return render_template_string(INDEX_TEMPLATE, mode="edit", item=item)
@app.route("/delete/<int:item_id>", methods=["POST"])
def delete_item(item_id):
conn = get_db_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT ipaddress FROM iplist WHERE id = %s", (item_id,))
item = cur.fetchone()
if not item:
flash("Záznam nebyl nalezen.", "error")
return redirect(url_for("index"))
cur.execute("DELETE FROM iplist WHERE id = %s", (item_id,))
flash(f"Záznam {item['ipaddress']} byl smazán.", "success")
finally:
conn.close()
return redirect(url_for("index"))
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)