test: 81 unit tests for tickets (data, storage, transcript, ui)
All checks were successful
ci / ci (push) Successful in 1m3s

- tests/test_data.py: models serialization, enums, roundtrip
- tests/test_storage.py: CRUD JSON, cache, persistence
- tests/test_transcript.py: HTML generation, escaping, attachments
- tests/test_ui.py: button callbacks with mocked interactions
- Fixed: transcript.py attachments access (dataclass not dict)
This commit is contained in:
Mathis 2026-10-02 19:34:11 +02:00
parent 628e02aea9
commit e771073909
5 changed files with 812 additions and 1 deletions

244
tests/test_data.py Normal file
View file

@ -0,0 +1,244 @@
from __future__ import annotations
from tickets.data import (
GuildTicketConfig,
TicketCategory,
TicketData,
TicketMessage,
TicketPriority,
TicketStatus,
)
class TestTicketStatus:
def test_open_value(self):
assert TicketStatus.OPEN.value == "open"
def test_closed_value(self):
assert TicketStatus.CLOSED.value == "closed"
def test_enum_inheritance(self):
assert issubclass(TicketStatus, str)
class TestTicketPriority:
def test_all_values(self):
assert TicketPriority.LOW.value == "low"
assert TicketPriority.NORMAL.value == "normal"
assert TicketPriority.HIGH.value == "high"
assert TicketPriority.URGENT.value == "urgent"
class TestTicketCategory:
def test_create_default(self):
cat = TicketCategory(id="test", name="Test")
assert cat.id == "test"
assert cat.name == "Test"
assert cat.emoji == ""
assert cat.description == ""
assert cat.staff_role_id is None
assert cat.discord_category_id is None
def test_to_dict(self):
cat = TicketCategory(
id="support",
name="Support",
emoji="🎫",
description="Aide",
staff_role_id=123,
discord_category_id=456,
)
d = cat.to_dict()
assert d["id"] == "support"
assert d["name"] == "Support"
assert d["emoji"] == "🎫"
assert d["description"] == "Aide"
assert d["staff_role_id"] == 123
assert d["discord_category_id"] == 456
def test_from_dict(self):
d = {
"id": "support",
"name": "Support",
"emoji": "🎫",
"description": "Aide",
"staff_role_id": 123,
"discord_category_id": 456,
}
cat = TicketCategory.from_dict(d)
assert cat.id == "support"
assert cat.name == "Support"
assert cat.staff_role_id == 123
def test_from_dict_ignores_extra_keys(self):
d = {"id": "x", "name": "X", "unknown_key": "should be ignored"}
cat = TicketCategory.from_dict(d)
assert cat.id == "x"
def test_roundtrip(self):
cat = TicketCategory(id="a", name="A", emoji="🔧", description="desc")
cat2 = TicketCategory.from_dict(cat.to_dict())
assert cat2 == cat
class TestTicketMessage:
def test_create_minimal(self):
msg = TicketMessage(author_id=1, author_name="user", content="hello", timestamp="2026-01-01")
assert msg.attachments == []
assert msg.is_staff is False
def test_create_full(self):
msg = TicketMessage(
author_id=1,
author_name="user",
content="hello",
timestamp="2026-01-01",
attachments=["a.png"],
is_staff=True,
)
assert msg.attachments == ["a.png"]
assert msg.is_staff is True
class TestTicketData:
def test_create_minimal(self):
t = TicketData(channel_id=1, guild_id=2, user_id=3, category_id="cat")
assert t.status == TicketStatus.OPEN
assert t.priority == TicketPriority.NORMAL
assert t.locked is False
assert t.claimed_by is None
assert t.messages == []
assert t.reason == ""
def test_to_dict(self):
t = TicketData(
channel_id=100,
guild_id=200,
user_id=300,
category_id="support",
status=TicketStatus.CLOSED,
priority=TicketPriority.HIGH,
claimed_by=999,
locked=True,
reason="test",
)
d = t.to_dict()
assert d["channel_id"] == 100
assert d["status"] == "closed"
assert d["priority"] == "high"
assert d["claimed_by"] == 999
assert d["locked"] is True
assert d["reason"] == "test"
def test_to_dict_with_messages(self):
t = TicketData(channel_id=1, guild_id=2, user_id=3, category_id="c")
t.messages.append(TicketMessage(author_id=1, author_name="a", content="hi", timestamp="t"))
d = t.to_dict()
assert len(d["messages"]) == 1
assert d["messages"][0]["content"] == "hi"
def test_from_dict(self):
d = {
"channel_id": 1,
"guild_id": 2,
"user_id": 3,
"category_id": "c",
"status": "closed",
"priority": "high",
"claimed_by": 99,
"locked": True,
"reason": "r",
"messages": [{"author_id": 1, "author_name": "a", "content": "hi", "timestamp": "t"}],
}
t = TicketData.from_dict(d)
assert t.status == TicketStatus.CLOSED
assert t.priority == TicketPriority.HIGH
assert t.claimed_by == 99
assert t.locked is True
assert len(t.messages) == 1
def test_from_dict_legacy_keys(self):
d = {
"channel_id": 1,
"guild_id": 2,
"user_id": 3,
"category_id": "c",
"transcript_path": "/old/path.html",
"rating": 5,
"feedback": "ok",
}
t = TicketData.from_dict(d)
assert not hasattr(t, "transcript_path")
assert not hasattr(t, "rating")
assert not hasattr(t, "feedback")
def test_roundtrip(self):
t = TicketData(
channel_id=10,
guild_id=20,
user_id=30,
category_id="cat",
status=TicketStatus.CLOSED,
priority=TicketPriority.URGENT,
claimed_by=50,
locked=True,
reason="test reason",
created_at="2026-01-01T00:00:00",
closed_at="2026-01-02T00:00:00",
)
t.messages.append(TicketMessage(author_id=1, author_name="u", content="m", timestamp="t"))
t2 = TicketData.from_dict(t.to_dict())
assert t2.channel_id == t.channel_id
assert t2.status == t.status
assert t2.priority == t.priority
assert t2.claimed_by == t.claimed_by
assert t2.locked == t.locked
assert len(t2.messages) == 1
class TestGuildTicketConfig:
def test_create_default(self):
c = GuildTicketConfig(guild_id=1)
assert c.enabled is False
assert c.categories == []
assert c.staff_role_id is None
assert c.log_channel_id is None
assert c.transcript_enabled is True
def test_to_dict(self):
c = GuildTicketConfig(
guild_id=1,
enabled=True,
staff_role_id=100,
log_channel_id=200,
panel_channel_id=300,
panel_message_id=400,
)
c.categories.append(TicketCategory(id="a", name="A"))
d = c.to_dict()
assert d["guild_id"] == 1
assert d["enabled"] is True
assert d["staff_role_id"] == 100
assert d["log_channel_id"] == 200
assert len(d["categories"]) == 1
def test_from_dict(self):
d = {
"guild_id": 1,
"enabled": True,
"staff_role_id": 100,
"log_channel_id": 200,
"categories": [{"id": "a", "name": "A"}],
}
c = GuildTicketConfig.from_dict(d)
assert c.enabled is True
assert len(c.categories) == 1
assert c.categories[0].id == "a"
def test_roundtrip(self):
c = GuildTicketConfig(guild_id=42, enabled=True)
c.categories.append(TicketCategory(id="x", name="X", emoji="🔧"))
c2 = GuildTicketConfig.from_dict(c.to_dict())
assert c2.guild_id == 42
assert c2.enabled is True
assert c2.categories[0].id == "x"

