From 0029ba41a2d35b17e4d7f247a5f9c78c6c5d5192 Mon Sep 17 00:00:00 2001 From: Repinoid Date: Fri, 2 Oct 2026 11:47:52 +0300 Subject: [PATCH] Port Flask CRUD to MariaDB --- .gitignore | 12 ++++ README.md | 5 ++ requirements.txt | 3 + site/app.py | 147 ++++++++++++++++++++++++++++++++++++++ site/static/favicon.svg | 55 ++++++++++++++ site/static/logo.svg | 25 +++++++ site/static/style.css | 111 ++++++++++++++++++++++++++++ site/templates/index.html | 116 ++++++++++++++++++++++++++++++ 8 files changed, 474 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 requirements.txt create mode 100644 site/app.py create mode 100644 site/static/favicon.svg create mode 100755 site/static/logo.svg create mode 100644 site/static/style.css create mode 100644 site/templates/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2530054 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +.env +*.egg-info/ +dist/ +*.log +.DS_Store +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a8ac2d --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# Flask MariaDB CRUD + +Flask + MariaDB через PyMySQL. Подключение задаётся переменными `DB_HOST`, `DB_PORT`, +`DB_NAME`, `DB_USER`, `DB_PASSWORD`; таблица `crud_items` создаётся при старте. +Деплоится через Terraform (`nubes_flask`). diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5c656a5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask>=3.0 +PyMySQL>=1.1,<2 +gunicorn>=21.2 diff --git a/site/app.py b/site/app.py new file mode 100644 index 0000000..58ac1e0 --- /dev/null +++ b/site/app.py @@ -0,0 +1,147 @@ +""" +app.py — Flask CRUD-интерфейс для MariaDB. + +Переменные окружения (из json_env): + - DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD — подключение к БД + - TABLE_NAME — имя таблицы (по умолчанию crud_items) + +При старте: CREATE TABLE IF NOT EXISTS (БЕЗ DROP). +""" + +import os +import re +import pymysql +from flask import Flask, render_template, request, redirect, url_for, g + +VERSION = "1.0.0" +TABLE_NAME = os.environ.get("TABLE_NAME", "crud_items") +if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", TABLE_NAME): + raise ValueError("TABLE_NAME must be a simple SQL identifier") +TABLE_SQL = f"`{TABLE_NAME}`" +# SERVICE_NAME — какое приложение добавило строку (колонка created_by). +# Задаётся в json_env манифеста отдельно для каждого сервиса (flask / lucee / nodejs), +# чтобы три приложения, пишущие в ОДНУ таблицу, были различимы. +SERVICE_NAME = os.environ.get("SERVICE_NAME", "flask") + +app = Flask(__name__) + + +def get_db(): + """Ленивое подключение к MariaDB. Один коннект на запрос.""" + if "db" not in g: + g.db = pymysql.connect( + host=os.environ.get("DB_HOST", "127.0.0.1"), + port=int(os.environ.get("DB_PORT", "3306")), + database=os.environ.get("DB_NAME", "maria"), + user=os.environ.get("DB_USER", ""), + password=os.environ.get("DB_PASSWORD", ""), + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + connect_timeout=5, + autocommit=True, + ) + return g.db + + +def init_db(): + """Создать таблицу, если её нет, и досыпать новые колонки. + + Таблица выставочная — кроме id/value храним только две метки: + created_at — когда строка добавлена (ставит сама СУБД); + created_by — какое приложение её добавило (flask / lucee / nodejs). + + ALTER TABLE ... ADD COLUMN IF NOT EXISTS нужен, чтобы таблица, созданная + предыдущей версией приложения, получила новые колонки без потери данных. + """ + db = get_db() + cur = db.cursor() + cur.execute( + f""" + CREATE TABLE IF NOT EXISTS {TABLE_SQL} ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + `value` TEXT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(255) NOT NULL DEFAULT '' + ) + """ + ) + cur.execute( + f"ALTER TABLE {TABLE_SQL} ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP" + ) + cur.execute( + f"ALTER TABLE {TABLE_SQL} ADD COLUMN IF NOT EXISTS created_by VARCHAR(255) NOT NULL DEFAULT ''" + ) + cur.close() + + +@app.teardown_appcontext +def close_db(exc): + db = g.pop("db", None) + if db is not None: + db.close() + + +@app.route("/", methods=["GET", "POST"]) +def index(): + db = get_db() + cur = db.cursor() + + # --- Обработка POST (create / update / delete) — POST-redirect-GET --- + if request.method == "POST": + action = request.form.get("action", "") + + if action == "create": + # created_at ставит СУБД (DEFAULT now()), created_by — имя этого сервиса + cur.execute( + f"INSERT INTO {TABLE_SQL} (`value`, created_by) VALUES (%s, %s)", + (request.form.get("value", ""), SERVICE_NAME), + ) + + elif action == "update": + cur.execute( + f"UPDATE {TABLE_SQL} SET `value` = %s WHERE id = %s", + (request.form.get("value", ""), int(request.form.get("id", 0))), + ) + + elif action == "delete": + cur.execute( + f"DELETE FROM {TABLE_SQL} WHERE id = %s", + (int(request.form.get("id", 0)),), + ) + + cur.close() + return redirect(url_for("index")) + + # --- GET: список + форма редактирования --- + edit_row = None + edit_id = request.args.get("edit") + if edit_id: + cur.execute( + f"SELECT id, `value`, created_at, created_by FROM {TABLE_SQL} WHERE id = %s", + (int(edit_id),), + ) + row = cur.fetchone() + if row: + edit_row = dict(row) + + # Список всех записей — вместе с метками «когда» и «кем» добавлено + cur.execute(f"SELECT id, `value`, created_at, created_by FROM {TABLE_SQL} ORDER BY id") + rows = cur.fetchall() + cur.close() + + return render_template( + "index.html", + table_name=TABLE_NAME, + rows=rows, + edit_row=edit_row, + version=VERSION, + ) + + +# --- Инициализация БД при старте (до первого запроса) --- +with app.app_context(): + init_db() + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", 5000)), debug=False) diff --git a/site/static/favicon.svg b/site/static/favicon.svg new file mode 100644 index 0000000..11a9ece --- /dev/null +++ b/site/static/favicon.svg @@ -0,0 +1,55 @@ + + diff --git a/site/static/logo.svg b/site/static/logo.svg new file mode 100755 index 0000000..d76eef6 --- /dev/null +++ b/site/static/logo.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + diff --git a/site/static/style.css b/site/static/style.css new file mode 100644 index 0000000..8a5eb67 --- /dev/null +++ b/site/static/style.css @@ -0,0 +1,111 @@ +:root { + --brand-primary: #2563eb; + --brand-primary-dark: #1d4ed8; + --brand-gray: #d1d5db; + --brand-grey-light: #f3f4f6; + --brand-destructive: #ef4444; + --brand-success: #22c55e; + --text-primary: #1a1a1a; + --text-muted: #6b7280; + --bg-card: #ffffff; + --radius: 12px; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +body { + font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + font-size: 14px; + color: var(--text-primary); + background: var(--brand-grey-light); + min-height: 100vh; + padding: 40px 16px; +} +.container { max-width: 720px; margin: 0 auto; } + +/* Header */ +.header { + display: flex; align-items: center; gap: 12px; + margin-bottom: 24px; +} +.header img { height: 36px; } +.header h1 { font-size: 20px; font-weight: 600; color: #001C34; } + +/* Card */ +.card { + background: var(--bg-card); + border-radius: var(--radius); + border: 1px solid var(--brand-gray); + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + overflow: hidden; + margin-bottom: 20px; +} +.card-header { + background: var(--brand-grey-light); + padding: 12px 16px; + font-weight: 600; + font-size: 16px; +} +.card-body { padding: 16px; } + +/* Table */ +table { width: 100%; border-collapse: collapse; } +th { + background: var(--brand-grey-light); + text-transform: uppercase; + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + padding: 8px 12px; + text-align: left; + border-right: 1px solid var(--brand-gray); +} +th:last-child { border-right: none; text-align: center; width: 60px; } +td { + padding: 8px 12px; + border-bottom: 1px solid var(--brand-gray); + border-right: 1px solid var(--brand-gray); +} +td:last-child { border-right: none; text-align: center; width: 60px; } +tr:hover { background: rgba(243,244,246,0.5); } +tr:last-child td { border-bottom: none; } + +/* Buttons */ +.btn { + display: inline-flex; align-items: center; gap: 4px; + height: 32px; padding: 0 12px; + border: 1px solid var(--brand-gray); + border-radius: 6px; + background: var(--bg-card); + font-size: 14px; font-family: inherit; + cursor: pointer; white-space: nowrap; + text-decoration: none; + color: var(--text-primary); +} +.btn:hover { background: var(--brand-grey-light); } +.btn-primary { + background: var(--brand-primary); color: #fff; border-color: var(--brand-primary); +} +.btn-primary:hover { background: var(--brand-primary-dark); } +.btn-sm { height: 28px; padding: 0 8px; font-size: 13px; } + +/* Form */ +.form-grid { + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} +.form-row { + display: flex; gap: 8px; align-items: flex-end; +} +.form-row input { flex: 1; } +input[type="text"] { + height: 36px; padding: 0 12px; + border: 1px solid var(--brand-gray); + border-radius: 6px; font-size: 14px; font-family: inherit; +} +input[type="text"]:focus { outline: none; border-color: var(--brand-primary); box-shadow: 0 0 0 2px rgba(37,99,235,0.15); } + +/* Footer */ +.footer { text-align: center; padding: 16px; color: #9ca3af; font-size: 12px; } + +/* Empty state */ +.empty-cell { color: var(--text-muted); padding: 24px; text-align: center; } diff --git a/site/templates/index.html b/site/templates/index.html new file mode 100644 index 0000000..03aa7c3 --- /dev/null +++ b/site/templates/index.html @@ -0,0 +1,116 @@ + + + + + + + CRUD Flask Example — {{ table_name }} + + + + + +
+ +
+ Nubes +

CRUD Flask Example

+
+ +
+
Записи
+
+ + + + + + + + + + + + + {% for row in rows %} + + + + + + + + + {% endfor %} + +
IDVALUEДОБАВЛЕНОКЕМ
{{ row.id }}{{ row.value }}{{ row.created_at.strftime('%d.%m.%Y %H:%M:%S') if row.created_at else '' }}{{ row.created_by }} + ✏️ + +
+ + + +
+
+ {% if not rows %} +
Нет записей. Создайте первую.
+ {% endif %} +
+
+ +
+ {% if edit_row %} +
Редактировать #{{ edit_row.id }}
+
+
+ + +
+ + + Отмена +
+
+
+ {% else %} +
Добавить
+
+
+ +
+ + +
+
+
+ {% endif %} +
+ +
+ + + + + \ No newline at end of file