Update: admin panel, SMTP/EWS/Outlook mail modes, QR fix, event folders, security improvements
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
from flask import request
|
||||
|
||||
from app import db
|
||||
from app.models import AuditLog
|
||||
|
||||
|
||||
def log_action(action, target_type=None, target_id=None, details=None):
|
||||
"""Записывает действие пользователя в аудит-лог.
|
||||
|
||||
Аргументы:
|
||||
action: короткий код действия, например "guest_create", "event_delete".
|
||||
target_type: тип объекта (event, guest, user, ...).
|
||||
target_id: идентификатор объекта (может быть строкой).
|
||||
details: произвольное текстовое описание или причина.
|
||||
"""
|
||||
try:
|
||||
from app.routes import current_user
|
||||
|
||||
user = current_user()
|
||||
user_id = user.id if user else None
|
||||
username = user.username if user else None
|
||||
except Exception:
|
||||
user_id = None
|
||||
username = None
|
||||
|
||||
ip_address = None
|
||||
try:
|
||||
if request:
|
||||
# Учитываем прокси
|
||||
ip_address = request.headers.get("X-Forwarded-For", request.remote_addr)
|
||||
if ip_address:
|
||||
ip_address = ip_address.split(",")[0].strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
record = AuditLog(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
action=action,
|
||||
target_type=target_type,
|
||||
target_id=str(target_id) if target_id is not None else None,
|
||||
details=details,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
db.session.add(record)
|
||||
# Не делаем commit здесь, чтобы не ломать транзакции вызывающего кода.
|
||||
# Вызывающий код сам делает db.session.commit() после основных изменений.
|
||||
+175
-51
@@ -7,65 +7,44 @@ from pathlib import Path
|
||||
from flask import current_app, render_template
|
||||
|
||||
from app.services.ticket_service import generate_ticket_image
|
||||
from app.utils.paths import event_qr_folder
|
||||
|
||||
|
||||
def send_invitation(guest, event_name=None, additional_text=""):
|
||||
"""Отправляет приглашение гостю на email с QR-кодом и билетом во вложении."""
|
||||
OUTLOOK_CONTENT_ID_PROP = "http://schemas.microsoft.com/mapi/proptag/0x3712001F"
|
||||
|
||||
|
||||
def _send_smtp_email(to_address, subject, html_body, sender_name, attachments):
|
||||
"""Отправляет письмо через SMTP с inline-вложениями."""
|
||||
config = current_app.config
|
||||
|
||||
if event_name is None:
|
||||
event_name = guest.event.name if guest.event else "Мероприятие"
|
||||
|
||||
msg = MIMEMultipart("related")
|
||||
msg["Subject"] = f"Приглашение на мероприятие: {event_name}"
|
||||
msg["From"] = formataddr(("InviteMe", config.get("MAIL_DEFAULT_SENDER", config["MAIL_USERNAME"])))
|
||||
msg["To"] = guest.email
|
||||
|
||||
# Генерируем билет
|
||||
try:
|
||||
ticket_path = generate_ticket_image(guest, guest.event)
|
||||
except Exception as e:
|
||||
ticket_path = None
|
||||
current_app.logger.warning(f"Не удалось сгенерировать билет для {guest.email}: {e}")
|
||||
|
||||
# HTML-шаблон письма
|
||||
html_body = render_template(
|
||||
"email/invitation.html",
|
||||
guest=guest,
|
||||
event_name=event_name,
|
||||
additional_text=additional_text,
|
||||
qr_cid="qr_code_image",
|
||||
ticket_cid="ticket_image" if ticket_path else None,
|
||||
)
|
||||
|
||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
# Прикрепляем QR-код
|
||||
qr_path = Path(config["QR_FOLDER"]) / guest.qr_filename
|
||||
if qr_path.exists():
|
||||
with open(qr_path, "rb") as img_file:
|
||||
img = MIMEImage(img_file.read())
|
||||
img.add_header("Content-ID", "<qr_code_image>")
|
||||
img.add_header("Content-Disposition", "inline", filename=guest.qr_filename)
|
||||
msg.attach(img)
|
||||
|
||||
# Прикрепляем билет
|
||||
if ticket_path and ticket_path.exists():
|
||||
with open(ticket_path, "rb") as img_file:
|
||||
img = MIMEImage(img_file.read())
|
||||
img.add_header("Content-ID", "<ticket_image>")
|
||||
img.add_header("Content-Disposition", "inline", filename=f"ticket_{guest.token}.png")
|
||||
msg.attach(img)
|
||||
|
||||
# Отправка через SMTP
|
||||
server = config["MAIL_SERVER"]
|
||||
port = config["MAIL_PORT"]
|
||||
username = config["MAIL_USERNAME"]
|
||||
password = config["MAIL_PASSWORD"]
|
||||
server = config.get("MAIL_SERVER", "")
|
||||
port = config.get("MAIL_PORT", 465)
|
||||
username = config.get("MAIL_USERNAME", "")
|
||||
password = config.get("MAIL_PASSWORD", "")
|
||||
use_ssl = config.get("MAIL_USE_SSL", True)
|
||||
sender = config.get("MAIL_DEFAULT_SENDER") or username
|
||||
|
||||
if not username or not password:
|
||||
raise ValueError("MAIL_USERNAME и MAIL_PASSWORD должны быть указаны в конфигурации")
|
||||
if not server or not port:
|
||||
raise ValueError("MAIL_SERVER и MAIL_PORT должны быть указаны в конфигурации")
|
||||
|
||||
msg = MIMEMultipart("related")
|
||||
msg["Subject"] = subject
|
||||
msg["From"] = formataddr((sender_name, sender))
|
||||
msg["To"] = to_address
|
||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||
|
||||
for att in attachments:
|
||||
path = att["path"]
|
||||
cid = att.get("cid")
|
||||
filename = att.get("filename", path.name)
|
||||
with open(path, "rb") as img_file:
|
||||
img = MIMEImage(img_file.read())
|
||||
if cid:
|
||||
img.add_header("Content-ID", f"<{cid}>")
|
||||
img.add_header("Content-Disposition", "inline", filename=filename)
|
||||
msg.attach(img)
|
||||
|
||||
if use_ssl:
|
||||
with smtplib.SMTP_SSL(server, port) as smtp:
|
||||
@@ -77,5 +56,150 @@ def send_invitation(guest, event_name=None, additional_text=""):
|
||||
smtp.login(username, password)
|
||||
smtp.send_message(msg)
|
||||
|
||||
|
||||
def _send_outlook_email(to_address, subject, html_body, sender_name, attachments):
|
||||
"""Отправляет письмо через локальный Outlook (MAPI/COM)."""
|
||||
try:
|
||||
import win32com.client
|
||||
except ImportError as e:
|
||||
raise RuntimeError("Для отправки через Outlook необходима библиотека pywin32") from e
|
||||
|
||||
try:
|
||||
outlook = win32com.client.Dispatch("Outlook.Application")
|
||||
except Exception as e:
|
||||
raise RuntimeError("Не удалось подключиться к Outlook. Убедитесь, что Outlook установлен и запущен.") from e
|
||||
|
||||
mail = outlook.CreateItem(0) # olMailItem
|
||||
mail.To = to_address
|
||||
mail.Subject = subject
|
||||
mail.HTMLBody = html_body
|
||||
|
||||
for att in attachments:
|
||||
path = att["path"]
|
||||
cid = att.get("cid")
|
||||
filename = att.get("filename", path.name)
|
||||
# 1 = olByValue, 0 = позиция (inline)
|
||||
attachment = mail.Attachments.Add(str(path), 1, 0, filename)
|
||||
if cid:
|
||||
attachment.PropertyAccessor.SetProperty(OUTLOOK_CONTENT_ID_PROP, cid)
|
||||
|
||||
mail.Send()
|
||||
|
||||
|
||||
def _send_ews_email(to_address, subject, html_body, sender_name, attachments):
|
||||
"""Отправляет письмо через Exchange Web Services (EWS)."""
|
||||
try:
|
||||
from exchangelib import Credentials, Configuration, Account, Message, Mailbox, HTMLBody, FileAttachment
|
||||
except ImportError as e:
|
||||
raise RuntimeError("Для отправки через EWS необходима библиотека exchangelib") from e
|
||||
|
||||
config = current_app.config
|
||||
ews_url = config.get("EWS_URL", "")
|
||||
username = config.get("MAIL_USERNAME", "")
|
||||
password = config.get("MAIL_PASSWORD", "")
|
||||
|
||||
if not ews_url or not username or not password:
|
||||
raise ValueError("Для EWS должны быть указаны EWS URL, логин и пароль")
|
||||
|
||||
try:
|
||||
credentials = Credentials(username=username, password=password)
|
||||
ews_config = Configuration(service_endpoint=ews_url, credentials=credentials)
|
||||
account = Account(
|
||||
primary_smtp_address=username,
|
||||
config=ews_config,
|
||||
autodiscover=False,
|
||||
access_type="delegate",
|
||||
)
|
||||
|
||||
msg = Message(
|
||||
account=account,
|
||||
subject=subject,
|
||||
body=HTMLBody(html_body),
|
||||
to_recipients=[Mailbox(email_address=to_address)],
|
||||
)
|
||||
|
||||
for att in attachments:
|
||||
path = att["path"]
|
||||
filename = att.get("filename", path.name)
|
||||
cid = att.get("cid")
|
||||
with open(path, "rb") as f:
|
||||
content = f.read()
|
||||
file_att = FileAttachment(name=filename, content=content, content_id=cid)
|
||||
msg.attach(file_att)
|
||||
|
||||
msg.send()
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Ошибка отправки через EWS: {e}") from e
|
||||
|
||||
|
||||
def _send_email(to_address, subject, html_body, sender_name="InviteMe", attachments=None):
|
||||
"""Маршрутизирует отправку через SMTP, EWS или локальный Outlook."""
|
||||
attachments = attachments or []
|
||||
if current_app.config.get("MAIL_USE_LOCAL_OUTLOOK"):
|
||||
_send_outlook_email(to_address, subject, html_body, sender_name, attachments)
|
||||
elif current_app.config.get("MAIL_USE_EWS"):
|
||||
_send_ews_email(to_address, subject, html_body, sender_name, attachments)
|
||||
else:
|
||||
_send_smtp_email(to_address, subject, html_body, sender_name, attachments)
|
||||
|
||||
|
||||
def send_invitation(guest, event_name=None, additional_text=""):
|
||||
"""Отправляет приглашение гостю на email с QR-кодом и билетом во вложении."""
|
||||
config = current_app.config
|
||||
|
||||
if event_name is None:
|
||||
event_name = guest.event.name if guest.event else "Мероприятие"
|
||||
|
||||
# Генерируем билет
|
||||
try:
|
||||
ticket_path = generate_ticket_image(guest, guest.event)
|
||||
except Exception as e:
|
||||
ticket_path = None
|
||||
current_app.logger.warning(f"Не удалось сгенерировать билет для {guest.email}: {e}")
|
||||
|
||||
html_body = render_template(
|
||||
"email/invitation.html",
|
||||
guest=guest,
|
||||
event_name=event_name,
|
||||
additional_text=additional_text,
|
||||
qr_cid="qr_code_image",
|
||||
ticket_cid="ticket_image" if ticket_path else None,
|
||||
)
|
||||
|
||||
attachments = []
|
||||
|
||||
# QR-код
|
||||
qr_path = event_qr_folder(guest.event_id) / guest.qr_filename
|
||||
if not qr_path.exists():
|
||||
qr_path = Path(config["QR_FOLDER"]) / guest.qr_filename
|
||||
if qr_path.exists():
|
||||
attachments.append({"path": qr_path, "cid": "qr_code_image", "filename": guest.qr_filename})
|
||||
|
||||
# Билет
|
||||
if ticket_path and ticket_path.exists():
|
||||
attachments.append({
|
||||
"path": ticket_path,
|
||||
"cid": "ticket_image",
|
||||
"filename": f"ticket_{guest.token}.png",
|
||||
})
|
||||
|
||||
_send_email(
|
||||
to_address=guest.email,
|
||||
subject=f"Приглашение на мероприятие: {event_name}",
|
||||
html_body=html_body,
|
||||
attachments=attachments,
|
||||
)
|
||||
|
||||
guest.invitation_sent = True
|
||||
return True
|
||||
|
||||
|
||||
def send_test_email(to_address):
|
||||
"""Отправляет тестовое письмо для проверки настроек."""
|
||||
html_body = render_template("email/test_email.html")
|
||||
_send_email(
|
||||
to_address=to_address,
|
||||
subject="Тестовое письмо InviteMe",
|
||||
html_body=html_body,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -2,11 +2,13 @@ import qrcode
|
||||
from pathlib import Path
|
||||
from flask import current_app, url_for
|
||||
|
||||
from app.utils.paths import event_qr_folder
|
||||
|
||||
|
||||
def generate_qr_for_guest(guest, base_url=None):
|
||||
"""Генерирует QR-код для гостя и сохраняет его в uploads/qrcodes/."""
|
||||
"""Генерирует QR-код для гостя и сохраняет его в папку мероприятия."""
|
||||
token = guest.token
|
||||
qr_folder = Path(current_app.config["QR_FOLDER"])
|
||||
qr_folder = event_qr_folder(guest.event_id)
|
||||
qr_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
qr_filename = f"{token}.png"
|
||||
|
||||
@@ -5,6 +5,8 @@ from PIL import Image, ImageDraw, ImageFont
|
||||
from flask import current_app
|
||||
|
||||
from app.services.qr_service import generate_qr_for_guest
|
||||
from app.utils.timezone import utc_to_local
|
||||
from app.utils.paths import event_logo_folder, event_tickets_folder, event_photo_folder, event_qr_folder
|
||||
|
||||
|
||||
TICKET_SIZE = (650, 1150)
|
||||
@@ -49,7 +51,7 @@ def _draw_logo_centered(img, event, y, max_width):
|
||||
if not event.logo_filename:
|
||||
return y
|
||||
|
||||
logo_path = Path(current_app.config["LOGO_FOLDER"]) / event.logo_filename
|
||||
logo_path = event_logo_folder(event.id) / event.logo_filename
|
||||
if not logo_path.exists():
|
||||
current_app.logger.warning(f"Логотип не найден: {logo_path}")
|
||||
return y
|
||||
@@ -292,8 +294,9 @@ def generate_ticket_image(guest, event, output_path=None):
|
||||
_draw_ornament(draw, y, width, accent_color)
|
||||
y += 25
|
||||
if event.event_date:
|
||||
date_value = event.event_date.strftime("%d.%m.%Y")
|
||||
time_value = event.event_date.strftime("%H:%M")
|
||||
local_event_date = utc_to_local(event.event_date)
|
||||
date_value = local_event_date.strftime("%d.%m.%Y")
|
||||
time_value = local_event_date.strftime("%H:%M")
|
||||
y = _draw_info_row(draw, "Дата", date_value, x_content, y, content_width, label_font, body_font, text_color)
|
||||
y = _draw_info_row(draw, "Время", time_value, x_content, y, content_width, label_font, body_font, text_color)
|
||||
if event.location:
|
||||
@@ -302,7 +305,10 @@ def generate_ticket_image(guest, event, output_path=None):
|
||||
# QR-код
|
||||
if event.ticket_show_qr:
|
||||
y += 15
|
||||
qr_path = Path(current_app.config["QR_FOLDER"]) / guest.qr_filename
|
||||
qr_path = event_qr_folder(guest.event_id) / guest.qr_filename
|
||||
if not qr_path.exists():
|
||||
# fallback на старую корневую папку
|
||||
qr_path = Path(current_app.config["QR_FOLDER"]) / guest.qr_filename
|
||||
if qr_path.exists():
|
||||
qr_size = 200
|
||||
qr_img = Image.open(str(qr_path)).convert("RGB")
|
||||
@@ -314,7 +320,10 @@ def generate_ticket_image(guest, event, output_path=None):
|
||||
|
||||
# Фото гостя
|
||||
if event.ticket_show_photo and guest.photo_filename:
|
||||
photo_path = Path(current_app.config["PHOTO_FOLDER"]) / guest.photo_filename
|
||||
photo_path = event_photo_folder(guest.event_id) / guest.photo_filename
|
||||
if not photo_path.exists():
|
||||
# fallback на старую корневую папку
|
||||
photo_path = Path(current_app.config["PHOTO_FOLDER"]) / guest.photo_filename
|
||||
if photo_path.exists():
|
||||
photo_img = Image.open(str(photo_path)).convert("RGB")
|
||||
photo_img.thumbnail((130, 130))
|
||||
@@ -339,7 +348,7 @@ def generate_ticket_image(guest, event, output_path=None):
|
||||
|
||||
# Сохранение
|
||||
if output_path is None:
|
||||
tickets_folder = Path(current_app.config["UPLOAD_FOLDER"]) / "tickets"
|
||||
tickets_folder = event_tickets_folder(event.id)
|
||||
tickets_folder.mkdir(parents=True, exist_ok=True)
|
||||
output_path = tickets_folder / f"ticket_{event.id}_{guest.token}.png"
|
||||
|
||||
@@ -352,6 +361,7 @@ def generate_preview_ticket(event, sample_guest=None):
|
||||
if sample_guest is None:
|
||||
from app.models import Guest
|
||||
sample_guest = Guest(
|
||||
event_id=event.id,
|
||||
last_name="Иванов",
|
||||
first_name="Иван",
|
||||
middle_name="Иванович",
|
||||
|
||||
Reference in New Issue
Block a user