import os import sqlite3 import hashlib from datetime import datetime from functools import wraps from flask import Flask, render_template, request, redirect, url_for, session, flash, g app = Flask(__name__) app.secret_key = os.environ.get("SECRET_KEY", "jpchx_volga_secret_key_2012_matrixhasyou") DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) os.makedirs(DATA_DIR, exist_ok=True) DB_PATH = os.path.join(DATA_DIR, "jpchx.db") def get_db(): db = getattr(g, "_database", None) if db is None: db = g._database = sqlite3.connect(DB_PATH) db.row_factory = sqlite3.Row return db @app.teardown_appcontext def close_connection(exception): db = getattr(g, "_database", None) if db is not None: db.close() def hash_pw(password: str) -> str: return hashlib.sha256(password.encode("utf-8")).hexdigest() def login_required(f): @wraps(f) def decorated_function(*args, **kwargs): if "user_id" not in session: flash("Для выполнения этого действия необходимо войти в систему или зарегистрироваться.", "warning") return redirect(url_for("login", next=request.url)) return f(*args, **kwargs) return decorated_function def init_db(): conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row cur = conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT DEFAULT 'Участник', avatar_text TEXT DEFAULT '📷', created_at TEXT NOT NULL ); """) cur.execute(""" CREATE TABLE IF NOT EXISTS categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT NOT NULL ); """) cur.execute(""" CREATE TABLE IF NOT EXISTS topics ( id INTEGER PRIMARY KEY AUTOINCREMENT, category_id INTEGER NOT NULL, title TEXT NOT NULL, author_id INTEGER NOT NULL, views INTEGER DEFAULT 0, created_at TEXT NOT NULL, FOREIGN KEY (category_id) REFERENCES categories (id), FOREIGN KEY (author_id) REFERENCES users (id) ); """) cur.execute(""" CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, topic_id INTEGER NOT NULL, author_id INTEGER NOT NULL, content TEXT NOT NULL, created_at TEXT NOT NULL, FOREIGN KEY (topic_id) REFERENCES topics (id), FOREIGN KEY (author_id) REFERENCES users (id) ); """) cur.execute(""" CREATE TABLE IF NOT EXISTS gallery ( id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT NOT NULL, title TEXT NOT NULL, location TEXT NOT NULL, year TEXT NOT NULL, description TEXT NOT NULL ); """) # Check if empty, populate initial content cur.execute("SELECT count(*) FROM users;") if cur.fetchone()[0] == 0: print("Populating initial JPCHX club database...") now = "2012-08-11 14:20:00" # 1. Users users_data = [ ("Admin", hash_pw("admin_jpchx_2012"), "Главный Хранитель ЖПЧХ", "🎖️", "2012-01-10 12:00:00"), ("MatrixHasYou", hash_pw("matrix_volga_2012"), "Основатель / Мастер кадра", "👑", "2012-02-01 10:00:00"), ("PitKa", hash_pw("pitka_master_2012"), "Основатель / Теоретик контраста", "🎩", "2012-02-01 10:15:00"), ("LensMaster", hash_pw("lens_pass_2014"), "Оптик-испытатель", "🔍", "2013-05-12 16:30:00"), ("VolgaPhotographer", hash_pw("volga_pass_2014"), "Хроникёр Поволжья", "🌊", "2013-07-20 18:45:00"), ("BokehLover", hash_pw("bokeh_pass_2015"), "Любитель светосилы", "✨", "2014-03-15 11:20:00"), ("StreetHunter73", hash_pw("street_pass_2015"), "Уличный снайпер 73", "🎯", "2014-09-01 09:10:00"), ("WideAngleGuy", hash_pw("wide_pass_2016"), "Широкоугольный мастер", "📐", "2015-04-18 14:05:00"), ("WeddingCrasher", hash_pw("wedding_pass_2016"), "Банкетный летописец", "🍾", "2015-08-22 17:50:00"), ("ShutterBug", hash_pw("shutter_pass_2017"), "Плёночный энтузиаст", "🎞️", "2016-11-05 13:40:00"), ] cur.executemany("INSERT INTO users (username, password_hash, role, avatar_text, created_at) VALUES (?, ?, ?, ?, ?)", users_data) # 2. Categories cats_data = [ (1, "Теория и философия ЖПЧХ", "Основы композиции, контраст планов, смысл жеста и манифест движения"), (2, "Техника и оптика", "Камеры, объективы, глубина резкости, выдержка и угол захвата"), (3, "Полевые отчеты и истории", "Хроники внедрения на свадьбах, выпускных, банкетах и пленэрах"), (4, "Клубная жизнь и мастерская", "Членство, дипломы, этика участника и организация съемок"), ] cur.executemany("INSERT INTO categories (id, title, description) VALUES (?, ?, ?)", cats_data) # 3. Topics & Posts # Topic 1 cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)", (1, "Основы композиции ЖПЧХ: контраст первого плана против пафоса фона", 2, 428, "2013-04-15 10:20:00")) t1_id = cur.lastrowid cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t1_id, 2, """Коллеги и единомышленники! Часто новички спрашивают: «В чём глубинная суть ЖПЧХ? Это просто шутка или фото-арт?». Отвечаю со всей основательностью. ЖПЧХ — это в первую очередь художественный приём абсолютного контраста. На заднем плане кадра разворачивается нечто чрезмерно торжественное: чинные лица гостей в строгих костюмах, пафосный тост на банкете, церемония вручения дипломов у Мемцентра или свадебная фотосессия на волжском косогоре. Все напряжены, неестественны, играют социальные роли. И тут на первом плане — в расфокусе или чётком контуре спиной к камере — появляется рука, уверенно и честно почёсывающая задницу. Этот жест мгновенно приземляет напыщенность, возвращает кадру витальность и заставляет зрителя улыбнуться искренней правде жизни. Главное правило: почес должен быть уверенным, хватким и органичным. Не имитация, а честный физиологический импульс!""", "2013-04-15 10:20:00")) cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t1_id, 3, """Поддерживаю каждое слово MatrixHasYou! От себя добавлю как сооснователь: важен тайминг. Нельзя чесаться непрерывно, как шелудивый пёс. Истинный мастер ЖПЧХ ждёт апогея торжества на заднем плане — момент поцелуя молодых, звон бокалов или речь ректора. Именно в эту секунду производится вход в кадр и фиксация.""", "2013-04-15 11:45:00")) cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t1_id, 4, """Великолепный манифест! Пробовал снимать на 50mm f/1.4 с открытой диафрагмой. Рука на первом плане слегка в боке, а на заднем плане чётко видны изумлённые лица свидетелей. Эффект потрясающий!""", "2013-04-15 14:10:00")) # Topic 2 cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)", (2, "Выбор оптики: сверхширокий угол 16-35mm против классического портретника 50mm", 3, 312, "2013-06-02 12:00:00")) t2_id = cur.lastrowid cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t2_id, 3, """Провёл полевые испытания на двух объективах: Nikkor 16-35mm f/4 и 50mm f/1.8. На 16mm масштаб задницы на переднем плане получается монументальным — она буквально доминирует над архитектурой города и Волгой. Но требует подойти к оператору вплотную (30-40 см). На 50mm геометрия естественнее, легче остаться незамеченным охраной. Кто какую оптику предпочитает?""", "2013-06-02 12:00:00")) cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t2_id, 8, """Ширик рулит однозначно! 24mm — золотая середина. Влезает и весь банкетный зал, и монументальный почес.""", "2013-06-02 13:30:00")) cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t2_id, 2, """Согласен с PitKa насчёт 50mm для скрытой съёмки. Особенно когда свадьба на Венце или банкет в ресторане Волга. Подойти на 30 см с шириком не всегда дадут подружки невесты. А с полтинника со второго ряда — шедевр готов в одно нажатие!""", "2013-06-02 15:00:00")) # Topic 3 cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)", (3, "Хроники Венца 2014: как мы встревали в кадр официального свадебного кортежа", 2, 580, "2014-04-10 17:30:00")) t3_id = cur.lastrowid cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t3_id, 2, """Апрель 2014 года. Венец, вид на Императорский мост через Волгу. Белый лимузин, жених поднимает невесту на руки, фотограф с зонтом командует: «Смотрим в даль, изображаем романтику!». Мы с PitKa прогуливаемся рядом в куртках. PitKa занимает позицию с камерой, я делаю непринуждённый шаг спиной к объективу, рука уверенно впивается в карман джинсов и производит канонический ЖПЧХ. Фотограф свадьбы даже не понял, почему у него на заднем плане композиции возник наш монументальный шедевр. Кадр вошёл в золотой фонд Клуба (см. Галерею, экспонат №4)!""", "2014-04-10 17:30:00")) cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t3_id, 9, """Это же классика жанра! Невеста потом пересматривает свадебный альбом, а там на фоне вечной Волги — вечный человеческий почес! Искусство как оно есть.""", "2014-04-10 19:15:00")) # Topic 4 cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)", (4, "Кодекс и этика участника Клуба: границы допустимого и уважение к событию", 1, 290, "2014-05-18 09:00:00")) t4_id = cur.lastrowid cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t4_id, 1, """Напоминание Администрации всем новым членам сообщества. Клуб Любителей ЖПЧХ — это интеллектуальная уличная фотография, а не хулиганство. Правила: 1. Никакого оголения! Почес производится строго поверх одежды (джинсы, брюки, шорты). 2. Никаких оскорблений и срыва мероприятий. Мы не мешаем людям, мы просто фиксируем контраст. 3. Лица участников на открытых ресурсах стилизуются графически (карандаш, гравюра) во избежание лишней мирской славы. Соблюдайте чистоту жанра!""", "2014-05-18 09:00:00")) cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t4_id, 2, """Золотые слова. Именно поэтому в нашей официальной галерее все 16 архивных работ представлены в строгой графической обработке.""", "2014-05-18 10:30:00")) # Topic 5 cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)", (2, "Съемка со вспышкой в темных залах: синхронизация по второй шторке", 6, 215, "2014-11-20 20:00:00")) t5_id = cur.lastrowid cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t5_id, 6, """На юбилеях и корпоративах в полутемных банкетных залах свет обычно отвратительный. Если лупить вспышкой в лоб — убивается атмосфера. Кто как настраивает синхронизацию по второй шторке (Rear Sync), чтобы смаз движения руки давал ощущение динамики почеса?""", "2014-11-20 20:00:00")) cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t5_id, 2, """Выдержка 1/15 секунды, ISO 800, пыха на -1.7 EV с рассеивателем в потолок по второй шторке. Рука оставляет лёгкий динамический шлейф, а в конце импульс фиксирует пальцы в кульминационной точке. Проверено на десятке банкетов!""", "2014-11-20 21:40:00")) # Topic 6 cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)", (3, "Парная работа: техника «Наводчик и Исполнитель»", 3, 340, "2015-02-14 15:20:00")) t6_id = cur.lastrowid cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t6_id, 3, """В одиночку снимать ЖПЧХ на автоспуске или от бедра сложно. Идеальная тактика — работа в паре: - Фотограф делает вид, что увлечённо снимает памятник, панораму реки или фонтан. - Исполнитель как бы невзначай переходит улицу или останавливается перед объективом, любуясь пейзажем. - Кодовое слово: «Горизонт завален!», после чего исполнитель приступает к почесу, а фотограф жмёт спуск серией.""", "2015-02-14 15:20:00")) cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t6_id, 7, """Мы в Ульяновске у Дома Офицеров так три кадра сняли подряд. Ни одна собака не заподозрила! Паре зачет.""", "2015-02-14 16:50:00")) # Topic 7 cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)", (1, "История зарождения движения на берегах Волги (Симбирск, лето 2012)", 1, 620, "2015-08-11 14:00:00")) t7_id = cur.lastrowid cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t7_id, 1, """Историческая справка для архива. Клуб ЖПЧХ ведёт свою летопись с жаркого августа 2012 года. Именно тогда в славном волжском городе Симбирске (Ульяновске) MatrixHasYou и PitKa впервые сформулировали концепцию контр-глянцевой фотографии. В эпоху повального увлечения постановочными фотосессиями с искусственными улыбками и вылизанными фильтрами Инстаграма, зародился протест: возвращение к корням, честности и несокрушимой самоиронии. С тех пор движение обрело последователей далеко за пределами Волги!""", "2015-08-11 14:00:00")) cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t7_id, 2, """Помню тот жаркий день на волжском берегу как вчера. 11 августа 2012 года. Именно тогда был сделан легендарный кадр `20120811_141940-2` (экспонат №6 в Галерее). 14 лет прошло, а актуальность только растёт!""", "2015-08-11 15:30:00")) cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t7_id, 3, """Волга вообще располагает к глубоким философским выводам о бренности пафоса. Слава ЖПЧХ!""", "2015-08-11 16:15:00")) # Topic 8 cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)", (2, "Плёнка или матрица: передача зерна и текстуры денима в ч/б исполнении", 10, 185, "2016-03-01 11:10:00")) t8_id = cur.lastrowid cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t8_id, 10, """Зарядил в старый Киев-19 плёнку Tasma Тип-42. Контраст бешеный, фактура потертых джинсов при почесе выглядит как гравюра эпохи Возрождения. Цифра всё-таки слишком стерильна для такого жанра, что думаете?""", "2016-03-01 11:10:00")) cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t8_id, 4, """Поэтому мы и переводим все цифры в карандашный набросок и гравюру. Штрих придаёт вечность.""", "2016-03-01 13:00:00")) # Topic 9 cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)", (1, "Символизм позы: анатомия захвата и положение пальцев", 2, 275, "2016-06-19 18:00:00")) t9_id = cur.lastrowid cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t9_id, 2, """Важная деталь, отличающая мастера от дилетанта. Пальцы не должны быть расслаблены! Должна чувствоваться работа предплечья, естественное натяжение ткани кармана или шва. Если рука просто вяло лежит на бедре — это не ЖПЧХ, это поза уставшего грузчика. ЖПЧХ — это действие в апогее!""", "2016-06-19 18:00:00")) cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t9_id, 3, """Абсолютно. В кадре должно читаться намерение. Зритель должен почувствовать этот чес спинным мозгом.""", "2016-06-19 19:20:00")) # Topic 10 cur.execute("INSERT INTO topics (category_id, title, author_id, views, created_at) VALUES (?, ?, ?, ?, ?)", (4, "Клубные членские билеты и дипломы Мастера Первой Степени", 1, 390, "2017-01-25 14:15:00")) t10_id = cur.lastrowid cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t10_id, 1, """По многочисленным просьбам участников: В типографии отпечатаны номерные членские билеты Клуба Любителей ЖПЧХ на плотной крафтовой бумаге. Условия получения: 1. Зарегистрироваться на форуме. 2. Опубликовать не менее одного подтверждённого полевого фото-шедевра. 3. Получить одобрение Основателей (MatrixHasYou / PitKa).""", "2017-01-25 14:15:00")) cur.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (t10_id, 5, """Подал заявку с серией кадров с набережной Свияги! Жду вердикта комиссии!""", "2017-01-25 16:30:00")) # 4. Gallery items gallery_items = [ ("jpchx_01.jpg", "Экспонат №1: Монументальный почес у парадной", "Симбирск, Центр", "2012", "Первые полевые испытания контрастного кадрирования."), ("jpchx_02.jpg", "Экспонат №2: Весеннее обострение классики", "Набережная реки Свияги", "2014", "Кадр с естественным освещением в закатный час."), ("jpchx_03.jpg", "Экспонат №3: Кульминация банкетного вечера", "Ресторанный зал", "2014", "На заднем плане разгар торжества, на переднем — суровая правда жизни."), ("jpchx_04.jpg", "Экспонат №4: Свадебный эскорт на Венце", "Бульвар Новый Венец", "2014", "Легендарный кадр с участием Основателей. Хрестоматийный контраст."), ("jpchx_05.jpg", "Экспонат №5: Зимняя выдержка на морозе", "Площадь 100-летия Ленина", "2012", "Почес через плотный зимний пуховик требует особого мастерства."), ("jpchx_06.jpg", "Экспонат №6: Августовский манифест на Волге", "Волжский косогор", "2012", "Исторический день рождения движения 11 августа 2012 года."), ("jpchx_07.jpg", "Экспонат №7: Диалог с архитектурой", "Мемориальный комплекс", "2013", "Геометрическая рифма вертикалей здания и руки мастера."), ("jpchx_08.jpg", "Экспонат №8: Праздничный фуршет", "Банкетный комплекс", "2013", "Внедрение в официальное протокольное мероприятие."), ("jpchx_09.jpg", "Экспонат №9: Летний полдень в тени акаций", "Городской сквер", "2013", "Классический ракурс со спины на среднем фокусном расстоянии."), ("jpchx_10.jpg", "Экспонат №10: Встречное солнце и силуэт", "Волжский пляж", "2013", "Контровой свет выявляет фактуру ткани и динамику жеста."), ("jpchx_11.jpg", "Экспонат №11: Инспекция горизонта", "Смотровая площадка", "2014", "Широкий угол охвата: волжские дали и уверенный почес."), ("jpchx_12.jpg", "Экспонат №12: Ночной дозор после юбилея", "Вход в клуб", "2014", "Съемка при свете неоновых фонарей с легким смазом."), ("jpchx_13.jpg", "Экспонат №13: Вертикаль и ритм ступеней", "Лестница к речному вокзалу", "2014", "Уличный этюд с акцентом на пластику движения."), ("jpchx_14.jpg", "Экспонат №14: Осенний сплин у парапета", "Парк Дружбы Народов", "2014", "Меланхоличный волжский пейзаж и бескомпромиссная поза."), ("jpchx_15.jpg", "Экспонат №15: Академический этюд", "Аллея Славы", "2015", "Выверенная композиция по правилу третей."), ("jpchx_16.jpg", "Экспонат №16: Финал юбилейной сессии", "Загородная усадьба", "2015", "Закрытие четвертого сезона полевых съемок Клуба."), ] cur.executemany("INSERT INTO gallery (filename, title, location, year, description) VALUES (?, ?, ?, ?, ?)", gallery_items) conn.commit() conn.close() # ----------------- ROUTES ----------------- @app.context_processor def inject_user(): user = None if "user_id" in session: db = get_db() user = db.execute("SELECT id, username, role, avatar_text FROM users WHERE id = ?", (session["user_id"],)).fetchone() return dict(current_user=user) @app.route("/") def index(): db = get_db() latest_topics = db.execute(""" SELECT t.id, t.title, t.views, t.created_at, u.username, u.avatar_text, (SELECT count(*) FROM posts p WHERE p.topic_id = t.id) as posts_count FROM topics t JOIN users u ON t.author_id = u.id ORDER BY t.created_at DESC LIMIT 5 """).fetchall() gallery_sample = db.execute("SELECT id, filename, title, location, year FROM gallery ORDER BY id ASC LIMIT 4").fetchall() stats = { "users": db.execute("SELECT count(*) FROM users").fetchone()[0], "topics": db.execute("SELECT count(*) FROM topics").fetchone()[0], "posts": db.execute("SELECT count(*) FROM posts").fetchone()[0], "photos": db.execute("SELECT count(*) FROM gallery").fetchone()[0], } return render_template("index.html", latest_topics=latest_topics, gallery_sample=gallery_sample, stats=stats) @app.route("/manifest") def manifest(): return render_template("manifest.html") @app.route("/gallery") def gallery(): db = get_db() items = db.execute("SELECT id, filename, title, location, year, description FROM gallery ORDER BY id ASC").fetchall() return render_template("gallery.html", items=items) @app.route("/forum") def forum(): db = get_db() categories = db.execute("SELECT id, title, description FROM categories ORDER BY id ASC").fetchall() topics_by_cat = {} for cat in categories: topics = db.execute(""" SELECT t.id, t.title, t.views, t.created_at, u.username, u.role, u.avatar_text, (SELECT count(*) FROM posts p WHERE p.topic_id = t.id) as posts_count, (SELECT p.created_at FROM posts p WHERE p.topic_id = t.id ORDER BY p.id DESC LIMIT 1) as last_post_time FROM topics t JOIN users u ON t.author_id = u.id WHERE t.category_id = ? ORDER BY t.created_at DESC """, (cat["id"],)).fetchall() topics_by_cat[cat["id"]] = topics return render_template("forum.html", categories=categories, topics_by_cat=topics_by_cat) @app.route("/forum/topic/") def topic(topic_id): db = get_db() db.execute("UPDATE topics SET views = views + 1 WHERE id = ?", (topic_id,)) db.commit() topic_row = db.execute(""" SELECT t.id, t.title, t.created_at, t.views, c.id as cat_id, c.title as cat_title, u.username, u.avatar_text, u.role FROM topics t JOIN categories c ON t.category_id = c.id JOIN users u ON t.author_id = u.id WHERE t.id = ? """, (topic_id,)).fetchone() if not topic_row: flash("Тема не найдена.", "danger") return redirect(url_for("forum")) posts = db.execute(""" SELECT p.id, p.content, p.created_at, u.username, u.role, u.avatar_text, (SELECT count(*) FROM posts p2 WHERE p2.author_id = u.id) as user_posts_count FROM posts p JOIN users u ON p.author_id = u.id WHERE p.topic_id = ? ORDER BY p.id ASC """, (topic_id,)).fetchall() return render_template("topic.html", topic=topic_row, posts=posts) @app.route("/forum/new_topic", methods=["GET", "POST"]) @login_required def new_topic(): db = get_db() if request.method == "POST": title = request.form.get("title", "").strip() category_id = request.form.get("category_id") content = request.form.get("content", "").strip() if not title or not category_id or not content: flash("Все поля обязательны для заполнения.", "warning") return redirect(url_for("new_topic")) now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") cur = db.execute("INSERT INTO topics (category_id, title, author_id, created_at) VALUES (?, ?, ?, ?)", (category_id, title, session["user_id"], now)) topic_id = cur.lastrowid db.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (topic_id, session["user_id"], content, now)) db.commit() flash("Тема успешно создана!", "success") return redirect(url_for("topic", topic_id=topic_id)) categories = db.execute("SELECT id, title FROM categories ORDER BY id ASC").fetchall() return render_template("new_topic.html", categories=categories) @app.route("/forum/reply/", methods=["POST"]) @login_required def reply_topic(topic_id): content = request.form.get("content", "").strip() if not content: flash("Сообщение не может быть пустым.", "warning") return redirect(url_for("topic", topic_id=topic_id)) db = get_db() now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") db.execute("INSERT INTO posts (topic_id, author_id, content, created_at) VALUES (?, ?, ?, ?)", (topic_id, session["user_id"], content, now)) db.commit() flash("Ваш ответ опубликован!", "success") return redirect(url_for("topic", topic_id=topic_id)) @app.route("/login", methods=["GET", "POST"]) def login(): if request.method == "POST": username = request.form.get("username", "").strip() password = request.form.get("password", "").strip() db = get_db() user = db.execute("SELECT id, username, password_hash FROM users WHERE username = ?", (username,)).fetchone() if user and user["password_hash"] == hash_pw(password): session["user_id"] = user["id"] session["username"] = user["username"] flash(f"Добро пожаловать, {username}!", "success") next_url = request.args.get("next") or url_for("forum") return redirect(next_url) else: flash("Неверное имя пользователя или пароль.", "danger") return render_template("login.html") @app.route("/register", methods=["GET", "POST"]) def register(): if request.method == "POST": username = request.form.get("username", "").strip() password = request.form.get("password", "").strip() password2 = request.form.get("password2", "").strip() if not username or not password: flash("Заполните все обязательные поля.", "warning") return render_template("register.html") if len(username) < 3: flash("Имя пользователя должно быть не менее 3 символов.", "warning") return render_template("register.html") if password != password2: flash("Введенные пароли не совпадают.", "warning") return render_template("register.html") db = get_db() existing = db.execute("SELECT id FROM users WHERE username = ?", (username,)).fetchone() if existing: flash("Пользователь с таким именем уже зарегистрирован.", "warning") return render_template("register.html") now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") cur = db.execute("INSERT INTO users (username, password_hash, role, avatar_text, created_at) VALUES (?, ?, ?, ?, ?)", (username, hash_pw(password), "Новичок Клуба", "📸", now)) db.commit() session["user_id"] = cur.lastrowid session["username"] = username flash(f"Регистрация успешна! Добро пожаловать в Клуб Любителей ЖПЧХ, {username}!", "success") return redirect(url_for("forum")) return render_template("register.html") @app.route("/logout") def logout(): session.clear() flash("Вы успешно вышли из системы.", "info") return redirect(url_for("index")) @app.route("/healthz") def healthz(): return "OK", 200 if __name__ == "__main__": init_db() port = int(os.environ.get("PORT", 8080)) app.run(host="0.0.0.0", port=port, debug=False)