Files
qa-tester 0b48746e67 docker: подготовка к запуску в контейнере (Flask)
- Dockerfile: python:3.12-slim, зависимости отдельным слоем
- docker-compose.yml: порт 5000, env из .env
- .dockerignore: .env, .git, кэш
- app.py: bind 0.0.0.0:5000 (в контейнере)
2026-08-04 20:20:54 +04:00

387 lines
12 KiB
Python

import csv
import io
import os
import sqlite3
from datetime import datetime
from functools import wraps
from hmac import compare_digest
from pathlib import Path
from flask import Flask, flash, make_response, redirect, render_template, request, session, url_for
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_wtf import CSRFProtect
BASE_DIR = Path(__file__).resolve().parent
DATABASE_PATH = BASE_DIR / "consultations.db"
SUBJECTS = {"math": "Математика", "physics": "Физика"}
EXAMS = {"oge": "ОГЭ", "ege": "ЕГЭ", "other": "Пока не определились"}
FORMATS = {"online": "Онлайн", "offline": "Очно", "other": "Пока не определились"}
PREFERRED_TIMES = {
"flexible": "Гибко",
"morning": "Утро",
"day": "День",
"evening": "Вечер",
}
REQUEST_STATUSES = {
"new": "Новая",
"contacted": "Связались",
"scheduled": "Запланирована",
"closed": "Завершена",
}
app = Flask(__name__)
app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY")
if not app.config["SECRET_KEY"]:
raise RuntimeError("SECRET_KEY не задан. Установите переменную окружения SECRET_KEY.")
app.config["SESSION_COOKIE_HTTPONLY"] = True
app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
app.config["SESSION_COOKIE_SECURE"] = os.environ.get("COOKIE_SECURE", "0") == "1"
csrf = CSRFProtect(app)
limiter = Limiter(
get_remote_address,
app=app,
default_limits=["200 per hour"],
storage_uri="memory://",
)
def get_connection():
connection = sqlite3.connect(DATABASE_PATH)
connection.row_factory = sqlite3.Row
return connection
def init_database():
with get_connection() as connection:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS consultation_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
contact TEXT NOT NULL,
subject TEXT NOT NULL,
exam TEXT NOT NULL,
format TEXT NOT NULL,
preferred_time TEXT NOT NULL DEFAULT 'flexible',
message TEXT,
status TEXT NOT NULL DEFAULT 'new',
admin_note TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT
)
"""
)
existing_columns = {
row["name"]
for row in connection.execute("PRAGMA table_info(consultation_requests)").fetchall()
}
migrations = {
"preferred_time": "TEXT NOT NULL DEFAULT 'flexible'",
"status": "TEXT NOT NULL DEFAULT 'new'",
"admin_note": "TEXT NOT NULL DEFAULT ''",
"updated_at": "TEXT",
}
for column, definition in migrations.items():
if column not in existing_columns:
connection.execute(
f"ALTER TABLE consultation_requests ADD COLUMN {column} {definition}"
)
@app.context_processor
def inject_site_data():
return {"current_year": datetime.now().year}
@app.get("/")
def index():
return render_template("index.html")
@app.post("/submit")
@limiter.limit("10 per minute")
def submit_request():
name = request.form.get("name", "").strip()
contact = request.form.get("contact", "").strip()
subject_key = request.form.get("subject", "").strip()
exam_key = request.form.get("exam", "").strip()
format_key = request.form.get("format", "").strip()
preferred_time_key = request.form.get("preferred_time", "").strip()
message = request.form.get("message", "").strip()[:2000]
if (
not name
or not contact
or subject_key not in SUBJECTS
or exam_key not in EXAMS
or format_key not in FORMATS
or preferred_time_key not in PREFERRED_TIMES
):
flash("Пожалуйста, заполните обязательные поля формы.", "error")
return redirect(url_for("index") + "#contact")
created_at = datetime.now().isoformat(timespec="minutes")
with get_connection() as connection:
connection.execute(
"""
INSERT INTO consultation_requests
(name, contact, subject, exam, format, preferred_time, message, status, admin_note, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
name[:120],
contact[:160],
SUBJECTS[subject_key],
EXAMS[exam_key],
FORMATS[format_key],
PREFERRED_TIMES[preferred_time_key],
message,
"new",
"",
created_at,
created_at,
),
)
return redirect(url_for("success", name=name))
@app.get("/success")
def success():
return render_template("success.html", name=request.args.get("name", ""))
def admin_required(view):
@wraps(view)
def wrapped_view(*args, **kwargs):
if not session.get("is_admin"):
return redirect(url_for("admin_login"))
return view(*args, **kwargs)
return wrapped_view
def get_request_filters():
status = request.args.get("status", "").strip()
subject = request.args.get("subject", "").strip()
search = request.args.get("q", "").strip()
return {
"status": status if status in REQUEST_STATUSES else "",
"subject": subject if subject in SUBJECTS.values() else "",
"q": search[:100],
}
def build_request_query(filters):
conditions = []
parameters = []
if filters["status"]:
conditions.append("status = ?")
parameters.append(filters["status"])
if filters["subject"]:
conditions.append("subject = ?")
parameters.append(filters["subject"])
if filters["q"]:
escaped = filters["q"].replace("%", "\\%").replace("_", "\\_")
search_pattern = f"%{escaped}%"
conditions.append(
"(name LIKE ? ESCAPE '\\' OR contact LIKE ? ESCAPE '\\' "
"OR message LIKE ? ESCAPE '\\' OR admin_note LIKE ? ESCAPE '\\')"
)
parameters.extend([search_pattern] * 4)
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
return where_clause, parameters
def fetch_requests(connection, filters):
where_clause, parameters = build_request_query(filters)
return connection.execute(
f"""
SELECT id, name, contact, subject, exam, format, preferred_time,
message, status, admin_note, created_at, updated_at
FROM consultation_requests
{where_clause}
ORDER BY id DESC
""",
parameters,
).fetchall()
def fetch_stats(connection):
stats = connection.execute(
"""
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN status = 'new' THEN 1 ELSE 0 END), 0) AS new,
COALESCE(SUM(CASE WHEN status = 'contacted' THEN 1 ELSE 0 END), 0) AS contacted,
COALESCE(SUM(CASE WHEN status = 'scheduled' THEN 1 ELSE 0 END), 0) AS scheduled,
COALESCE(SUM(CASE WHEN status = 'closed' THEN 1 ELSE 0 END), 0) AS closed
FROM consultation_requests
"""
).fetchone()
return dict(stats)
@app.route("/admin/login", methods=["GET", "POST"])
@limiter.limit("10 per minute")
def admin_login():
if session.get("is_admin"):
return redirect(url_for("admin_dashboard"))
if request.method == "POST":
password = request.form.get("password", "")
admin_password = os.environ.get("ADMIN_PASSWORD")
if not admin_password:
raise RuntimeError("ADMIN_PASSWORD не задан. Установите переменную окружения.")
if compare_digest(password, admin_password):
session.clear()
session["is_admin"] = True
return redirect(url_for("admin_dashboard"))
flash("Неверный пароль.", "error")
return render_template("admin_login.html")
@app.get("/admin")
@admin_required
def admin_dashboard():
filters = get_request_filters()
with get_connection() as connection:
consultation_requests = fetch_requests(connection, filters)
stats = fetch_stats(connection)
return render_template(
"admin.html",
consultation_requests=consultation_requests,
filters=filters,
stats=stats,
statuses=REQUEST_STATUSES,
subjects=SUBJECTS.values(),
)
@app.post("/admin/request/<int:request_id>/status")
@admin_required
def admin_update_status(request_id):
status = request.form.get("status", "").strip()
if status not in REQUEST_STATUSES:
flash("Неизвестный статус заявки.", "error")
return redirect(url_for("admin_dashboard"))
with get_connection() as connection:
connection.execute(
"UPDATE consultation_requests SET status = ?, updated_at = ? WHERE id = ?",
(status, datetime.now().isoformat(timespec="minutes"), request_id),
)
flash("Статус заявки обновлён.", "success")
return redirect(url_for("admin_dashboard"))
@app.post("/admin/request/<int:request_id>/note")
@admin_required
def admin_update_note(request_id):
admin_note = request.form.get("admin_note", "").strip()[:2000]
with get_connection() as connection:
connection.execute(
"UPDATE consultation_requests SET admin_note = ?, updated_at = ? WHERE id = ?",
(admin_note, datetime.now().isoformat(timespec="minutes"), request_id),
)
flash("Заметка сохранена.", "success")
return redirect(url_for("admin_dashboard"))
@app.post("/admin/request/<int:request_id>/delete")
@admin_required
def admin_delete_request(request_id):
with get_connection() as connection:
connection.execute("DELETE FROM consultation_requests WHERE id = ?", (request_id,))
flash("Заявка удалена.", "success")
return redirect(url_for("admin_dashboard"))
def csv_safe(value):
"""Защита от CSV-инъекции: значения с формулами экранируются апострофом."""
if value and value[0] in ("=", "+", "-", "@", "\t", "\r"):
return "'" + value
return value
@app.get("/admin/export.csv")
@admin_required
def admin_export():
filters = get_request_filters()
with get_connection() as connection:
consultation_requests = fetch_requests(connection, filters)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(
[
"Номер",
"Имя",
"Контакт",
"Предмет",
"Экзамен",
"Формат",
"Желаемое время",
"Статус",
"Комментарий",
"Заметка преподавателя",
"Создана",
"Обновлена",
]
)
for consultation in consultation_requests:
writer.writerow(
[
consultation["id"],
csv_safe(consultation["name"]),
csv_safe(consultation["contact"]),
consultation["subject"],
consultation["exam"],
consultation["format"],
consultation["preferred_time"],
REQUEST_STATUSES.get(consultation["status"], consultation["status"]),
csv_safe(consultation["message"] or ""),
csv_safe(consultation["admin_note"] or ""),
consultation["created_at"],
consultation["updated_at"] or "",
]
)
response = make_response("\ufeff" + output.getvalue())
response.headers["Content-Type"] = "text/csv; charset=utf-8"
response.headers["Content-Disposition"] = (
f"attachment; filename=consultations-{datetime.now().strftime('%Y-%m-%d')}.csv"
)
return response
@app.get("/admin/logout")
def admin_logout():
session.clear()
return redirect(url_for("admin_login"))
init_database()
if __name__ == "__main__":
app.run(
host="0.0.0.0",
port=5000,
debug=os.environ.get("FLASK_DEBUG") == "1",
)