484 lines
36 KiB
Python
484 lines
36 KiB
Python
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)
|