init
This commit is contained in:
BIN
rodchenko/.auction_db/auction.db
Normal file
BIN
rodchenko/.auction_db/auction.db
Normal file
Binary file not shown.
16
rodchenko/Dockerfile
Executable file
16
rodchenko/Dockerfile
Executable file
@@ -0,0 +1,16 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN apt update && apt install -y curl sqlite3 xxd
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN echo SECRET_KEY=$(xxd -l 20 -p /dev/urandom) >> .env
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app/ .
|
||||
|
||||
EXPOSE 5050
|
||||
|
||||
CMD ["gunicorn", "--worker-class", "gevent", "--worker-connections", "1024", "--bind", "0.0.0.0:5050", "app:app"]
|
||||
353
rodchenko/app/app.py
Executable file
353
rodchenko/app/app.py
Executable file
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
Rodchenko
|
||||
Suprematist art auction
|
||||
"""
|
||||
from flask import Flask, render_template, request, redirect, url_for, session, flash
|
||||
from dotenv import load_dotenv
|
||||
import requests
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
|
||||
from utils.db import (
|
||||
authenticate_user,
|
||||
cleanup_expired_records,
|
||||
create_artwork_record,
|
||||
create_user,
|
||||
session_user,
|
||||
fetch_recent_artworks_for_user,
|
||||
get_artwork_owner_id,
|
||||
get_artwork_with_settings,
|
||||
check_connect,
|
||||
get_user_balance,
|
||||
get_user_profile,
|
||||
import_artwork_record,
|
||||
init_db,
|
||||
purchase_artwork,
|
||||
save_artwork_settings,
|
||||
search_artworks,
|
||||
)
|
||||
from utils.art import generate_suprematist_art, generate_artwork_title
|
||||
from utils.security import load_artwork_settings, save_artwork_description, is_safe_url
|
||||
|
||||
app = Flask(__name__)
|
||||
load_dotenv()
|
||||
app.secret_key = os.environ.get("SECRET_KEY", "super_secret_key_123")
|
||||
_cleaner_started = False
|
||||
|
||||
CLEANER_INTERVAL_SECONDS = 7 * 60
|
||||
CLEANER_MAX_AGE_MINUTES = 7
|
||||
logger = logging.getLogger("db-cleaner")
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
)
|
||||
handler.setFormatter(formatter)
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def start_db_cleaner():
|
||||
global _cleaner_started
|
||||
if _cleaner_started:
|
||||
return
|
||||
_cleaner_started = True
|
||||
logger.info("Starting DB cleaner thread with interval=%ss window=%smin",
|
||||
CLEANER_INTERVAL_SECONDS, CLEANER_MAX_AGE_MINUTES)
|
||||
|
||||
def _worker():
|
||||
while True:
|
||||
try:
|
||||
result = cleanup_expired_records(max_age_minutes=CLEANER_MAX_AGE_MINUTES)
|
||||
logger.info("DB cleanup run: %s", result)
|
||||
except Exception as exc:
|
||||
logger.exception("DB cleanup failed: %s", exc)
|
||||
time.sleep(CLEANER_INTERVAL_SECONDS)
|
||||
|
||||
thread = threading.Thread(target=_worker, name="db-cleaner", daemon=True)
|
||||
thread.start()
|
||||
|
||||
|
||||
init_db()
|
||||
start_db_cleaner()
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
if 'username' not in session:
|
||||
return redirect(url_for('login'))
|
||||
if session_user(session['username']):
|
||||
return redirect(url_for('login'))
|
||||
|
||||
artworks = fetch_recent_artworks_for_user(session['user_id'])
|
||||
user_balance = get_user_balance(session['user_id'])
|
||||
|
||||
return render_template('index.html', artworks=artworks, balance=user_balance)
|
||||
|
||||
@app.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
|
||||
user = authenticate_user(username, password)
|
||||
|
||||
if user:
|
||||
session['user_id'] = user["id"]
|
||||
session['username'] = user["username"]
|
||||
flash('Успешный вход!', 'success')
|
||||
return redirect(url_for('index'))
|
||||
else:
|
||||
flash('Неверные учетные данные!', 'error')
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
|
||||
@app.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if request.method == 'POST':
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
|
||||
if not username or not password:
|
||||
flash('Заполните все поля!', 'error')
|
||||
return render_template('register.html')
|
||||
|
||||
if len(username) < 3:
|
||||
flash('Имя пользователя должно быть не менее 3 символов!', 'error')
|
||||
return render_template('register.html')
|
||||
|
||||
created, _ = create_user(username, password)
|
||||
if not created:
|
||||
flash('Пользователь с таким именем уже существует!', 'error')
|
||||
return render_template('register.html')
|
||||
|
||||
flash('Регистрация успешна!', 'success')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
return render_template('register.html')
|
||||
|
||||
|
||||
@app.route('/logout')
|
||||
def logout():
|
||||
session.clear()
|
||||
flash('Вы вышли из системы', 'success')
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@app.route('/create_artwork', methods=['POST'])
|
||||
def create_artwork():
|
||||
if 'username' not in session:
|
||||
return redirect(url_for('login'))
|
||||
if session_user(session['username']):
|
||||
return redirect(url_for('login'))
|
||||
|
||||
price = request.form.get('price', 100)
|
||||
description = request.form.get('description', '')
|
||||
is_private = 1 if request.form.get('is_private') else 0
|
||||
signature = request.form.get('signature', '')
|
||||
|
||||
title = generate_artwork_title()
|
||||
art_data = generate_suprematist_art()
|
||||
|
||||
settings_data = save_artwork_description(description) if description else None
|
||||
create_artwork_record(
|
||||
owner_id=session['user_id'],
|
||||
title=title,
|
||||
data=art_data,
|
||||
price=price,
|
||||
is_private=is_private,
|
||||
signature=signature,
|
||||
settings_data=settings_data,
|
||||
)
|
||||
if settings_data:
|
||||
_ = load_artwork_settings(settings_data)
|
||||
|
||||
flash('Картина создана!', 'success')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
@app.route('/buy/<int:artwork_id>')
|
||||
def buy_artwork(artwork_id):
|
||||
if 'username' not in session:
|
||||
return redirect(url_for('login'))
|
||||
if session_user(session['username']):
|
||||
return redirect(url_for('login'))
|
||||
|
||||
success, status = purchase_artwork(session['user_id'], artwork_id)
|
||||
|
||||
if success:
|
||||
flash('Покупка успешна!', 'success')
|
||||
else:
|
||||
if status == "not_found":
|
||||
flash('Картина не найдена!', 'error')
|
||||
elif status == "same_owner":
|
||||
flash('Нельзя купить свою картину!', 'error')
|
||||
elif status == "insufficient":
|
||||
flash('Недостаточно средств!', 'error')
|
||||
else:
|
||||
flash('Не удалось завершить покупку.', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
|
||||
@app.route('/profile')
|
||||
def profile():
|
||||
if 'username' not in session:
|
||||
return redirect(url_for('login'))
|
||||
if session_user(session['username']):
|
||||
return redirect(url_for('login'))
|
||||
|
||||
user_id = session['user_id']
|
||||
user, artworks = get_user_profile(user_id)
|
||||
|
||||
if not user:
|
||||
flash('Пользователь не найден', 'error')
|
||||
return redirect(url_for('logout'))
|
||||
|
||||
return render_template('profile.html', user=user, artworks=artworks, balance=user['balance'])
|
||||
|
||||
|
||||
@app.route('/artwork_settings/<int:artwork_id>')
|
||||
def artwork_settings(artwork_id):
|
||||
if session_user(session['username']):
|
||||
return redirect(url_for('login'))
|
||||
|
||||
artwork_data = get_artwork_with_settings(artwork_id)
|
||||
|
||||
if not artwork_data:
|
||||
flash('Артворк не найден!', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
artwork, settings_data = artwork_data
|
||||
|
||||
if artwork['owner_id'] != session['user_id']:
|
||||
flash('Нет доступа!', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
settings = None
|
||||
if settings_data:
|
||||
settings = load_artwork_settings(settings_data)
|
||||
|
||||
balance = get_user_balance(session['user_id'])
|
||||
|
||||
return render_template('artwork_settings.html', artwork=artwork, settings=settings, balance=balance)
|
||||
|
||||
|
||||
@app.route('/update_settings', methods=['POST'])
|
||||
def update_settings():
|
||||
if 'username' not in session:
|
||||
return redirect(url_for('login'))
|
||||
if session_user(session['username']):
|
||||
return redirect(url_for('login'))
|
||||
|
||||
artwork_id = request.form.get('artwork_id')
|
||||
description = request.form.get('description', '')
|
||||
|
||||
try:
|
||||
artwork_id_int = int(artwork_id)
|
||||
except (TypeError, ValueError):
|
||||
flash('Недостаточно прав!', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
owner_id = get_artwork_owner_id(artwork_id_int)
|
||||
|
||||
if owner_id and owner_id == session['user_id']:
|
||||
if description:
|
||||
settings_data = save_artwork_description(description)
|
||||
|
||||
save_artwork_settings(artwork_id_int, settings_data)
|
||||
|
||||
_ = load_artwork_settings(settings_data)
|
||||
flash('Описание обновлено!', 'success')
|
||||
else:
|
||||
flash('Введите описание!', 'error')
|
||||
else:
|
||||
flash('Недостаточно прав!', 'error')
|
||||
|
||||
return redirect(url_for('artwork_settings', artwork_id=artwork_id_int))
|
||||
|
||||
@app.route('/search')
|
||||
def search():
|
||||
if 'username' not in session:
|
||||
return redirect(url_for('login'))
|
||||
if session_user(session['username']):
|
||||
return redirect(url_for('login'))
|
||||
|
||||
query = request.args.get('q', '')
|
||||
|
||||
balance = get_user_balance(session['user_id'])
|
||||
|
||||
results = search_artworks(query) if query else []
|
||||
|
||||
return render_template('search.html', results=results, query=query, balance=balance)
|
||||
|
||||
@app.route('/import_artwork', methods=['GET', 'POST'])
|
||||
def import_artwork():
|
||||
if 'username' not in session:
|
||||
return redirect(url_for('login'))
|
||||
if session_user(session['username']):
|
||||
return redirect(url_for('login'))
|
||||
|
||||
preview_content = None
|
||||
error = None
|
||||
success = None
|
||||
fetched_url = None
|
||||
balance = get_user_balance(session['user_id'])
|
||||
|
||||
if request.method == 'POST':
|
||||
artwork_url = request.form.get('artwork_url', '').strip()
|
||||
|
||||
if artwork_url:
|
||||
is_safe, result = is_safe_url(artwork_url)
|
||||
|
||||
if not is_safe:
|
||||
error = f"Небезопасный URL: {result}"
|
||||
else:
|
||||
try:
|
||||
resp = requests.get(
|
||||
artwork_url,
|
||||
timeout=5,
|
||||
allow_redirects=True,
|
||||
headers={'User-Agent': 'Rodchenko-Gallery/1.0'}
|
||||
)
|
||||
|
||||
if resp.status_code == 200:
|
||||
preview_content = resp.text[:10000]
|
||||
fetched_url = resp.url
|
||||
|
||||
try:
|
||||
data = resp.json()
|
||||
if isinstance(data, dict) and 'shapes' in data:
|
||||
title = data.get('title', 'Импортированная композиция')
|
||||
shapes_json = json.dumps(data['shapes'])
|
||||
price = data.get('price', 100)
|
||||
|
||||
import_artwork_record(
|
||||
owner_id=session['user_id'],
|
||||
title=title,
|
||||
shapes_json=shapes_json,
|
||||
price=price,
|
||||
signature=data.get('signature', ''),
|
||||
)
|
||||
success = f"Картина '{title}' успешно импортирована!"
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
else:
|
||||
error = f"Сервер вернул статус {resp.status_code}"
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
error = "Таймаут при загрузке"
|
||||
except requests.exceptions.RequestException as e:
|
||||
error = f"Ошибка загрузки: {str(e)}"
|
||||
|
||||
return render_template('import_artwork.html',
|
||||
preview_content=preview_content,
|
||||
error=error,
|
||||
success=success,
|
||||
fetched_url=fetched_url,
|
||||
balance=balance)
|
||||
|
||||
@app.route('/healthcheck')
|
||||
def healthcheck():
|
||||
return check_connect(request.remote_addr), 200
|
||||
156
rodchenko/app/templates/artwork_settings.html
Executable file
156
rodchenko/app/templates/artwork_settings.html
Executable file
@@ -0,0 +1,156 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 40px;">
|
||||
<div>
|
||||
<h2 class="section-title">{{ artwork.title }}</h2>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">◆ Превью</div>
|
||||
<div class="card-body">
|
||||
<div class="art-preview" style="height: 350px;">
|
||||
<canvas id="settings-art" width="500" height="350"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">◼ Информация</div>
|
||||
<div class="card-body">
|
||||
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; text-align: center;">
|
||||
<div>
|
||||
<p class="text-muted" style="margin-bottom: 5px;">ID</p>
|
||||
<p style="font-size: 24px; font-weight: 700; color: var(--red);">#{{ artwork.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted" style="margin-bottom: 5px;">Цена</p>
|
||||
<p style="font-size: 24px; font-weight: 700;">{{ artwork.price }} ₽</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-muted" style="margin-bottom: 5px;">Создано</p>
|
||||
<p style="font-size: 14px; font-weight: 500;">{{ artwork.created_at[:10] if artwork.created_at else 'Недавно' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="section-title">Настройки</h3>
|
||||
|
||||
{% if settings %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">▲ Текущее описание</div>
|
||||
<div class="card-body">
|
||||
{% if settings.error %}
|
||||
<div class="info-block" style="background: var(--red);">
|
||||
Ошибка: {{ settings.error }}
|
||||
</div>
|
||||
{% elif settings.description %}
|
||||
<p style="font-size: 16px; line-height: 1.6;">{{ settings.description }}</p>
|
||||
{% else %}
|
||||
<pre style="background: var(--black); color: var(--cream); padding: 15px; overflow-x: auto; font-family: monospace; font-size: 13px; white-space: pre-wrap; word-wrap: break-word;">{{ settings }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">◉ Изменить описание</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('update_settings') }}">
|
||||
<input type="hidden" name="artwork_id" value="{{ artwork.id }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">Описание работы</label>
|
||||
<textarea class="form-control" id="description" name="description"
|
||||
rows="6" placeholder="Опишите концепцию вашей работы...">{% if settings and settings.description %}{{ settings.description }}{% endif %}</textarea>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; gap: 15px;">
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-secondary">Назад</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media (max-width: 900px) {
|
||||
div[style*="grid-template-columns: 1fr 1fr"] {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const artworkData = {{ artwork.data|tojson }};
|
||||
|
||||
function drawArt(canvas, artData) {
|
||||
try {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const shapes = JSON.parse(artData);
|
||||
|
||||
ctx.fillStyle = '#F5F0E6';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const scale = canvas.width / 100;
|
||||
|
||||
shapes.forEach(shape => {
|
||||
ctx.save();
|
||||
ctx.fillStyle = shape.color;
|
||||
ctx.strokeStyle = shape.color;
|
||||
ctx.lineWidth = 4;
|
||||
|
||||
const x = shape.x * scale;
|
||||
const y = shape.y * scale;
|
||||
const w = shape.width * scale;
|
||||
const h = shape.height * scale;
|
||||
const angle = (shape.angle || 0) * Math.PI / 180;
|
||||
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
|
||||
if (shape.type === 'rectangle' || shape.type === 'rotated_rect') {
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(angle);
|
||||
ctx.fillRect(-w/2, -h/2, w, h);
|
||||
} else if (shape.type === 'circle') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, Math.min(w, h) / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (shape.type === 'triangle') {
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(angle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -h/2);
|
||||
ctx.lineTo(w/2, h/2);
|
||||
ctx.lineTo(-w/2, h/2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
} else if (shape.type === 'line') {
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(angle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-w/2, 0);
|
||||
ctx.lineTo(w/2, 0);
|
||||
ctx.lineWidth = h || 4;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const canvas = document.getElementById('settings-art');
|
||||
if (canvas && artworkData) {
|
||||
drawArt(canvas, artworkData);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
628
rodchenko/app/templates/base.html
Executable file
628
rodchenko/app/templates/base.html
Executable file
@@ -0,0 +1,628 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>РОДЧЕНКО — Аукцион Супрематизма</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Oswald:wght@400;500;700&family=Playfair+Display:wght@700;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--red: #E41E26;
|
||||
--black: #1A1A1A;
|
||||
--cream: #F5F0E6;
|
||||
--yellow: #FFD100;
|
||||
--blue: #003366;
|
||||
--white: #FFFFFF;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Oswald', sans-serif;
|
||||
background: var(--cream);
|
||||
color: var(--black);
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: -100px;
|
||||
right: -100px;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
background: var(--red);
|
||||
opacity: 0.1;
|
||||
transform: rotate(45deg);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
body::after {
|
||||
content: '';
|
||||
position: fixed;
|
||||
bottom: -50px;
|
||||
left: -50px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
background: var(--black);
|
||||
border-radius: 50%;
|
||||
opacity: 0.05;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: var(--black);
|
||||
padding: 0;
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 0 var(--red);
|
||||
}
|
||||
|
||||
.navbar-inner {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 15px 30px;
|
||||
background: var(--red);
|
||||
text-decoration: none;
|
||||
position: relative;
|
||||
clip-path: polygon(0 0, calc(100% - 20px) 0, 100% 100%, 0 100%);
|
||||
padding-right: 50px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
background: #C41920;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-weight: 900;
|
||||
font-size: 28px;
|
||||
color: var(--white);
|
||||
letter-spacing: 4px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.logo-sub {
|
||||
font-size: 10px;
|
||||
color: var(--yellow);
|
||||
letter-spacing: 2px;
|
||||
margin-left: 15px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 25px;
|
||||
color: var(--cream);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background: var(--red);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.nav-link::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 3px;
|
||||
background: var(--yellow);
|
||||
transition: all 0.2s ease;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.nav-link:hover::after {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-balance {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 30px;
|
||||
background: var(--yellow);
|
||||
color: var(--black);
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
letter-spacing: 1px;
|
||||
clip-path: polygon(20px 0, 100% 0, 100% 100%, 0 100%);
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.balance-icon {
|
||||
margin-right: 8px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
width: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
padding: 40px 30px;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.alerts {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 15px 25px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
animation: slideIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(-20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.alert-success {
|
||||
background: var(--black);
|
||||
color: var(--yellow);
|
||||
border-left: 5px solid var(--yellow);
|
||||
}
|
||||
|
||||
.alert-danger {
|
||||
background: var(--red);
|
||||
color: var(--white);
|
||||
border-left: 5px solid var(--black);
|
||||
}
|
||||
|
||||
.alert-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.alert-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
font-family: 'Oswald', sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--red);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #C41920;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 0 var(--black);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--black);
|
||||
color: var(--cream);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #333;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 0 var(--red);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: var(--yellow);
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #E5BC00;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 0 var(--black);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: var(--black);
|
||||
border: 2px solid var(--black);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--black);
|
||||
color: var(--cream);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 8px 16px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--white);
|
||||
border: none;
|
||||
box-shadow: 8px 8px 0 var(--black);
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translate(-4px, -4px);
|
||||
box-shadow: 12px 12px 0 var(--black);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background: var(--black);
|
||||
color: var(--cream);
|
||||
padding: 15px 20px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 25px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
margin-bottom: 15px;
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 12px 15px;
|
||||
font-family: 'Oswald', sans-serif;
|
||||
font-size: 16px;
|
||||
border: 2px solid var(--black);
|
||||
background: var(--cream);
|
||||
color: var(--black);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--red);
|
||||
box-shadow: 4px 4px 0 var(--red);
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
textarea.form-control {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.form-text {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: var(--yellow);
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: var(--blue);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5 {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-weight: 700;
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32px;
|
||||
margin-bottom: 30px;
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.section-title::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -10px;
|
||||
left: 0;
|
||||
width: 60px;
|
||||
height: 5px;
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-price {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--black);
|
||||
}
|
||||
|
||||
.text-price span {
|
||||
color: var(--red);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.grid-3 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.grid-4 {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.grid-4 {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid-3, .grid-4 {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.grid-2, .grid-3, .grid-4 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.art-preview {
|
||||
background: var(--white);
|
||||
border: 3px solid var(--black);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.art-preview canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.decorative-line {
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: repeating-linear-gradient(
|
||||
90deg,
|
||||
var(--red) 0px,
|
||||
var(--red) 20px,
|
||||
var(--black) 20px,
|
||||
var(--black) 40px
|
||||
);
|
||||
margin: 30px 0;
|
||||
}
|
||||
|
||||
.info-block {
|
||||
background: var(--black);
|
||||
color: var(--cream);
|
||||
padding: 15px 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.info-block code {
|
||||
background: var(--red);
|
||||
color: var(--white);
|
||||
padding: 2px 8px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.layout-sidebar {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 380px;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.layout-sidebar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-center {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.mb-1 { margin-bottom: 10px; }
|
||||
.mb-2 { margin-bottom: 20px; }
|
||||
.mb-3 { margin-bottom: 30px; }
|
||||
.mt-2 { margin-top: 20px; }
|
||||
.mt-3 { margin-top: 30px; }
|
||||
|
||||
.footer {
|
||||
background: var(--black);
|
||||
color: var(--cream);
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
margin-top: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.footer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 5px;
|
||||
background: var(--red);
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 12px;
|
||||
letter-spacing: 3px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.5s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.diagonal-stripe {
|
||||
position: absolute;
|
||||
width: 200%;
|
||||
height: 60px;
|
||||
background: var(--red);
|
||||
transform: rotate(-3deg);
|
||||
left: -50%;
|
||||
z-index: -1;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="navbar-inner">
|
||||
<a href="{{ url_for('index') }}" class="logo">
|
||||
<span class="logo-text">Родченко</span>
|
||||
<span class="logo-sub">Аукцион</span>
|
||||
</a>
|
||||
|
||||
<div class="nav-links">
|
||||
{% if session.user_id %}
|
||||
<a class="nav-link" href="{{ url_for('search') }}">Поиск</a>
|
||||
<a class="nav-link" href="{{ url_for('profile') }}">Профиль</a>
|
||||
<a class="nav-link" href="{{ url_for('logout') }}">Выход</a>
|
||||
<div class="nav-balance">
|
||||
<span class="balance-icon">◆</span>
|
||||
{{ balance if balance else 0 }} ₽
|
||||
</div>
|
||||
{% else %}
|
||||
<a class="nav-link" href="{{ url_for('login') }}">Вход</a>
|
||||
<a class="nav-link" href="{{ url_for('register') }}">Регистрация</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="main-content fade-in">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="alerts">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else 'success' }}">
|
||||
<span>{{ message }}</span>
|
||||
<button class="alert-close" onclick="this.parentElement.remove()">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<p class="footer-text">MCTF 2025 • Супрематизм • Искусство Будущего</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
57
rodchenko/app/templates/import_artwork.html
Executable file
57
rodchenko/app/templates/import_artwork.html
Executable file
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container" style="max-width: 800px; margin: 0 auto;">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">◼ Импорт картины по URL</div>
|
||||
<div class="card-body">
|
||||
<p>Загрузите супрематическую композицию с внешнего ресурса.</p>
|
||||
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control" name="artwork_url"
|
||||
placeholder="https://example.com/artwork.json" value="">
|
||||
<p class="text-muted" style="font-size: 12px; margin-top: 5px;">
|
||||
Укажите URL файла с данными картины в формате JSON.
|
||||
</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Загрузить</button>
|
||||
</form>
|
||||
|
||||
<div style="background: #f5f5f5; padding: 15px; font-family: monospace; font-size: 12px; margin-top: 20px;">
|
||||
<strong>Формат JSON:</strong><br>
|
||||
{"title": "Название", "price": 100, "signature": "Подпись",<br>
|
||||
"shapes": [{"type": "rectangle", "x": 10, "y": 10, "width": 30, "height": 20, "color": "#E41E26"}]}
|
||||
</div>
|
||||
|
||||
<p style="margin-top: 20px;">
|
||||
<a href="{{ url_for('index') }}" style="color: var(--red);">← Вернуться в галерею</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if success %}
|
||||
<div class="card mb-3" style="background: #28a745; color: white;">
|
||||
<div class="card-body">{{ success }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if error %}
|
||||
<div class="card mb-3" style="background: var(--red); color: white;">
|
||||
<div class="card-body">{{ error }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if preview_content and not success %}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
Ответ сервера{% if fetched_url %} ({{ fetched_url }}){% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<pre style="background: #1a1a1a; color: #f5f0e6; padding: 20px; max-height: 400px; overflow: auto; white-space: pre-wrap; word-break: break-all;">{{ preview_content }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
182
rodchenko/app/templates/index.html
Executable file
182
rodchenko/app/templates/index.html
Executable file
@@ -0,0 +1,182 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="layout-sidebar">
|
||||
<div>
|
||||
<h2 class="section-title">Галерея</h2>
|
||||
|
||||
{% if artworks %}
|
||||
<div class="grid grid-2">
|
||||
{% for art in artworks %}
|
||||
<div class="card">
|
||||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>#{{ art.id }} • {{ art.created_at[:10] if art.created_at else 'Новое' }}</span>
|
||||
{% if art.is_private %}
|
||||
<span style="background: var(--red); color: white; padding: 2px 8px; font-size: 10px;">ПРИВАТНАЯ</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ art.title }}</h5>
|
||||
|
||||
<div class="art-preview mb-2" style="height: 180px;">
|
||||
<canvas id="art-{{ art.id }}" width="400" height="180"></canvas>
|
||||
</div>
|
||||
|
||||
{% if art.signature %}
|
||||
<div class="artwork-signature mb-2" style="padding: 10px; background: #f0f0f0; border-left: 3px solid var(--red); font-style: italic;">
|
||||
<small style="color: #666;">Подпись художника:</small><br>
|
||||
{{ art.signature }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="text-price mb-2">
|
||||
{{ art.price }} <span>₽</span>
|
||||
</div>
|
||||
|
||||
<p class="text-muted mb-2">
|
||||
Владелец:
|
||||
{% if art.owner_id == session.user_id %}
|
||||
<strong>Вы</strong>
|
||||
{% else %}
|
||||
{{ art.owner_name }}
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
{% if art.owner_id != session.user_id %}
|
||||
<a href="{{ url_for('buy_artwork', artwork_id=art.id) }}" class="btn btn-primary">Купить</a>
|
||||
{% else %}
|
||||
<span class="badge badge-success">Ваша работа</span>
|
||||
<a href="{{ url_for('artwork_settings', artwork_id=art.id) }}" class="btn btn-outline btn-small" style="margin-left: 10px;">Настройки</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="info-block">
|
||||
<p>Галерея пуста. Станьте первым художником — создайте свою супрематическую композицию!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<aside>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">◼ Создать работу</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('create_artwork') }}">
|
||||
<div class="form-group">
|
||||
<label for="price" class="form-label">Цена</label>
|
||||
<input type="number" class="form-control" id="price" name="price" value="100" min="1" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="signature" class="form-label">Подпись художника</label>
|
||||
<textarea class="form-control" id="signature" name="signature"
|
||||
rows="2" placeholder="Ваша творческая подпись..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description" class="form-label">Описание (опционально)</label>
|
||||
<textarea class="form-control" id="description" name="description"
|
||||
rows="2" placeholder="Концепция работы..."></textarea>
|
||||
</div>
|
||||
<div class="form-group" style="display: flex; align-items: center; gap: 10px;">
|
||||
<input type="checkbox" id="is_private" name="is_private" value="1" style="width: 20px; height: 20px;">
|
||||
<label for="is_private" style="margin: 0; cursor: pointer;">Приватная работа</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success" style="width: 100%;">Создать композицию</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="card-header">◉ Поиск</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('search') }}" method="GET">
|
||||
<div class="form-group">
|
||||
<input type="text" class="form-control" name="q" placeholder="Название или данные...">
|
||||
</div>
|
||||
<button class="btn btn-secondary" type="submit" style="width: 100%;">Найти</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">▲ Действия</div>
|
||||
<div class="card-body">
|
||||
<a href="{{ url_for('profile') }}" class="btn btn-outline mb-1" style="width: 100%;">Мой профиль</a>
|
||||
<a href="{{ url_for('import_artwork') }}" class="btn btn-outline" style="width: 100%;">Импорт картины</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const artworksData = {{ artworks|tojson }};
|
||||
|
||||
function drawArt(canvas, artData) {
|
||||
try {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const shapes = JSON.parse(artData);
|
||||
|
||||
ctx.fillStyle = '#F5F0E6';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const scale = canvas.width / 100;
|
||||
|
||||
shapes.forEach(shape => {
|
||||
ctx.save();
|
||||
ctx.fillStyle = shape.color;
|
||||
ctx.strokeStyle = shape.color;
|
||||
ctx.lineWidth = 3;
|
||||
|
||||
const x = shape.x * scale;
|
||||
const y = shape.y * scale;
|
||||
const w = shape.width * scale;
|
||||
const h = shape.height * scale;
|
||||
const angle = (shape.angle || 0) * Math.PI / 180;
|
||||
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
|
||||
if (shape.type === 'rectangle' || shape.type === 'rotated_rect') {
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(angle);
|
||||
ctx.fillRect(-w/2, -h/2, w, h);
|
||||
} else if (shape.type === 'circle') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, Math.min(w, h) / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (shape.type === 'triangle') {
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(angle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -h/2);
|
||||
ctx.lineTo(w/2, h/2);
|
||||
ctx.lineTo(-w/2, h/2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
} else if (shape.type === 'line') {
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(angle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-w/2, 0);
|
||||
ctx.lineTo(w/2, 0);
|
||||
ctx.lineWidth = h || 4;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Drawing error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
artworksData.forEach(art => {
|
||||
const canvas = document.getElementById('art-' + art.id);
|
||||
if (canvas) {
|
||||
drawArt(canvas, art.data);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
66
rodchenko/app/templates/login.html
Executable file
66
rodchenko/app/templates/login.html
Executable file
@@ -0,0 +1,66 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="layout-center">
|
||||
<div style="text-align: center; margin-bottom: 40px;">
|
||||
<h1 style="font-size: 48px; margin-bottom: 10px;">ВХОД</h1>
|
||||
<div class="decorative-line" style="width: 100px; margin: 0 auto;"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">◆ Авторизация</div>
|
||||
<div class="card-body">
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label">Имя пользователя</label>
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
placeholder="Введите логин" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">Пароль</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
placeholder="Введите пароль" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" style="width: 100%;">Войти</button>
|
||||
</form>
|
||||
|
||||
<div class="decorative-line mt-3"></div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<p class="text-muted">Нет аккаунта?</p>
|
||||
<a href="{{ url_for('register') }}" class="btn btn-outline">Зарегистрироваться</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Декоративные фигуры для страницы входа */
|
||||
.layout-center {
|
||||
position: relative;
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.layout-center::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--red);
|
||||
transform: rotate(15deg);
|
||||
}
|
||||
|
||||
.layout-center::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -50px;
|
||||
right: -80px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 8px solid var(--black);
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
136
rodchenko/app/templates/profile.html
Executable file
136
rodchenko/app/templates/profile.html
Executable file
@@ -0,0 +1,136 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card mb-3" style="background: var(--black); color: var(--cream);">
|
||||
<div class="card-body" style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 20px;">
|
||||
<div>
|
||||
<p style="font-size: 12px; letter-spacing: 2px; text-transform: uppercase; opacity: 0.7; margin-bottom: 5px;">Профиль</p>
|
||||
<h2 style="font-family: 'Playfair Display', serif; font-size: 36px; color: var(--cream); margin: 0;">
|
||||
{{ user.username }}
|
||||
</h2>
|
||||
</div>
|
||||
<div style="display: flex; gap: 40px; align-items: center;">
|
||||
<div style="text-align: center;">
|
||||
<p style="font-size: 12px; letter-spacing: 1px; text-transform: uppercase; opacity: 0.7;">ID</p>
|
||||
<p style="font-size: 24px; font-weight: 700; color: var(--yellow);">#{{ user.id }}</p>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<p style="font-size: 12px; letter-spacing: 1px; text-transform: uppercase; opacity: 0.7;">Баланс</p>
|
||||
<p style="font-size: 24px; font-weight: 700; color: var(--yellow);">{{ user.balance }} ₽</p>
|
||||
</div>
|
||||
<div style="text-align: center;">
|
||||
<p style="font-size: 12px; letter-spacing: 1px; text-transform: uppercase; opacity: 0.7;">Работ</p>
|
||||
<p style="font-size: 24px; font-weight: 700; color: var(--yellow);">{{ artworks|length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="section-title">Коллекция</h3>
|
||||
|
||||
{% if artworks %}
|
||||
<div class="grid grid-3">
|
||||
{% for art in artworks %}
|
||||
<div class="card">
|
||||
<div class="card-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>#{{ art.id }}</span>
|
||||
{% if art.is_private %}
|
||||
<span style="background: var(--red); color: white; padding: 2px 8px; font-size: 10px;">ПРИВАТНАЯ</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ art.title }}</h5>
|
||||
<div class="art-preview mb-2" style="height: 160px;">
|
||||
<canvas id="profile-art-{{ art.id }}" width="350" height="160"></canvas>
|
||||
</div>
|
||||
{% if art.signature %}
|
||||
<div class="artwork-signature mb-2" style="padding: 8px; background: #f0f0f0; border-left: 3px solid var(--red); font-style: italic; font-size: 13px;">
|
||||
{{ art.signature }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="text-price mb-1">
|
||||
{{ art.price }} <span>₽</span>
|
||||
</div>
|
||||
<p class="text-muted mb-2">{{ art.created_at[:16] if art.created_at else 'Недавно' }}</p>
|
||||
<a href="{{ url_for('artwork_settings', artwork_id=art.id) }}" class="btn btn-outline btn-small">Настройки</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="info-block">
|
||||
<p>У вас пока нет работ. <a href="{{ url_for('index') }}" style="color: var(--yellow);">Создайте первую!</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
const profileArtworksData = {{ artworks|tojson }};
|
||||
|
||||
function drawArt(canvas, artData) {
|
||||
try {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const shapes = JSON.parse(artData);
|
||||
|
||||
ctx.fillStyle = '#F5F0E6';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const scale = canvas.width / 100;
|
||||
|
||||
shapes.forEach(shape => {
|
||||
ctx.save();
|
||||
ctx.fillStyle = shape.color;
|
||||
ctx.strokeStyle = shape.color;
|
||||
ctx.lineWidth = 3;
|
||||
|
||||
const x = shape.x * scale;
|
||||
const y = shape.y * scale;
|
||||
const w = shape.width * scale;
|
||||
const h = shape.height * scale;
|
||||
const angle = (shape.angle || 0) * Math.PI / 180;
|
||||
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
|
||||
if (shape.type === 'rectangle' || shape.type === 'rotated_rect') {
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(angle);
|
||||
ctx.fillRect(-w/2, -h/2, w, h);
|
||||
} else if (shape.type === 'circle') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, Math.min(w, h) / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (shape.type === 'triangle') {
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(angle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -h/2);
|
||||
ctx.lineTo(w/2, h/2);
|
||||
ctx.lineTo(-w/2, h/2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
} else if (shape.type === 'line') {
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(angle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-w/2, 0);
|
||||
ctx.lineTo(w/2, 0);
|
||||
ctx.lineWidth = h || 4;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
profileArtworksData.forEach(art => {
|
||||
const canvas = document.getElementById('profile-art-' + art.id);
|
||||
if (canvas) {
|
||||
drawArt(canvas, art.data);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
73
rodchenko/app/templates/register.html
Executable file
73
rodchenko/app/templates/register.html
Executable file
@@ -0,0 +1,73 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="layout-center">
|
||||
<div style="text-align: center; margin-bottom: 40px;">
|
||||
<h1 style="font-size: 42px; margin-bottom: 10px;">РЕГИСТРАЦИЯ</h1>
|
||||
<div class="decorative-line" style="width: 100px; margin: 0 auto;"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">◼ Новый художник</div>
|
||||
<div class="card-body">
|
||||
<div class="info-block mb-3">
|
||||
<p style="font-size: 14px;">
|
||||
Присоединяйтесь к миру супрематизма!<br>
|
||||
Начальный баланс: <strong>1000 ₽</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form method="POST">
|
||||
<div class="form-group">
|
||||
<label for="username" class="form-label">Имя пользователя</label>
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
placeholder="Придумайте логин" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password" class="form-label">Пароль</label>
|
||||
<input type="password" class="form-control" id="password" name="password"
|
||||
placeholder="Придумайте пароль" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success" style="width: 100%;">Создать аккаунт</button>
|
||||
</form>
|
||||
|
||||
<div class="decorative-line mt-3"></div>
|
||||
|
||||
<div style="text-align: center;">
|
||||
<p class="text-muted">Уже зарегистрированы?</p>
|
||||
<a href="{{ url_for('login') }}" class="btn btn-outline">Войти</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.layout-center {
|
||||
position: relative;
|
||||
padding-top: 60px;
|
||||
}
|
||||
|
||||
.layout-center::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
right: -100px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: var(--yellow);
|
||||
transform: rotate(-10deg);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.layout-center::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -30px;
|
||||
left: -60px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: var(--black);
|
||||
z-index: -1;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
121
rodchenko/app/templates/search.html
Executable file
121
rodchenko/app/templates/search.html
Executable file
@@ -0,0 +1,121 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="section-title">Поиск</h2>
|
||||
|
||||
<form action="{{ url_for('search') }}" method="GET" class="mb-3" style="width: 100%;">
|
||||
<div style="display: flex; gap: 20px; width: 100%;">
|
||||
<input type="text" name="q" value="{{ query }}"
|
||||
placeholder="Поиск по названию или данным..."
|
||||
style="flex-grow: 1; width: calc(100% - 160px); padding: 15px 20px; font-size: 16px; border: 2px solid #1A1A1A; font-family: 'Oswald', sans-serif; background: #F5F0E6;">
|
||||
<button class="btn btn-primary" type="submit" style="padding: 15px 40px; flex-shrink: 0;">Найти</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if results %}
|
||||
<div class="info-block mb-3">
|
||||
<p>Найдено работ: <strong>{{ results|length }}</strong> по запросу «{{ query }}»</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-3">
|
||||
{% for art in results %}
|
||||
<div class="card">
|
||||
<div class="card-header">#{{ art.id }}</div>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{ art.title }}</h5>
|
||||
<div class="art-preview mb-2" style="height: 160px;">
|
||||
<canvas id="search-art-{{ art.id }}" width="350" height="160"></canvas>
|
||||
</div>
|
||||
<div class="text-price mb-2">
|
||||
{{ art.price }} <span>₽</span>
|
||||
</div>
|
||||
{% if art.owner_id != session.user_id %}
|
||||
<a href="{{ url_for('buy_artwork', artwork_id=art.id) }}" class="btn btn-primary">Купить</a>
|
||||
{% else %}
|
||||
<span class="badge badge-success">Ваша работа</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% elif query %}
|
||||
<div class="info-block">
|
||||
<p>По запросу «<strong>{{ query }}</strong>» ничего не найдено.</p>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="info-block">
|
||||
<p>Введите поисковый запрос для поиска работ.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
const searchResultsData = {{ results|tojson }};
|
||||
|
||||
function drawArt(canvas, artData) {
|
||||
try {
|
||||
const ctx = canvas.getContext('2d');
|
||||
const shapes = JSON.parse(artData);
|
||||
|
||||
ctx.fillStyle = '#F5F0E6';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const scale = canvas.width / 100;
|
||||
|
||||
shapes.forEach(shape => {
|
||||
ctx.save();
|
||||
ctx.fillStyle = shape.color;
|
||||
ctx.strokeStyle = shape.color;
|
||||
ctx.lineWidth = 3;
|
||||
|
||||
const x = shape.x * scale;
|
||||
const y = shape.y * scale;
|
||||
const w = shape.width * scale;
|
||||
const h = shape.height * scale;
|
||||
const angle = (shape.angle || 0) * Math.PI / 180;
|
||||
|
||||
const cx = x + w / 2;
|
||||
const cy = y + h / 2;
|
||||
|
||||
if (shape.type === 'rectangle' || shape.type === 'rotated_rect') {
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(angle);
|
||||
ctx.fillRect(-w/2, -h/2, w, h);
|
||||
} else if (shape.type === 'circle') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, Math.min(w, h) / 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (shape.type === 'triangle') {
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(angle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -h/2);
|
||||
ctx.lineTo(w/2, h/2);
|
||||
ctx.lineTo(-w/2, h/2);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
} else if (shape.type === 'line') {
|
||||
ctx.translate(cx, cy);
|
||||
ctx.rotate(angle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-w/2, 0);
|
||||
ctx.lineTo(w/2, 0);
|
||||
ctx.lineWidth = h || 4;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
searchResultsData.forEach(art => {
|
||||
const canvas = document.getElementById('search-art-' + art.id);
|
||||
if (canvas && art.data) {
|
||||
drawArt(canvas, art.data);
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
2
rodchenko/app/utils/__init__.py
Executable file
2
rodchenko/app/utils/__init__.py
Executable file
@@ -0,0 +1,2 @@
|
||||
# utils package
|
||||
|
||||
144
rodchenko/app/utils/art.py
Executable file
144
rodchenko/app/utils/art.py
Executable file
@@ -0,0 +1,144 @@
|
||||
import json
|
||||
import random
|
||||
|
||||
|
||||
def generate_suprematist_art():
|
||||
|
||||
palettes = [
|
||||
['#E41E26', '#000000', '#1A1A1A', '#FFD100'],
|
||||
['#E41E26', '#000000', '#FFFFFF', '#003366'],
|
||||
['#E41E26', '#FF6B35', '#000000', '#FFD100', '#1A1A1A'],
|
||||
['#003366', '#000000', '#4A90D9', '#1A1A1A', '#708090'],
|
||||
['#E41E26', '#FF6B35', '#FFD100', '#000000'],
|
||||
]
|
||||
|
||||
palette = random.choice(palettes)
|
||||
composition = []
|
||||
|
||||
comp_type = random.choice(['diagonal', 'centered', 'scattered', 'layered', 'cross'])
|
||||
|
||||
if comp_type == 'diagonal':
|
||||
composition.append({
|
||||
'type': 'rotated_rect',
|
||||
'color': palette[0],
|
||||
'x': random.randint(5, 20),
|
||||
'y': random.randint(30, 50),
|
||||
'width': random.randint(60, 80),
|
||||
'height': random.randint(8, 15),
|
||||
'angle': random.randint(-45, -25)
|
||||
})
|
||||
for _ in range(random.randint(2, 4)):
|
||||
composition.append({
|
||||
'type': random.choice(['rotated_rect', 'rectangle']),
|
||||
'color': random.choice(palette),
|
||||
'x': random.randint(10, 70),
|
||||
'y': random.randint(10, 70),
|
||||
'width': random.randint(15, 40),
|
||||
'height': random.randint(5, 15),
|
||||
'angle': random.randint(-60, 60)
|
||||
})
|
||||
|
||||
elif comp_type == 'centered':
|
||||
main_shape = random.choice(['rectangle', 'circle'])
|
||||
composition.append({
|
||||
'type': main_shape,
|
||||
'color': palette[0],
|
||||
'x': random.randint(25, 35),
|
||||
'y': random.randint(20, 35),
|
||||
'width': random.randint(30, 45),
|
||||
'height': random.randint(25, 40),
|
||||
'angle': 0
|
||||
})
|
||||
for _ in range(random.randint(3, 6)):
|
||||
composition.append({
|
||||
'type': random.choice(['rectangle', 'circle', 'triangle']),
|
||||
'color': random.choice(palette[1:]),
|
||||
'x': random.randint(5, 85),
|
||||
'y': random.randint(5, 80),
|
||||
'width': random.randint(8, 20),
|
||||
'height': random.randint(8, 20),
|
||||
'angle': random.randint(-30, 30)
|
||||
})
|
||||
|
||||
elif comp_type == 'scattered':
|
||||
for _ in range(random.randint(5, 9)):
|
||||
shape_type = random.choice(['rectangle', 'circle', 'triangle', 'rotated_rect'])
|
||||
size = random.randint(10, 30)
|
||||
composition.append({
|
||||
'type': shape_type,
|
||||
'color': random.choice(palette),
|
||||
'x': random.randint(5, 75),
|
||||
'y': random.randint(5, 70),
|
||||
'width': size,
|
||||
'height': size if shape_type == 'circle' else random.randint(8, 30),
|
||||
'angle': random.randint(-45, 45) if 'rect' in shape_type else 0
|
||||
})
|
||||
|
||||
elif comp_type == 'layered':
|
||||
base_x, base_y = random.randint(15, 30), random.randint(15, 30)
|
||||
for i in range(random.randint(3, 5)):
|
||||
offset = i * random.randint(8, 15)
|
||||
composition.append({
|
||||
'type': 'rectangle',
|
||||
'color': palette[i % len(palette)],
|
||||
'x': base_x + offset,
|
||||
'y': base_y + offset // 2,
|
||||
'width': random.randint(25, 45),
|
||||
'height': random.randint(20, 35),
|
||||
'angle': 0
|
||||
})
|
||||
for _ in range(random.randint(1, 3)):
|
||||
composition.append({
|
||||
'type': 'circle',
|
||||
'color': palette[0],
|
||||
'x': random.randint(50, 80),
|
||||
'y': random.randint(10, 60),
|
||||
'width': random.randint(10, 20),
|
||||
'height': random.randint(10, 20),
|
||||
'angle': 0
|
||||
})
|
||||
|
||||
else:
|
||||
center_x, center_y = random.randint(35, 50), random.randint(30, 45)
|
||||
composition.append({
|
||||
'type': 'rectangle',
|
||||
'color': palette[0],
|
||||
'x': 5,
|
||||
'y': center_y,
|
||||
'width': 90,
|
||||
'height': random.randint(10, 18),
|
||||
'angle': 0
|
||||
})
|
||||
composition.append({
|
||||
'type': 'rectangle',
|
||||
'color': random.choice(palette[1:3]),
|
||||
'x': center_x,
|
||||
'y': 5,
|
||||
'width': random.randint(12, 20),
|
||||
'height': 85,
|
||||
'angle': 0
|
||||
})
|
||||
for _ in range(random.randint(2, 4)):
|
||||
composition.append({
|
||||
'type': random.choice(['circle', 'rectangle', 'triangle']),
|
||||
'color': random.choice(palette),
|
||||
'x': random.randint(5, 80),
|
||||
'y': random.randint(5, 75),
|
||||
'width': random.randint(8, 18),
|
||||
'height': random.randint(8, 18),
|
||||
'angle': random.randint(-20, 20)
|
||||
})
|
||||
|
||||
return json.dumps(composition)
|
||||
|
||||
|
||||
def generate_artwork_title():
|
||||
prefixes = [
|
||||
'Супрематическая композиция',
|
||||
'Динамические формы',
|
||||
'Геометрическая абстракция',
|
||||
'Цветовой контраст',
|
||||
'Пространственная структура'
|
||||
]
|
||||
return f"{random.choice(prefixes)} №{random.randint(1, 1000)}"
|
||||
|
||||
475
rodchenko/app/utils/db.py
Executable file
475
rodchenko/app/utils/db.py
Executable file
@@ -0,0 +1,475 @@
|
||||
import sqlite3
|
||||
import hashlib
|
||||
import ipaddress
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
DATABASE = 'data/auction.db'
|
||||
|
||||
|
||||
def get_db():
|
||||
return sqlite3.connect(DATABASE)
|
||||
|
||||
|
||||
def _table_has_column(cursor, table, column):
|
||||
cursor.execute(f"PRAGMA table_info({table})")
|
||||
return any(row[1] == column for row in cursor.fetchall())
|
||||
|
||||
|
||||
def _ensure_created_at(conn, table, backfill_age_minutes=None):
|
||||
c = conn.cursor()
|
||||
if not _table_has_column(c, table, "created_at"):
|
||||
c.execute(
|
||||
f"ALTER TABLE {table} ADD COLUMN created_at TIMESTAMP"
|
||||
)
|
||||
if backfill_age_minutes is None:
|
||||
c.execute(
|
||||
f"UPDATE {table} SET created_at = CURRENT_TIMESTAMP WHERE created_at IS NULL"
|
||||
)
|
||||
else:
|
||||
c.execute(
|
||||
f"UPDATE {table} SET created_at = datetime('now', ?) WHERE created_at IS NULL",
|
||||
(f"-{backfill_age_minutes} minutes",),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _delete_by_age(cursor, table, column, max_age_minutes, extra_where=None):
|
||||
cutoff_delta = f"-{max_age_minutes} minutes"
|
||||
extra = f" AND {extra_where}" if extra_where else ""
|
||||
cursor.execute(
|
||||
f"DELETE FROM {table} WHERE datetime({column}) < datetime('now', ?){extra}",
|
||||
(cutoff_delta,),
|
||||
)
|
||||
return cursor.rowcount
|
||||
|
||||
|
||||
def cleanup_expired_records(max_age_minutes=7):
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
try:
|
||||
for table in ("users", "artworks", "transactions", "artwork_settings"):
|
||||
# Backfill to "old enough" so legacy rows get cleaned on the next cycle.
|
||||
_ensure_created_at(conn, table, backfill_age_minutes=max_age_minutes + 1)
|
||||
|
||||
deletions = {}
|
||||
for table in ("artworks", "transactions", "artwork_settings", "users"):
|
||||
if _table_has_column(c, table, "created_at"):
|
||||
extra_where = "username != 'admin'" if table == "users" else None
|
||||
deletions[table] = _delete_by_age(
|
||||
c,
|
||||
table=table,
|
||||
column="created_at",
|
||||
max_age_minutes=max_age_minutes,
|
||||
extra_where=extra_where,
|
||||
)
|
||||
else:
|
||||
deletions[table] = 0
|
||||
|
||||
c.execute(
|
||||
"DELETE FROM artwork_settings WHERE artwork_id NOT IN (SELECT id FROM artworks)"
|
||||
)
|
||||
deleted_settings = c.rowcount
|
||||
|
||||
conn.commit()
|
||||
return {
|
||||
"artworks": deletions.get("artworks", 0),
|
||||
"transactions": deletions.get("transactions", 0),
|
||||
"artwork_settings": deletions.get("artwork_settings", 0)
|
||||
+ deleted_settings,
|
||||
"users": deletions.get("users", 0),
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def init_db():
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS users
|
||||
(id INTEGER PRIMARY KEY, username TEXT UNIQUE, password TEXT, balance INTEGER DEFAULT 1000,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||||
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS artworks
|
||||
(id INTEGER PRIMARY KEY, title TEXT, data TEXT, price INTEGER,
|
||||
owner_id INTEGER, is_private INTEGER DEFAULT 0, signature TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||||
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS transactions
|
||||
(id INTEGER PRIMARY KEY, artwork_id INTEGER, buyer_id INTEGER,
|
||||
seller_id INTEGER, amount INTEGER, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||||
|
||||
c.execute('''CREATE TABLE IF NOT EXISTS artwork_settings
|
||||
(id INTEGER PRIMARY KEY, artwork_id INTEGER, settings_data TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
||||
|
||||
# Schema guards for older databases
|
||||
for table in ("users", "artworks", "transactions", "artwork_settings"):
|
||||
_ensure_created_at(conn, table)
|
||||
|
||||
c.execute("INSERT OR IGNORE INTO users (username, password, balance) VALUES (?, ?, ?)",
|
||||
('admin', hashlib.md5('hJAED8FUUoj6tYbyQkRkAqni'.encode()).hexdigest(), 999999))
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_user_balance(user_id):
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT balance FROM users WHERE id = ?", (user_id,))
|
||||
result = c.fetchone()
|
||||
conn.close()
|
||||
return result[0] if result else 0
|
||||
|
||||
|
||||
def fetch_recent_artworks_for_user(user_id: int, limit: int = 20) -> List[Dict]:
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute(
|
||||
"""
|
||||
SELECT a.id, a.title, a.data, a.price, a.owner_id, a.is_private,
|
||||
a.signature, a.created_at, u.username
|
||||
FROM artworks a
|
||||
LEFT JOIN users u ON a.owner_id = u.id
|
||||
WHERE a.is_private = 0 OR a.owner_id = ?
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(user_id, limit),
|
||||
)
|
||||
artworks_data = c.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": art[0],
|
||||
"title": art[1],
|
||||
"data": art[2],
|
||||
"price": art[3],
|
||||
"owner_id": art[4],
|
||||
"is_private": art[5],
|
||||
"signature": art[6] or "",
|
||||
"created_at": art[7],
|
||||
"owner_name": art[8],
|
||||
}
|
||||
for art in artworks_data
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def authenticate_user(username: str, password: str) -> Optional[Dict]:
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute(
|
||||
"SELECT id, username, balance FROM users WHERE username = ? AND password = ?",
|
||||
(username, hashlib.md5(password.encode()).hexdigest()),
|
||||
)
|
||||
user = c.fetchone()
|
||||
if user:
|
||||
return {"id": user[0], "username": user[1], "balance": user[2]}
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def session_user(username: str) -> Optional[Dict]:
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute(
|
||||
"SELECT id, username, balance FROM users WHERE username = ?",
|
||||
[username],
|
||||
)
|
||||
user = c.fetchone()
|
||||
if user:
|
||||
return None
|
||||
return True
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_user(username: str, password: str) -> Tuple[bool, Optional[int]]:
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("SELECT id FROM users WHERE username = ?", (username,))
|
||||
if c.fetchone():
|
||||
return False, None
|
||||
c.execute(
|
||||
"INSERT INTO users (username, password) VALUES (?, ?)",
|
||||
(username, hashlib.md5(password.encode()).hexdigest()),
|
||||
)
|
||||
conn.commit()
|
||||
return True, c.lastrowid
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def create_artwork_record(
|
||||
owner_id: int,
|
||||
title: str,
|
||||
data: str,
|
||||
price: int,
|
||||
is_private: int,
|
||||
signature: str,
|
||||
settings_data: Optional[str] = None,
|
||||
created_at: Optional[str] = None,
|
||||
) -> int:
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
if created_at:
|
||||
c.execute(
|
||||
"""
|
||||
INSERT INTO artworks (title, data, price, owner_id, is_private, signature, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(title, data, price, owner_id, is_private, signature, created_at),
|
||||
)
|
||||
else:
|
||||
c.execute(
|
||||
"""
|
||||
INSERT INTO artworks (title, data, price, owner_id, is_private, signature)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(title, data, price, owner_id, is_private, signature),
|
||||
)
|
||||
|
||||
artwork_id = c.lastrowid
|
||||
|
||||
if settings_data:
|
||||
c.execute(
|
||||
"INSERT INTO artwork_settings (artwork_id, settings_data) VALUES (?, ?)",
|
||||
(artwork_id, settings_data),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
return artwork_id
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_user_profile(user_id: int) -> Tuple[Optional[Dict], List[Dict]]:
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("SELECT id, username, balance FROM users WHERE id = ?", (user_id,))
|
||||
user_data = c.fetchone()
|
||||
if not user_data:
|
||||
return None, []
|
||||
|
||||
c.execute(
|
||||
"""
|
||||
SELECT id, title, data, price, owner_id, is_private, signature, created_at
|
||||
FROM artworks
|
||||
WHERE owner_id = ?
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
artworks_data = c.fetchall()
|
||||
artworks = [
|
||||
{
|
||||
"id": art[0],
|
||||
"title": art[1],
|
||||
"data": art[2],
|
||||
"price": art[3],
|
||||
"owner_id": art[4],
|
||||
"is_private": art[5],
|
||||
"signature": art[6] or "",
|
||||
"created_at": art[7],
|
||||
}
|
||||
for art in artworks_data
|
||||
]
|
||||
user = {"id": user_data[0], "username": user_data[1], "balance": user_data[2]}
|
||||
return user, artworks
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_artwork_with_settings(artwork_id: int) -> Optional[Tuple[Dict, Optional[str]]]:
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute(
|
||||
"""
|
||||
SELECT a.id, a.title, a.data, a.price, a.owner_id, a.is_private,
|
||||
a.signature, a.created_at, s.settings_data
|
||||
FROM artworks a
|
||||
LEFT JOIN artwork_settings s ON a.id = s.artwork_id
|
||||
WHERE a.id = ?
|
||||
""",
|
||||
(artwork_id,),
|
||||
)
|
||||
row = c.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
artwork = {
|
||||
"id": row[0],
|
||||
"title": row[1],
|
||||
"data": row[2],
|
||||
"price": row[3],
|
||||
"owner_id": row[4],
|
||||
"is_private": row[5],
|
||||
"signature": row[6] or "",
|
||||
"created_at": row[7],
|
||||
}
|
||||
return artwork, row[8]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def save_artwork_settings(artwork_id: int, settings_data: str) -> None:
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("SELECT id FROM artwork_settings WHERE artwork_id = ?", (artwork_id,))
|
||||
existing = c.fetchone()
|
||||
if existing:
|
||||
c.execute(
|
||||
"UPDATE artwork_settings SET settings_data = ? WHERE artwork_id = ?",
|
||||
(settings_data, artwork_id),
|
||||
)
|
||||
else:
|
||||
c.execute(
|
||||
"INSERT INTO artwork_settings (artwork_id, settings_data) VALUES (?, ?)",
|
||||
(artwork_id, settings_data),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_artwork_owner_id(artwork_id: int) -> Optional[int]:
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("SELECT owner_id FROM artworks WHERE id = ?", (artwork_id,))
|
||||
row = c.fetchone()
|
||||
return row[0] if row else None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def purchase_artwork(buyer_id: int, artwork_id: int) -> Tuple[bool, str]:
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("SELECT * FROM artworks WHERE id = ?", (artwork_id,))
|
||||
artwork = c.fetchone()
|
||||
if not artwork:
|
||||
return False, "not_found"
|
||||
|
||||
price = artwork[3]
|
||||
owner_id = artwork[4]
|
||||
|
||||
if owner_id == buyer_id:
|
||||
return False, "same_owner"
|
||||
|
||||
c.execute("SELECT balance FROM users WHERE id = ?", (buyer_id,))
|
||||
buyer = c.fetchone()
|
||||
if not buyer:
|
||||
return False, "buyer_missing"
|
||||
|
||||
buyer_balance = buyer[0]
|
||||
if buyer_balance < price:
|
||||
return False, "insufficient"
|
||||
|
||||
c.execute(
|
||||
"UPDATE users SET balance = balance - ? WHERE id = ?",
|
||||
(price, buyer_id),
|
||||
)
|
||||
c.execute(
|
||||
"UPDATE users SET balance = balance + ? WHERE id = ?",
|
||||
(price, owner_id),
|
||||
)
|
||||
c.execute(
|
||||
"UPDATE artworks SET owner_id = ? WHERE id = ?",
|
||||
(buyer_id, artwork_id),
|
||||
)
|
||||
c.execute(
|
||||
"INSERT INTO transactions (artwork_id, buyer_id, seller_id, amount) VALUES (?, ?, ?, ?)",
|
||||
(artwork_id, buyer_id, owner_id, price),
|
||||
)
|
||||
conn.commit()
|
||||
return True, "ok"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def search_artworks(query: str) -> List[Dict]:
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
search_query = f"SELECT * FROM artworks WHERE title LIKE '%{query}%' OR data LIKE '%{query}%'"
|
||||
c.execute(search_query)
|
||||
results_data = c.fetchall()
|
||||
return [
|
||||
{
|
||||
"id": art[0],
|
||||
"title": art[1],
|
||||
"data": art[2],
|
||||
"price": art[3],
|
||||
"owner_id": art[4],
|
||||
"created_at": art[5],
|
||||
}
|
||||
for art in results_data
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def import_artwork_record(
|
||||
owner_id: int, title: str, shapes_json: str, price: int, signature: str = ""
|
||||
) -> int:
|
||||
return create_artwork_record(
|
||||
owner_id=owner_id,
|
||||
title=title,
|
||||
data=shapes_json,
|
||||
price=price,
|
||||
is_private=0,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
|
||||
def check_connect(ip):
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
try:
|
||||
c.execute("SELECT COUNT(*) FROM users")
|
||||
users_count = c.fetchone()[0]
|
||||
|
||||
c.execute("SELECT COUNT(*) FROM artworks")
|
||||
artworks_count = c.fetchone()[0]
|
||||
|
||||
c.execute(
|
||||
"""
|
||||
SELECT a.id, a.title, a.signature, a.is_private, u.username, a.created_at
|
||||
FROM artworks a
|
||||
JOIN users u ON a.owner_id = u.id
|
||||
ORDER BY a.created_at DESC
|
||||
""",
|
||||
)
|
||||
recent_data = c.fetchall()
|
||||
recent = [
|
||||
{
|
||||
"id": art[0],
|
||||
"title": art[1],
|
||||
"signature": art[2] or "",
|
||||
"is_private": art[3],
|
||||
"owner": art[4],
|
||||
"created_at": art[5],
|
||||
}
|
||||
for art in recent_data
|
||||
]
|
||||
parts = ["OK"]
|
||||
if ipaddress.ip_address(ip).is_loopback:
|
||||
parts.append(recent)
|
||||
return parts
|
||||
finally:
|
||||
conn.close()
|
||||
105
rodchenko/app/utils/security.py
Executable file
105
rodchenko/app/utils/security.py
Executable file
@@ -0,0 +1,105 @@
|
||||
import socket
|
||||
import pickle
|
||||
import base64
|
||||
import ipaddress
|
||||
from urllib.parse import urlparse
|
||||
|
||||
def load_artwork_settings(settings_data):
|
||||
try:
|
||||
if not settings_data:
|
||||
return None
|
||||
|
||||
try:
|
||||
padding = len(settings_data) % 4
|
||||
if padding:
|
||||
settings_data_padded = settings_data + '=' * (4 - padding)
|
||||
else:
|
||||
settings_data_padded = settings_data
|
||||
|
||||
raw = base64.b64decode(settings_data_padded)
|
||||
if raw[:2] in (b'\x80\x03', b'\x80\x04', b'\x80\x05', b'\x80\x02'):
|
||||
settings = pickle.loads(raw)
|
||||
if hasattr(settings, '__dict__'):
|
||||
return settings.__dict__
|
||||
elif isinstance(settings, dict):
|
||||
return settings
|
||||
else:
|
||||
return {'data': str(settings)}
|
||||
except:
|
||||
pass
|
||||
|
||||
return {'description': settings_data}
|
||||
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
|
||||
def save_artwork_description(description):
|
||||
if not description:
|
||||
return None
|
||||
|
||||
try:
|
||||
padding = len(description) % 4
|
||||
if padding:
|
||||
padded = description + '=' * (4 - padding)
|
||||
else:
|
||||
padded = description
|
||||
|
||||
raw = base64.b64decode(padded)
|
||||
if raw[:2] in (b'\x80\x03', b'\x80\x04', b'\x80\x05', b'\x80\x02'):
|
||||
return description
|
||||
except:
|
||||
pass
|
||||
|
||||
return description
|
||||
|
||||
|
||||
class ArtworkConfig:
|
||||
def __init__(self, colors=None, animation=False, public=True):
|
||||
self.colors = colors or ["#FF0000", "#00FF00", "#0000FF"]
|
||||
self.animation = animation
|
||||
self.public = public
|
||||
|
||||
def __repr__(self):
|
||||
return f"ArtworkConfig(colors={self.colors}, animation={self.animation}, public={self.public})"
|
||||
|
||||
def __str__(self):
|
||||
return self.__repr__()
|
||||
|
||||
def __reduce__(self):
|
||||
return (self.__class__, (self.colors, self.animation, self.public))
|
||||
|
||||
def is_safe_url(url: str):
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
return False, "403"
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
return False, "403"
|
||||
|
||||
try:
|
||||
ip_str = socket.gethostbyname(hostname)
|
||||
except socket.gaierror:
|
||||
return False, "403"
|
||||
|
||||
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_str)
|
||||
except ValueError:
|
||||
return False, "403"
|
||||
|
||||
if (
|
||||
ip.is_loopback
|
||||
or ip.is_private
|
||||
or ip.is_link_local
|
||||
or ip.is_unspecified
|
||||
):
|
||||
return False, "403"
|
||||
return True, ip_str
|
||||
|
||||
except Exception:
|
||||
return False, "403"
|
||||
|
||||
24
rodchenko/docker-compose.yml
Executable file
24
rodchenko/docker-compose.yml
Executable file
@@ -0,0 +1,24 @@
|
||||
services:
|
||||
rodchenko:
|
||||
build: .
|
||||
ports:
|
||||
- "5050:5050"
|
||||
environment:
|
||||
PYTHONUNBUFFERED: 1
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://127.0.0.1:5050/healthcheck || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "2"
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: "1"
|
||||
memory: 1G
|
||||
volumes:
|
||||
- .auction_db:/app/data/
|
||||
22
rodchenko/requirements.txt
Executable file
22
rodchenko/requirements.txt
Executable file
@@ -0,0 +1,22 @@
|
||||
async-timeout==5.0.1
|
||||
blinker==1.9.0
|
||||
certifi==2025.11.12
|
||||
charset-normalizer==3.4.4
|
||||
click==8.1.8
|
||||
Flask==2.3.3
|
||||
gunicorn==21.2.0
|
||||
gevent==25.9.1
|
||||
idna==3.11
|
||||
importlib_metadata==8.7.0
|
||||
itsdangerous==2.2.0
|
||||
Jinja2==3.1.6
|
||||
MarkupSafe==3.0.3
|
||||
pytz==2025.2
|
||||
redis==7.0.1
|
||||
requests==2.31.0
|
||||
six==1.17.0
|
||||
urllib3==2.5.0
|
||||
user_agent==0.1.14
|
||||
Werkzeug==2.3.7
|
||||
zipp==3.23.0
|
||||
python-dotenv==1.2.1
|
||||
Reference in New Issue
Block a user