539 lines
21 KiB
Python
539 lines
21 KiB
Python
from flask import Flask, render_template, request, jsonify, session, send_file
|
||
from questions import QUESTION_POOL
|
||
import random
|
||
import uuid
|
||
import os
|
||
import json
|
||
from datetime import datetime
|
||
from io import BytesIO
|
||
from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageFilter
|
||
|
||
# Пытаемся импортировать qrcode с обработкой ошибок
|
||
try:
|
||
import qrcode
|
||
from qrcode.constants import ERROR_CORRECT_L
|
||
|
||
QRCODE_AVAILABLE = True
|
||
except ImportError:
|
||
QRCODE_AVAILABLE = False
|
||
print("Warning: qrcode library not installed. Install with: pip install qrcode[pil]")
|
||
|
||
app = Flask(__name__)
|
||
app.secret_key = 'dnr_youth_parliament_secretary_key_2024'
|
||
|
||
# Хранилище сессий игроков и результатов
|
||
active_games = {}
|
||
saved_results = {}
|
||
|
||
# Файл для сохранения вопросов
|
||
QUESTIONS_FILE = 'questions_custom.json'
|
||
|
||
|
||
def load_questions():
|
||
"""Загружает вопросы из файла или использует стандартный пул"""
|
||
if os.path.exists(QUESTIONS_FILE):
|
||
with open(QUESTIONS_FILE, 'r', encoding='utf-8') as f:
|
||
return json.load(f)
|
||
return QUESTION_POOL.copy()
|
||
|
||
|
||
def save_questions(questions):
|
||
"""Сохраняет вопросы в файл"""
|
||
with open(QUESTIONS_FILE, 'w', encoding='utf-8') as f:
|
||
json.dump(questions, f, ensure_ascii=False, indent=2)
|
||
|
||
|
||
def create_result_image(result_data):
|
||
"""Создаёт изображение с результатами викторины в формате 16:9"""
|
||
# Формат 16:9 (1920x1080)
|
||
width = 1920
|
||
height = 1080
|
||
|
||
# Создаём изображение с градиентным фоном
|
||
img = Image.new('RGB', (width, height))
|
||
draw = ImageDraw.Draw(img)
|
||
|
||
# Новый градиентный фон (тёмный градиент)
|
||
for i in range(height):
|
||
r = int(18 + (i / height) * 35) # RGB(18, 25, 45) -> RGB(53, 66, 89)
|
||
g = int(25 + (i / height) * 41)
|
||
b = int(45 + (i / height) * 44)
|
||
draw.line([(0, i), (width, i)], fill=(r, g, b))
|
||
|
||
# Декоративные элементы
|
||
# Большой круг в правом верхнем углу
|
||
circle1_radius = 300
|
||
draw.ellipse([width - circle1_radius - 50, -circle1_radius // 2,
|
||
width + 50, circle1_radius + 50],
|
||
fill=(255, 100, 100, 30), outline=None)
|
||
|
||
# Круг в левом нижнем углу
|
||
circle2_radius = 250
|
||
draw.ellipse([-circle2_radius // 2, height - circle2_radius - 50,
|
||
circle2_radius + 50, height + 50],
|
||
fill=(100, 150, 255, 30), outline=None)
|
||
|
||
# Золотая рамка с двойным контуром
|
||
border_margin = 15
|
||
border_color = (218, 165, 32) # Золотой
|
||
|
||
# Внешняя рамка
|
||
draw.rectangle([border_margin, border_margin,
|
||
width - border_margin, height - border_margin],
|
||
outline=border_color, width=3)
|
||
|
||
# Внутренняя рамка
|
||
draw.rectangle([border_margin + 10, border_margin + 10,
|
||
width - border_margin - 10, height - border_margin - 10],
|
||
outline=border_color, width=1)
|
||
|
||
# Угловые украшения
|
||
corner_length = 60
|
||
corner_width = 5
|
||
|
||
# Верхний левый угол
|
||
draw.line([border_margin, border_margin + corner_length,
|
||
border_margin, border_margin], fill=border_color, width=corner_width)
|
||
draw.line([border_margin + corner_length, border_margin,
|
||
border_margin, border_margin], fill=border_color, width=corner_width)
|
||
|
||
# Верхний правый угол
|
||
draw.line([width - border_margin, border_margin + corner_length,
|
||
width - border_margin, border_margin], fill=border_color, width=corner_width)
|
||
draw.line([width - border_margin - corner_length, border_margin,
|
||
width - border_margin, border_margin], fill=border_color, width=corner_width)
|
||
|
||
# Нижний левый угол
|
||
draw.line([border_margin, height - border_margin - corner_length,
|
||
border_margin, height - border_margin], fill=border_color, width=corner_width)
|
||
draw.line([border_margin + corner_length, height - border_margin,
|
||
border_margin, height - border_margin], fill=border_color, width=corner_width)
|
||
|
||
# Нижний правый угол
|
||
draw.line([width - border_margin, height - border_margin - corner_length,
|
||
width - border_margin, height - border_margin], fill=border_color, width=corner_width)
|
||
draw.line([width - border_margin - corner_length, height - border_margin,
|
||
width - border_margin, height - border_margin], fill=border_color, width=corner_width)
|
||
|
||
# Пытаемся загрузить шрифты
|
||
try:
|
||
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 64)
|
||
font_score = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 160)
|
||
font_percent = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 56)
|
||
font_normal = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 32)
|
||
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 24)
|
||
except:
|
||
try:
|
||
# Альтернативные шрифты для разных ОС
|
||
font_title = ImageFont.truetype("arial.ttf", 64)
|
||
font_score = ImageFont.truetype("arial.ttf", 160)
|
||
font_percent = ImageFont.truetype("arial.ttf", 56)
|
||
font_normal = ImageFont.truetype("arial.ttf", 32)
|
||
font_small = ImageFont.truetype("arial.ttf", 24)
|
||
except:
|
||
font_title = ImageFont.load_default()
|
||
font_score = ImageFont.load_default()
|
||
font_percent = ImageFont.load_default()
|
||
font_normal = ImageFont.load_default()
|
||
font_small = ImageFont.load_default()
|
||
|
||
# Центральный элемент - звезда/символ
|
||
center_x = width // 2
|
||
|
||
# Заголовок с тенью
|
||
title1 = "МОЛОДЁЖНЫЙ ПАРЛАМЕНТ"
|
||
title1_shadow = "МОЛОДЁЖНЫЙ ПАРЛАМЕНТ"
|
||
shadow_offset = 3
|
||
|
||
# Тень для заголовка
|
||
title1_width = draw.textlength(title1, font=font_title)
|
||
draw.text(((width - title1_width) // 2 + shadow_offset, 130 + shadow_offset),
|
||
title1_shadow, fill=(30, 30, 50), font=font_title)
|
||
draw.text(((width - title1_width) // 2, 130), title1, fill='white', font=font_title)
|
||
|
||
# Подзаголовок
|
||
title2 = "ДОНЕЦКОЙ НАРОДНОЙ РЕСПУБЛИКИ"
|
||
title2_width = draw.textlength(title2, font=font_normal)
|
||
draw.text(((width - title2_width) // 2 + shadow_offset, 210 + shadow_offset),
|
||
title2, fill=(30, 30, 50), font=font_normal)
|
||
draw.text(((width - title2_width) // 2, 210), title2, fill=(218, 165, 32), font=font_normal)
|
||
|
||
# Декоративная линия с точками
|
||
line_y = 270
|
||
line_length = 400
|
||
for i in range(0, line_length, 15):
|
||
x1 = center_x - line_length // 2 + i
|
||
if i % 30 == 0:
|
||
draw.ellipse([x1 - 3, line_y - 3, x1 + 3, line_y + 3], fill=(218, 165, 32))
|
||
else:
|
||
draw.line([x1, line_y, x1 + 10, line_y], fill=(218, 165, 32), width=2)
|
||
|
||
# Карточка с результатами
|
||
card_width = 600
|
||
card_height = 380 # Увеличена высота карточки
|
||
card_x = (width - card_width) // 2
|
||
card_y = 310 # Немного поднята карточка
|
||
|
||
# Полупрозрачная карточка
|
||
card_overlay = Image.new('RGBA', (card_width, card_height), (0, 0, 0, 0))
|
||
card_draw = ImageDraw.Draw(card_overlay)
|
||
card_draw.rectangle([0, 0, card_width, card_height], fill=(255, 255, 255, 30))
|
||
|
||
# Рамка карточки
|
||
card_draw.rectangle([0, 0, card_width, card_height], outline=(218, 165, 32), width=2)
|
||
|
||
# Вставляем карточку
|
||
img.paste(card_overlay, (card_x, card_y), card_overlay)
|
||
|
||
# Основной счёт (поднят выше)
|
||
score_y = card_y + 70
|
||
score_text = f"{result_data['score']} / {result_data['total']}"
|
||
score_width = draw.textlength(score_text, font=font_score)
|
||
draw.text(((width - score_width) // 2, score_y), score_text, fill=(255, 215, 0), font=font_score)
|
||
|
||
# Проценты (опущены ниже)
|
||
percent_y = card_y + 230 # Было 200, стало 230 - опустили на 30px
|
||
percent_text = f"{result_data['percentage']}%"
|
||
percent_width = draw.textlength(percent_text, font=font_percent)
|
||
|
||
# Радужный градиент для процентов
|
||
draw.text(((width - percent_width) // 2, percent_y), percent_text, fill=(255, 215, 0), font=font_percent)
|
||
|
||
# Звание (опущено ниже)
|
||
grade_y = card_y + 310 # Было 280, стало 310 - опустили на 30px
|
||
grade_text = result_data['grade']
|
||
grade_width = draw.textlength(grade_text, font=font_normal)
|
||
draw.text(((width - grade_width) // 2, grade_y), grade_text, fill=(255, 215, 0), font=font_normal)
|
||
|
||
# Нижняя часть
|
||
footer_y = height - 60
|
||
|
||
# Левая часть - QR-код
|
||
if QRCODE_AVAILABLE:
|
||
try:
|
||
result_url = f"http://localhost:5000/api/result/{result_data['game_id']}"
|
||
qr = qrcode.QRCode(version=1, box_size=4, border=2)
|
||
qr.add_data(result_url)
|
||
qr.make(fit=True)
|
||
qr_img = qr.make_image(fill_color=(218, 165, 32), back_color=(18, 25, 45))
|
||
qr_img = qr_img.resize((100, 100))
|
||
img.paste(qr_img, (80, footer_y - 70))
|
||
except:
|
||
pass
|
||
|
||
# Центральная часть - девиз
|
||
footer_text = "МОЛОДОСТЬ. ПАТРИОТИЗМ. РАЗВИТИЕ."
|
||
footer_width = draw.textlength(footer_text, font=font_small)
|
||
draw.text(((width - footer_width) // 2, footer_y), footer_text, fill=(218, 165, 32), font=font_small)
|
||
|
||
# Правая часть - дата
|
||
date_text = datetime.now().strftime("%d.%m.%Y")
|
||
date_width = draw.textlength(date_text, font=font_small)
|
||
draw.text((width - date_width - 80, footer_y), date_text, fill=(150, 150, 180), font=font_small)
|
||
|
||
# Подпись
|
||
sign_y = footer_y + 35
|
||
sign_text = "Молодёжный Парламент Донецкой Народной Республики"
|
||
sign_width = draw.textlength(sign_text, font=font_small)
|
||
draw.text(((width - sign_width) // 2, sign_y), sign_text, fill=(100, 100, 130), font=font_small)
|
||
|
||
return img
|
||
|
||
|
||
class QuizGame:
|
||
def __init__(self):
|
||
self.game_id = str(uuid.uuid4())[:8]
|
||
self.questions_pool = load_questions()
|
||
self.selected_questions = []
|
||
self.current_question_index = 0
|
||
self.score = 0
|
||
self.is_active = True
|
||
self.user_answers = []
|
||
self.start_time = datetime.now()
|
||
self._select_random_questions()
|
||
|
||
def _select_random_questions(self):
|
||
"""Выбирает 7 случайных вопросов из пула"""
|
||
if len(self.questions_pool) >= 7:
|
||
self.selected_questions = random.sample(self.questions_pool, 7)
|
||
else:
|
||
self.selected_questions = self.questions_pool.copy()
|
||
|
||
for q in self.selected_questions:
|
||
q['time_limit'] = 10
|
||
|
||
def get_current_question(self):
|
||
"""Возвращает текущий вопрос (с правильным ответом для подсветки)"""
|
||
if self.current_question_index < len(self.selected_questions):
|
||
q = self.selected_questions[self.current_question_index]
|
||
return {
|
||
'id': self.current_question_index,
|
||
'text': q['text'],
|
||
'options': q['options'],
|
||
'correct': q['correct'],
|
||
'time_limit': q.get('time_limit', 10)
|
||
}
|
||
return None
|
||
|
||
def check_answer(self, answer_index):
|
||
"""Проверяет ответ и обновляет счет"""
|
||
current_q = self.selected_questions[self.current_question_index]
|
||
is_correct = (answer_index == current_q['correct'])
|
||
|
||
self.user_answers.append({
|
||
'question_text': current_q['text'],
|
||
'options': current_q['options'],
|
||
'user_answer': answer_index,
|
||
'user_answer_text': current_q['options'][answer_index] if answer_index != -1 else 'Время вышло',
|
||
'correct_answer': current_q['options'][current_q['correct']],
|
||
'correct_index': current_q['correct'],
|
||
'is_correct': is_correct,
|
||
'explanation': current_q.get('explanation', '')
|
||
})
|
||
|
||
if is_correct:
|
||
self.score += 1
|
||
|
||
self.current_question_index += 1
|
||
|
||
return {
|
||
'is_correct': is_correct,
|
||
'correct_answer': current_q['options'][current_q['correct']],
|
||
'explanation': current_q.get('explanation', ''),
|
||
'score': self.score,
|
||
'is_finished': self.current_question_index >= len(self.selected_questions),
|
||
'total_questions': len(self.selected_questions)
|
||
}
|
||
|
||
def get_result(self):
|
||
"""Возвращает результат теста со всеми вопросами"""
|
||
total = len(self.selected_questions)
|
||
percentage = (self.score / total) * 100
|
||
|
||
if percentage >= 90:
|
||
grade = "ДЕПУТАТ ВЫСШЕЙ КАТЕГОРИИ"
|
||
message = "Вы отлично знаете историю и структуру Молодёжного Парламента ДНР!"
|
||
elif percentage >= 70:
|
||
grade = "АКТИВНЫЙ ПАРЛАМЕНТАРИЙ"
|
||
message = "Хороший результат! Вы достойно показали свои знания."
|
||
elif percentage >= 50:
|
||
grade = "КАНДИДАТ В ПАРЛАМЕНТ"
|
||
message = "Неплохо, но есть куда расти. Рекомендуем изучить материалы о работе Парламента."
|
||
else:
|
||
grade = "БУДУЩИЙ ЛИДЕР"
|
||
message = "Не расстраивайтесь! Участие в викторине - первый шаг к большим достижениям."
|
||
|
||
result_data = {
|
||
'game_id': self.game_id,
|
||
'score': self.score,
|
||
'total': total,
|
||
'percentage': round(percentage, 1),
|
||
'grade': grade,
|
||
'message': message,
|
||
'questions_summary': self.user_answers,
|
||
'completed_at': self.start_time.isoformat()
|
||
}
|
||
saved_results[self.game_id] = result_data
|
||
|
||
return {
|
||
'score': self.score,
|
||
'total': total,
|
||
'percentage': round(percentage, 1),
|
||
'grade': grade,
|
||
'message': message,
|
||
'questions_summary': self.user_answers,
|
||
'result_id': self.game_id
|
||
}
|
||
|
||
|
||
@app.route('/')
|
||
def index():
|
||
return render_template('index.html')
|
||
|
||
|
||
@app.route('/admin')
|
||
def admin():
|
||
return render_template('admin.html')
|
||
|
||
|
||
@app.route('/api/questions', methods=['GET'])
|
||
def get_questions():
|
||
questions = load_questions()
|
||
return jsonify(questions)
|
||
|
||
|
||
@app.route('/api/questions', methods=['POST'])
|
||
def add_question():
|
||
data = request.json
|
||
questions = load_questions()
|
||
|
||
new_question = {
|
||
'text': data['text'],
|
||
'options': data['options'],
|
||
'correct': data['correct'],
|
||
'explanation': data.get('explanation', '')
|
||
}
|
||
|
||
questions.append(new_question)
|
||
save_questions(questions)
|
||
|
||
return jsonify({'success': True, 'message': 'Вопрос добавлен', 'question': new_question})
|
||
|
||
|
||
@app.route('/api/questions/<int:index>', methods=['PUT'])
|
||
def update_question(index):
|
||
data = request.json
|
||
questions = load_questions()
|
||
|
||
if 0 <= index < len(questions):
|
||
questions[index] = {
|
||
'text': data['text'],
|
||
'options': data['options'],
|
||
'correct': data['correct'],
|
||
'explanation': data.get('explanation', '')
|
||
}
|
||
save_questions(questions)
|
||
return jsonify({'success': True, 'message': 'Вопрос обновлён'})
|
||
|
||
return jsonify({'success': False, 'message': 'Вопрос не найден'}), 404
|
||
|
||
|
||
@app.route('/api/questions/<int:index>', methods=['DELETE'])
|
||
def delete_question(index):
|
||
questions = load_questions()
|
||
|
||
if 0 <= index < len(questions):
|
||
deleted = questions.pop(index)
|
||
save_questions(questions)
|
||
return jsonify({'success': True, 'message': 'Вопрос удалён', 'question': deleted})
|
||
|
||
return jsonify({'success': False, 'message': 'Вопрос не найден'}), 404
|
||
|
||
|
||
@app.route('/api/questions/reset', methods=['POST'])
|
||
def reset_questions():
|
||
from questions import QUESTION_POOL
|
||
save_questions(QUESTION_POOL.copy())
|
||
return jsonify({'success': True, 'message': 'Вопросы сброшены к стандартному пулу'})
|
||
|
||
|
||
@app.route('/api/questions/export', methods=['GET'])
|
||
def export_questions():
|
||
questions = load_questions()
|
||
return jsonify(questions)
|
||
|
||
|
||
@app.route('/api/questions/import', methods=['POST'])
|
||
def import_questions():
|
||
data = request.json
|
||
if isinstance(data, list) and len(data) > 0:
|
||
save_questions(data)
|
||
return jsonify({'success': True, 'message': f'Импортировано {len(data)} вопросов'})
|
||
return jsonify({'success': False, 'message': 'Неверный формат данных'}), 400
|
||
|
||
|
||
@app.route('/api/new_game', methods=['POST'])
|
||
def new_game():
|
||
game = QuizGame()
|
||
active_games[game.game_id] = game
|
||
return jsonify({
|
||
'game_id': game.game_id,
|
||
'question': game.get_current_question(),
|
||
'question_number': 1,
|
||
'total_questions': len(game.selected_questions)
|
||
})
|
||
|
||
|
||
@app.route('/api/check_answer', methods=['POST'])
|
||
def check_answer():
|
||
data = request.json
|
||
game_id = data.get('game_id')
|
||
answer = data.get('answer')
|
||
|
||
game = active_games.get(game_id)
|
||
if not game or not game.is_active:
|
||
return jsonify({'error': 'Игра не найдена'}), 404
|
||
|
||
result = game.check_answer(answer)
|
||
|
||
if result['is_finished']:
|
||
final_result = game.get_result()
|
||
return jsonify({
|
||
'is_finished': True,
|
||
'result': final_result
|
||
})
|
||
else:
|
||
return jsonify({
|
||
'is_finished': False,
|
||
'result': result,
|
||
'next_question': game.get_current_question(),
|
||
'question_number': game.current_question_index + 1,
|
||
'total_questions': len(game.selected_questions)
|
||
})
|
||
|
||
|
||
@app.route('/api/result/<result_id>')
|
||
def get_result_image(result_id):
|
||
"""Возвращает изображение с результатами в формате 16:9"""
|
||
result = saved_results.get(result_id)
|
||
if not result:
|
||
return "Результат не найден", 404
|
||
|
||
# Создаём изображение
|
||
img = create_result_image(result)
|
||
|
||
# Сохраняем в байты
|
||
img_io = BytesIO()
|
||
img.save(img_io, 'PNG')
|
||
img_io.seek(0)
|
||
|
||
return send_file(img_io, mimetype='image/png')
|
||
|
||
|
||
@app.route('/api/qrcode/<result_id>')
|
||
def generate_qrcode(result_id):
|
||
if not QRCODE_AVAILABLE:
|
||
from PIL import Image, ImageDraw
|
||
img = Image.new('RGB', (200, 200), color='white')
|
||
draw = ImageDraw.Draw(img)
|
||
draw.text((20, 80), "QR Code", fill='black')
|
||
draw.text((20, 100), "not available", fill='black')
|
||
img_io = BytesIO()
|
||
img.save(img_io, 'PNG')
|
||
img_io.seek(0)
|
||
return send_file(img_io, mimetype='image/png')
|
||
|
||
result_url = request.host_url.rstrip('/') + f'/api/result/{result_id}'
|
||
|
||
try:
|
||
qr = qrcode.QRCode(
|
||
version=1,
|
||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||
box_size=10,
|
||
border=4,
|
||
)
|
||
qr.add_data(result_url)
|
||
qr.make(fit=True)
|
||
|
||
img = qr.make_image(fill_color=(218, 165, 32), back_color=(18, 25, 45))
|
||
|
||
img_io = BytesIO()
|
||
img.save(img_io, 'PNG')
|
||
img_io.seek(0)
|
||
|
||
return send_file(img_io, mimetype='image/png')
|
||
except Exception as e:
|
||
print(f"QR Code generation error: {e}")
|
||
from PIL import Image, ImageDraw
|
||
img = Image.new('RGB', (200, 200), color='white')
|
||
draw = ImageDraw.Draw(img)
|
||
draw.text((10, 80), "QR Error", fill='black')
|
||
draw.text((10, 100), result_url[:30], fill='black')
|
||
img_io = BytesIO()
|
||
img.save(img_io, 'PNG')
|
||
img_io.seek(0)
|
||
return send_file(img_io, mimetype='image/png')
|
||
|
||
|
||
if __name__ == '__main__':
|
||
app.run(debug=True, host='0.0.0.0', port=5000) |