pelmeni-shield-vpn/site/app.py

484 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/<int:topic_id>")
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/<int:topic_id>", 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)