130
tests/test_storage.py Normal file
View file

@ -0,0 +1,130 @@
from __future__ import annotations
import json
import pytest
from tickets import storage
from tickets.data import GuildTicketConfig, TicketCategory, TicketData
@pytest.fixture(autouse=True)
def clean_storage(tmp_path):
"""Isolate storage in a temp dir and reset caches."""
storage.TICKETS_DIR = tmp_path / "tickets"
storage._CONFIG_FILE = storage.TICKETS_DIR / "config.json"
storage._config_cache.clear()
storage._tickets_cache.clear()
yield
storage._config_cache.clear()
storage._tickets_cache.clear()
class TestGetConfig:
def test_returns_default_when_missing(self):
config = storage.get_config(guild_id=123)
assert config.guild_id == 123
assert config.enabled is False
assert config.categories == []
def test_returns_same_object(self):
c1 = storage.get_config(guild_id=1)
c2 = storage.get_config(guild_id=1)
assert c1 is c2
def test_different_guilds_different_configs(self):
c1 = storage.get_config(guild_id=1)
c2 = storage.get_config(guild_id=2)
assert c1.guild_id != c2.guild_id
class TestSaveConfig:
def test_save_creates_file(self, tmp_path):
config = storage.get_config(guild_id=1)
config.enabled = True
storage.save_config(config)
assert storage._CONFIG_FILE.exists()
def test_save_persists(self, tmp_path):
config = storage.get_config(guild_id=1)
config.enabled = True
config.categories.append(TicketCategory(id="a", name="A"))
storage.save_config(config)
storage._config_cache.clear()
loaded = storage.get_config(guild_id=1)
assert loaded.enabled is True
assert len(loaded.categories) == 1
assert loaded.categories[0].id == "a"
def test_save_multiple_guilds(self):
c1 = storage.get_config(guild_id=1)
c1.enabled = True
storage.save_config(c1)
c2 = storage.get_config(guild_id=2)
c2.enabled = False
storage.save_config(c2)
storage._config_cache.clear()
assert storage.get_config(guild_id=1).enabled is True
assert storage.get_config(guild_id=2).enabled is False
class TestSaveAndGetTicket:
def test_get_missing_returns_none(self):
assert storage.get_ticket(999) is None
def test_save_and_get(self):
ticket = TicketData(channel_id=100, guild_id=1, user_id=2, category_id="cat")
storage.save_ticket(ticket)
loaded = storage.get_ticket(100)
assert loaded is not None
assert loaded.channel_id == 100
assert loaded.category_id == "cat"
def test_save_persists_to_disk(self):
ticket = TicketData(channel_id=200, guild_id=1, user_id=2, category_id="c")
storage.save_ticket(ticket)
storage._tickets_cache.clear()
loaded = storage.get_ticket(200)
assert loaded is not None
assert loaded.user_id == 2
def test_save_overwrites(self):
t1 = TicketData(channel_id=300, guild_id=1, user_id=2, category_id="old")
storage.save_ticket(t1)
t2 = TicketData(channel_id=300, guild_id=1, user_id=2, category_id="new")
storage.save_ticket(t2)
loaded = storage.get_ticket(300)
assert loaded.category_id == "new"
def test_multiple_tickets(self):
t1 = TicketData(channel_id=400, guild_id=1, user_id=2, category_id="a")
t2 = TicketData(channel_id=401, guild_id=1, user_id=3, category_id="b")
storage.save_ticket(t1)
storage.save_ticket(t2)
assert storage.get_ticket(400).category_id == "a"
assert storage.get_ticket(401).category_id == "b"
class TestDeleteTicket:
def test_delete_removes_file(self):
ticket = TicketData(channel_id=500, guild_id=1, user_id=2, category_id="c")
storage.save_ticket(ticket)
assert storage.get_ticket(500) is not None
storage.delete_ticket(500)
assert storage.get_ticket(500) is None
def test_delete_nonexistent(self):
storage.delete_ticket(9999)
def test_delete_clears_cache(self):
ticket = TicketData(channel_id=600, guild_id=1, user_id=2, category_id="c")
storage.save_ticket(ticket)
storage.delete_ticket(600)
assert 600 not in storage._tickets_cache

