172 lines
5.8 KiB
Python
172 lines
5.8 KiB
Python
"""
|
||
Утилиты для работы с домашними заданиями.
|
||
"""
|
||
import re
|
||
import os
|
||
import logging
|
||
from django.core.exceptions import ValidationError
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Теги, разрешённые после конвертации Markdown + MathML (для санитизации)
|
||
ALLOWED_FEEDBACK_HTML_TAGS = [
|
||
'p', 'br', 'div', 'span', 'strong', 'em', 'b', 'i', 'u', 's',
|
||
'ul', 'ol', 'li', 'code', 'pre', 'blockquote', 'h1', 'h2', 'h3', 'h4',
|
||
'a', 'sub', 'sup',
|
||
# MathML (для формул из LaTeX)
|
||
'math', 'mrow', 'mi', 'mo', 'mn', 'msup', 'msub', 'munder', 'mover',
|
||
'mfrac', 'mtable', 'mtr', 'mtd', 'mstyle', 'mtext', 'annotation', 'semantics',
|
||
]
|
||
|
||
|
||
def feedback_to_html(raw: str) -> str:
|
||
"""
|
||
Переводит ответ ИИ (markdown + LaTeX вроде $a$, $$x^2$$) в безопасный HTML.
|
||
Используется для отображения ai_feedback и комментариев проверки без «сырых» символов $.
|
||
"""
|
||
if not raw or not raw.strip():
|
||
return ''
|
||
try:
|
||
import markdown
|
||
import bleach
|
||
except ImportError:
|
||
logger.warning("markdown/bleach не установлены — возвращаем экранированный текст")
|
||
return _escape_html(raw)
|
||
|
||
# 1) Выносим LaTeX в placeholder'ы, чтобы markdown их не трогал
|
||
block_maths = []
|
||
inline_maths = []
|
||
|
||
def block_replacer(m):
|
||
block_maths.append(m.group(1).strip())
|
||
return f'\x00MATH_BLOCK_{len(block_maths) - 1}\x00'
|
||
|
||
def inline_replacer(m):
|
||
inline_maths.append(m.group(1).strip())
|
||
return f'\x00MATH_INLINE_{len(inline_maths) - 1}\x00'
|
||
|
||
text = raw
|
||
# Сначала блочные $$ ... $$
|
||
text = re.sub(r'\$\$([^$]*?)\$\$', block_replacer, text, flags=re.DOTALL)
|
||
# Потом инлайновые $ ... $ (не захватываем переносы в инлайне)
|
||
text = re.sub(r'\$([^$\n]+?)\$', inline_replacer, text)
|
||
|
||
# 2) Markdown → HTML
|
||
html = markdown.markdown(
|
||
text,
|
||
extensions=['nl2br'],
|
||
output_format='html5',
|
||
)
|
||
|
||
# 3) LaTeX → MathML и подставляем обратно
|
||
try:
|
||
from latex2mathml.converter import convert as latex_to_mathml
|
||
except ImportError:
|
||
latex_to_mathml = None
|
||
|
||
def replace_math(placeholder_prefix, maths_list):
|
||
for i, latex in enumerate(maths_list):
|
||
token = f'\x00{placeholder_prefix}_{i}\x00'
|
||
if latex_to_mathml and latex:
|
||
try:
|
||
mathml = latex_to_mathml(latex)
|
||
# latex2mathml возвращает полный <math>...</math>
|
||
html_replacement = f'<span class="math-wrap">{mathml}</span>'
|
||
except Exception as e:
|
||
logger.debug("LaTeX → MathML failed for %r: %s", latex[:50], e)
|
||
html_replacement = _escape_html(f'${latex}$')
|
||
else:
|
||
html_replacement = _escape_html(f'${latex}$')
|
||
nonlocal html
|
||
html = html.replace(token, html_replacement)
|
||
|
||
replace_math('MATH_BLOCK', block_maths)
|
||
replace_math('MATH_INLINE', inline_maths)
|
||
|
||
# 4) Санитизация (разрешаем class, xmlns для формул и ссылок href)
|
||
allowed_attrs = {'*': ['class'], 'a': ['href', 'title', 'rel'], 'math': ['xmlns'], 'span': ['class']}
|
||
html = bleach.clean(html, tags=ALLOWED_FEEDBACK_HTML_TAGS, attributes=allowed_attrs, strip=True)
|
||
return html.strip()
|
||
|
||
|
||
def _escape_html(s: str) -> str:
|
||
"""Экранирует HTML-сущности."""
|
||
return (
|
||
s.replace('&', '&')
|
||
.replace('<', '<')
|
||
.replace('>', '>')
|
||
.replace('"', '"')
|
||
)
|
||
|
||
|
||
def sanitize_filename(filename):
|
||
"""
|
||
Очистка имени файла от опасных символов.
|
||
|
||
Args:
|
||
filename: Исходное имя файла
|
||
|
||
Returns:
|
||
str: Безопасное имя файла
|
||
"""
|
||
# Удаляем путь, оставляем только имя файла
|
||
filename = os.path.basename(filename)
|
||
|
||
# Удаляем опасные символы
|
||
filename = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '', filename)
|
||
|
||
# Удаляем ведущие/завершающие точки и пробелы
|
||
filename = filename.strip('. ')
|
||
|
||
# Ограничиваем длину
|
||
if len(filename) > 255:
|
||
name, ext = os.path.splitext(filename)
|
||
filename = name[:250] + ext
|
||
|
||
# Если имя пустое, возвращаем дефолтное
|
||
if not filename:
|
||
filename = 'file'
|
||
|
||
return filename
|
||
|
||
|
||
def validate_file_type(filename, allowed_types):
|
||
"""
|
||
Проверка типа файла.
|
||
|
||
Args:
|
||
filename: Имя файла
|
||
allowed_types: Строка с разрешенными расширениями (например, ".pdf,.doc,.docx")
|
||
|
||
Returns:
|
||
bool: True если тип разрешен
|
||
"""
|
||
if not allowed_types:
|
||
return True
|
||
|
||
# Получаем расширение файла
|
||
ext = os.path.splitext(filename)[1].lower()
|
||
|
||
# Нормализуем allowed_types
|
||
allowed_list = [t.strip().lower() for t in allowed_types.split(',') if t.strip()]
|
||
|
||
return ext in allowed_list
|
||
|
||
|
||
def validate_file_size(file_size, max_size):
|
||
"""
|
||
Проверка размера файла.
|
||
|
||
Args:
|
||
file_size: Размер файла в байтах
|
||
max_size: Максимальный размер в байтах
|
||
|
||
Returns:
|
||
bool: True если размер допустим
|
||
"""
|
||
if max_size <= 0:
|
||
return True
|
||
|
||
return file_size <= max_size
|
||
|