133
tests/test_transcript.py Normal file
View file

@ -0,0 +1,133 @@
from __future__ import annotations
from tickets.data import TicketData, TicketMessage
from tickets.transcript import _format_ts, generate_transcript
class TestFormatTs:
def test_valid_iso(self):
result = _format_ts("2026-01-15T10:30:00+00:00")
assert result == "15/01/2026 10:30:00"
def test_valid_naive_iso(self):
result = _format_ts("2026-06-01T12:00:00")
assert result == "01/06/2026 12:00:00"
def test_invalid_string_returns_as_is(self):
assert _format_ts("not-a-date") == "not-a-date"
def test_empty_string(self):
assert _format_ts("") == ""
def test_none_returns_none(self):
assert _format_ts(None) is None
def _make_ticket(**kwargs) -> TicketData:
defaults = dict(channel_id=123, guild_id=456, user_id=789, category_id="support")
defaults.update(kwargs)
return TicketData(**defaults)
class TestGenerateTranscript:
def test_returns_html(self):
result = generate_transcript(_make_ticket())
assert result.startswith("<!DOCTYPE html>")
assert "</html>" in result
def test_contains_channel_id(self):
result = generate_transcript(_make_ticket(channel_id=42))
assert "Ticket #42" in result
def test_empty_messages(self):
ticket = _make_ticket()
result = generate_transcript(ticket)
assert "Aucun message" in result
def test_single_user_message(self):
ticket = _make_ticket()
ticket.messages.append(
TicketMessage(author_id=1, author_name="TestUser", content="Hello!", timestamp="2026-01-01T00:00:00")
)
result = generate_transcript(ticket)
assert "TestUser" in result
assert "Hello!" in result
assert 'class="badge badge-staff"' not in result
def test_staff_message_has_badge(self):
ticket = _make_ticket()
ticket.messages.append(
TicketMessage(
author_id=2, author_name="StaffUser", content="Reply", timestamp="2026-01-01T00:00:00", is_staff=True
)
)
result = generate_transcript(ticket)
assert "badge-staff" in result
assert "STAFF" in result
def test_message_content_escaped(self):
ticket = _make_ticket()
ticket.messages.append(
TicketMessage(author_id=1, author_name="x", content="<script>alert('xss')</script>", timestamp="t")
)
result = generate_transcript(ticket)
assert "<script>" not in result
assert "&lt;script&gt;" in result
def test_image_attachment(self):
ticket = _make_ticket()
ticket.messages.append(
TicketMessage(
author_id=1,
author_name="u",
content="pic",
timestamp="t",
attachments=["https://example.com/image.png"],
)
)
result = generate_transcript(ticket)
assert "<img" in result
assert "image.png" in result
def test_file_attachment(self):
ticket = _make_ticket()
ticket.messages.append(
TicketMessage(
author_id=1,
author_name="u",
content="file",
timestamp="t",
attachments=["https://example.com/doc.pdf"],
)
)
result = generate_transcript(ticket)
assert "<a href=" in result
assert "doc.pdf" in result
def test_closed_ticket_shows_closed_at(self):
ticket = _make_ticket(closed_at="2026-01-02T10:00:00+00:00")
result = generate_transcript(ticket)
assert "Ferme" in result or "Fermé" in result
def test_open_ticket_no_closed_at(self):
ticket = _make_ticket()
result = generate_transcript(ticket)
assert "Ferme" not in result
def test_dark_theme_colors(self):
result = generate_transcript(_make_ticket())
assert "#0b0d11" in result
def test_multiple_messages(self):
ticket = _make_ticket()
for i in range(5):
ticket.messages.append(
TicketMessage(author_id=i, author_name=f"User{i}", content=f"Message {i}", timestamp=f"t{i}")
)
result = generate_transcript(ticket)
for i in range(5):
assert f"Message {i}" in result
def test_footer_text(self):
result = generate_transcript(_make_ticket())
assert "Vision Studio" in result

304
tests/test_ui.py Normal file
View file

@ -0,0 +1,304 @@
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import discord
import pytest
from tickets import storage
from tickets.cog import ConfigToggleButton
from tickets.data import GuildTicketConfig, TicketCategory, TicketData, TicketStatus
from tickets.ui import (
AddMemberButton,
ClaimButton,
CloseButton,
LockButton,
PanelCreateButton,
TransferButton,
_is_staff,
_now_iso,
_send_log,
)
STAFF_ROLE_ID = 999999
@pytest.fixture(autouse=True)
def clean_storage():
storage._config_cache.clear()
storage._tickets_cache.clear()
config = GuildTicketConfig(guild_id=999)
config.staff_role_id = STAFF_ROLE_ID
storage._config_cache[999] = config
yield
storage._config_cache.clear()
storage._tickets_cache.clear()
def _make_member(is_admin=False, has_staff_role=True):
member = MagicMock(spec=discord.Member)
member.guild_permissions.administrator = is_admin
if has_staff_role:
member.roles = [MagicMock(id=STAFF_ROLE_ID)]
else:
member.roles = []
return member
def _mock_interaction(guild_id=999, user_id=1, channel_id=123, member=None):
if member is None:
member = _make_member()
interaction = MagicMock(spec=discord.Interaction)
interaction.guild_id = guild_id
interaction.channel_id = channel_id
interaction.channel = MagicMock()
interaction.channel.id = channel_id
interaction.user = MagicMock()
interaction.user.id = user_id
interaction.user.mention = f"<@{user_id}>"
interaction.response = AsyncMock()
interaction.followup = AsyncMock()
interaction.guild = MagicMock(spec=discord.Guild)
interaction.guild.id = guild_id
interaction.guild.get_member.return_value = member
interaction.channel.overwrites_for = MagicMock()
interaction.channel.set_permissions = AsyncMock()
interaction.channel.delete = AsyncMock()
interaction.channel.send = AsyncMock()
return interaction
def _save_ticket(channel_id=123, **kwargs):
defaults = dict(channel_id=channel_id, guild_id=999, user_id=1, category_id="cat")
defaults.update(kwargs)
ticket = TicketData(**defaults)
storage.save_ticket(ticket)
return ticket
class TestIsStaff:
def test_admin(self):
member = _make_member(is_admin=True)
config = storage.get_config(999)
assert _is_staff(member, config) is True
def test_staff_role_match(self):
member = _make_member(has_staff_role=True)
config = storage.get_config(999)
assert _is_staff(member, config) is True
def test_not_staff(self):
member = _make_member(has_staff_role=False)
config = storage.get_config(999)
assert _is_staff(member, config) is False
def test_category_staff_role(self):
member = _make_member(has_staff_role=False)
member.roles = [MagicMock(id=77777)]
config = storage.get_config(999)
config.categories = [TicketCategory(id="c", name="C", staff_role_id=77777)]
assert _is_staff(member, config) is True
class TestNowIso:
def test_returns_iso_string(self):
result = _now_iso()
assert "T" in result
class TestSendLog:
@pytest.mark.asyncio
async def test_no_log_channel(self):
config = storage.get_config(999)
guild = MagicMock()
guild.get_channel.return_value = None
await _send_log(guild, config, MagicMock())
guild.get_channel.assert_not_called()
@pytest.mark.asyncio
async def test_sends_to_log_channel(self):
config = storage.get_config(999)
config.log_channel_id = 555
channel = AsyncMock()
guild = MagicMock()
guild.get_channel.return_value = channel
embed = MagicMock()
await _send_log(guild, config, embed)
channel.send.assert_called_once_with(embed=embed)
class TestPanelCreateButton:
@pytest.mark.asyncio
async def test_disabled_system(self):
config = storage.get_config(999)
config.enabled = False
interaction = _mock_interaction()
await PanelCreateButton().callback(interaction)
interaction.response.send_message.assert_called_once()
@pytest.mark.asyncio
async def test_no_categories(self):
config = storage.get_config(999)
config.enabled = True
config.categories = []
interaction = _mock_interaction()
await PanelCreateButton().callback(interaction)
interaction.response.send_message.assert_called_once()
@pytest.mark.asyncio
async def test_single_category_opens_modal(self):
config = storage.get_config(999)
config.enabled = True
config.categories = [TicketCategory(id="c1", name="Cat1")]
interaction = _mock_interaction()
await PanelCreateButton().callback(interaction)
interaction.response.send_modal.assert_called_once()
@pytest.mark.asyncio
async def test_multiple_categories_sends_select(self):
config = storage.get_config(999)
config.enabled = True
config.categories = [
TicketCategory(id="c1", name="Cat1"),
TicketCategory(id="c2", name="Cat2"),
]
interaction = _mock_interaction()
await PanelCreateButton().callback(interaction)
call_kwargs = interaction.response.send_message.call_args.kwargs
assert "view" in call_kwargs
class TestClaimButton:
@pytest.mark.asyncio
async def test_not_a_ticket(self):
interaction = _mock_interaction()
await ClaimButton().callback(interaction)
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
@pytest.mark.asyncio
async def test_not_staff(self):
_save_ticket()
member = _make_member(is_admin=False, has_staff_role=False)
interaction = _mock_interaction(member=member)
await ClaimButton().callback(interaction)
assert "staff" in interaction.response.send_message.call_args[0][0].lower()
@pytest.mark.asyncio
async def test_claim_success(self):
_save_ticket()
interaction = _mock_interaction()
await ClaimButton().callback(interaction)
assert storage.get_ticket(123).claimed_by == 1
@pytest.mark.asyncio
async def test_unclaim_own(self):
_save_ticket(claimed_by=1)
interaction = _mock_interaction()
await ClaimButton().callback(interaction)
assert storage.get_ticket(123).claimed_by is None
@pytest.mark.asyncio
async def test_already_claimed_by_other(self):
_save_ticket(claimed_by=42)
interaction = _mock_interaction()
await ClaimButton().callback(interaction)
assert storage.get_ticket(123).claimed_by == 42
class TestLockButton:
@pytest.mark.asyncio
async def test_not_a_ticket(self):
interaction = _mock_interaction(channel_id=99999)
await LockButton().callback(interaction)
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
@pytest.mark.asyncio
async def test_lock_toggle(self):
_save_ticket(locked=False)
interaction = _mock_interaction()
interaction.channel.overwrites_for.return_value = MagicMock()
await LockButton().callback(interaction)
assert storage.get_ticket(123).locked is True
interaction.channel.set_permissions.assert_called_once()
@pytest.mark.asyncio
async def test_unlock_toggle(self):
_save_ticket(locked=True)
interaction = _mock_interaction()
interaction.channel.overwrites_for.return_value = MagicMock()
await LockButton().callback(interaction)
assert storage.get_ticket(123).locked is False
class TestCloseButton:
@pytest.mark.asyncio
async def test_not_a_ticket(self):
interaction = _mock_interaction(channel_id=99999)
await CloseButton().callback(interaction)
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
@pytest.mark.asyncio
async def test_not_staff(self):
_save_ticket()
member = _make_member(is_admin=False, has_staff_role=False)
interaction = _mock_interaction(member=member)
await CloseButton().callback(interaction)
assert "staff" in interaction.response.send_message.call_args[0][0].lower()
@pytest.mark.asyncio
async def test_close_success(self):
_save_ticket()
interaction = _mock_interaction()
await CloseButton().callback(interaction)
loaded = storage.get_ticket(123)
assert loaded.status == TicketStatus.CLOSED
assert loaded.closed_by == 1
@pytest.mark.asyncio
async def test_close_without_transcript(self):
config = storage.get_config(999)
config.transcript_enabled = False
_save_ticket()
interaction = _mock_interaction()
await CloseButton().callback(interaction)
assert storage.get_ticket(123).status == TicketStatus.CLOSED
class TestTransferButton:
@pytest.mark.asyncio
async def test_not_a_ticket(self):
interaction = _mock_interaction(channel_id=99999)
await TransferButton().callback(interaction)
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
@pytest.mark.asyncio
async def test_sends_user_select(self):
_save_ticket()
interaction = _mock_interaction()
await TransferButton().callback(interaction)
assert "view" in interaction.response.send_message.call_args.kwargs
class TestAddMemberButton:
@pytest.mark.asyncio
async def test_not_a_ticket(self):
interaction = _mock_interaction(channel_id=99999)
await AddMemberButton().callback(interaction)
assert "pas un ticket" in interaction.response.send_message.call_args[0][0]
@pytest.mark.asyncio
async def test_sends_user_select(self):
_save_ticket()
interaction = _mock_interaction()
await AddMemberButton().callback(interaction)
assert "view" in interaction.response.send_message.call_args.kwargs
class TestConfigToggleButton:
@pytest.mark.asyncio
async def test_toggle(self):
config = storage.get_config(999)
initial = config.enabled
interaction = _mock_interaction()
await ConfigToggleButton(enabled=initial).callback(interaction)
assert config.enabled != initial

View file

@ -24,7 +24,7 @@ def generate_transcript(ticket: TicketData, guild_name: str = "") -> str:
badge = '<span class="badge badge-staff">STAFF</span>' if msg.is_staff else "" badge = '<span class="badge badge-staff">STAFF</span>' if msg.is_staff else ""
att_html = "" att_html = ""
for att in msg.get("attachments", []) if hasattr(msg, "attachments") else msg.attachments: for att in msg.attachments:
if any(att.lower().endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".gif", ".webp")): if any(att.lower().endswith(ext) for ext in (".png", ".jpg", ".jpeg", ".gif", ".webp")):
att_html += f'<div class="attachment"><img src="{html.escape(att)}" /></div>' att_html += f'<div class="attachment"><img src="{html.escape(att)}" /></div>'
else: else: