feat: Add initial Mobilizon => Discord sync
This commit is contained in:
parent
246ef4c829
commit
3fe23fdc38
12 changed files with 545 additions and 49 deletions
6
.envrc
6
.envrc
|
|
@ -1,5 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
if ! has nix_direnv_version || ! nix_direnv_version 3.0.5; then
|
||||
source_url "https://raw.githubusercontent.com/nix-community/nix-direnv/3.0.5/direnvrc" "sha256-RuwIS+QKFj/T9M2TFXScjBsLR6V3A17YVoEW/Q6AZ1w="
|
||||
fi
|
||||
watch_file nix/*.nix
|
||||
use nix
|
||||
|
||||
export DIRENV_WARN_TIMEOUT=20s
|
||||
|
||||
eval "$(devenv direnvrc)"
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,3 +4,6 @@ devenv.lock
|
|||
uv.lock
|
||||
__pycache__/
|
||||
*.egg-info/
|
||||
.env*
|
||||
*.pickle
|
||||
src/kronos/*events*.html
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ repos:
|
|||
- id: ruff-format
|
||||
name: Ruff format
|
||||
description: Format.
|
||||
entry: ruff format --check --config pyproject.toml
|
||||
entry: ruff format --config pyproject.toml
|
||||
language: system
|
||||
types: [python]
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ pre-commit install
|
|||
- Install as editable package.
|
||||
|
||||
```bash
|
||||
pip install -e .
|
||||
uv pip install -e .
|
||||
```
|
||||
|
||||
## Run
|
||||
|
|
|
|||
23
devenv.nix
23
devenv.nix
|
|
@ -1,3 +1,4 @@
|
|||
{ pkgs, ... }:
|
||||
{
|
||||
name = "kronos";
|
||||
|
||||
|
|
@ -11,4 +12,26 @@
|
|||
sync.allExtras = true;
|
||||
};
|
||||
};
|
||||
|
||||
packages = [
|
||||
pkgs.uv
|
||||
];
|
||||
|
||||
enterShell = ''
|
||||
echo
|
||||
echo "🪝 Installing pre-commit"
|
||||
echo
|
||||
pre-commit install
|
||||
|
||||
echo
|
||||
echo "✨ Installing kronos"
|
||||
echo
|
||||
uv pip install -e .
|
||||
|
||||
echo
|
||||
echo "🙌 Welcome to kronos' devenv!"
|
||||
echo 'To run kronos, type `kronos`'
|
||||
echo
|
||||
echo
|
||||
'';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
from argparse import ArgumentParser
|
||||
|
||||
|
||||
def _main():
|
||||
parser = ArgumentParser(prog="kronos", description="Share events between services.")
|
||||
parser.add_argument("--name")
|
||||
_args = parser.parse_args()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
|
|
@ -4,11 +4,36 @@ version = "0.1.0"
|
|||
description = "Share events between services."
|
||||
authors = [{ name = "Hackerspace Trójmiasto" }]
|
||||
readme = "README.md"
|
||||
license = { file = "LICENSE" }
|
||||
license-files = ["LICENSE"]
|
||||
|
||||
requires-python = ">=3.13"
|
||||
|
||||
dependencies = []
|
||||
dependencies = [
|
||||
"beautifulsoup4==4.13.4",
|
||||
"brevo-python==1.1.2",
|
||||
"bs4==0.0.2",
|
||||
"cachetools==6.1.0",
|
||||
"certifi==2025.4.26",
|
||||
"charset-normalizer==3.4.2",
|
||||
"cssselect==1.3.0",
|
||||
"cssutils==2.11.1",
|
||||
"discord.py==2.7.1",
|
||||
"html5lib==1.1",
|
||||
"idna==3.10",
|
||||
"Jinja2==3.1.6",
|
||||
"lxml==6.0.0",
|
||||
"MarkupSafe==3.0.2",
|
||||
"more-itertools==10.7.0",
|
||||
"premailer==3.10.0",
|
||||
"python-dateutil==2.9.0.post0",
|
||||
"python-dotenv==1.1.1",
|
||||
"requests==2.32.4",
|
||||
"six==1.17.0",
|
||||
"soupsieve==2.7",
|
||||
"typing_extensions==4.14.0",
|
||||
"urllib3==2.5.0",
|
||||
"webencodings==0.5.1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = ["pre-commit", "pre-commit-hooks", "pytest", "ruff"]
|
||||
|
|
@ -18,47 +43,47 @@ kronos = "kronos.main:_main"
|
|||
|
||||
[tool.ruff]
|
||||
exclude = [
|
||||
".bzr",
|
||||
".direnv",
|
||||
".eggs",
|
||||
".git",
|
||||
".git-rewrite",
|
||||
".hg",
|
||||
".ipynb_checkpoints",
|
||||
".mypy_cache",
|
||||
".nox",
|
||||
".pants.d",
|
||||
".pyenv",
|
||||
".pytest_cache",
|
||||
".pytype",
|
||||
".ruff_cache",
|
||||
".svn",
|
||||
".tox",
|
||||
".venv",
|
||||
".vscode",
|
||||
"__pypackages__",
|
||||
"_build",
|
||||
"buck-out",
|
||||
"build",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"site-packages",
|
||||
"venv",
|
||||
".bzr",
|
||||
".direnv",
|
||||
".eggs",
|
||||
".git",
|
||||
".git-rewrite",
|
||||
".hg",
|
||||
".ipynb_checkpoints",
|
||||
".mypy_cache",
|
||||
".nox",
|
||||
".pants.d",
|
||||
".pyenv",
|
||||
".pytest_cache",
|
||||
".pytype",
|
||||
".ruff_cache",
|
||||
".svn",
|
||||
".tox",
|
||||
".venv",
|
||||
".vscode",
|
||||
"__pypackages__",
|
||||
"_build",
|
||||
"buck-out",
|
||||
"build",
|
||||
"dist",
|
||||
"node_modules",
|
||||
"site-packages",
|
||||
"venv",
|
||||
]
|
||||
|
||||
line-length = 100
|
||||
indent-width = 4
|
||||
indent-width = 2
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
# pycodestyle
|
||||
"E",
|
||||
"W",
|
||||
# pyflakes
|
||||
"F",
|
||||
# pylint
|
||||
"PL",
|
||||
# pycodestyle
|
||||
"E",
|
||||
"W",
|
||||
# pyflakes
|
||||
"F",
|
||||
# pylint
|
||||
"PL",
|
||||
]
|
||||
ignore = []
|
||||
fixable = ["ALL"]
|
||||
|
|
|
|||
8
shell.nix
Normal file
8
shell.nix
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
pkgs ? import <nixpkgs> { },
|
||||
}:
|
||||
pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
devenv
|
||||
];
|
||||
}
|
||||
86
src/kronos/discordbot.py
Normal file
86
src/kronos/discordbot.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import pickle
|
||||
import os.path
|
||||
import urllib.request
|
||||
|
||||
import discord
|
||||
|
||||
|
||||
class KronosBot(discord.Client):
|
||||
def attach_events(self, events):
|
||||
print(f"Adding {len(events)} events")
|
||||
self.events = events
|
||||
|
||||
async def on_ready(self):
|
||||
event_mapping_file = "event_mapping.pickle"
|
||||
event_mapping = dict()
|
||||
|
||||
try:
|
||||
if os.path.isfile(event_mapping_file):
|
||||
with open(event_mapping_file, "rb") as f:
|
||||
event_mapping = pickle.load(f)
|
||||
except Exception:
|
||||
print("There are problems opening the pickle jar")
|
||||
|
||||
try:
|
||||
print(f"Logged on as {self.user}!")
|
||||
guilds_total = len(self.guilds)
|
||||
print(f"I work for {guilds_total} guilds")
|
||||
guilds_processed = 0
|
||||
for guild in self.guilds:
|
||||
guild_id = str(guild)
|
||||
print(f"Processing {guild.name} as {guild.me.name}")
|
||||
guilds_processed += 1
|
||||
for event in self.events:
|
||||
description = event["link"] + "\n\n" + event["description"]
|
||||
|
||||
picture_url = "https://hs3.pl/images/zasoby/null.jpg"
|
||||
if event["picture_url"] is not None:
|
||||
picture_url = event["picture_url"]
|
||||
pic = urllib.request.urlopen(picture_url).read()
|
||||
|
||||
print(f"Processing event {event}")
|
||||
if event["link"] in event_mapping and guild_id in event_mapping[event["link"]]:
|
||||
print("Updating the event, this one already processed")
|
||||
discord_event = guild.get_scheduled_event(event_mapping[event["link"]][guild_id])
|
||||
if discord_event is not None:
|
||||
await discord_event.edit(
|
||||
name=event["title"],
|
||||
description=description,
|
||||
start_time=event["start_time"],
|
||||
end_time=event["end_time"],
|
||||
location=event["location"],
|
||||
image=pic,
|
||||
)
|
||||
else:
|
||||
discord_event = await guild.create_scheduled_event(
|
||||
name=event["title"],
|
||||
description=description,
|
||||
start_time=event["start_time"],
|
||||
end_time=event["end_time"],
|
||||
location=event["location"],
|
||||
image=pic,
|
||||
entity_type=discord.EntityType.external,
|
||||
privacy_level=discord.PrivacyLevel.guild_only,
|
||||
)
|
||||
if event["link"] not in event_mapping:
|
||||
event_mapping[event["link"]] = dict()
|
||||
event_mapping[event["link"]][guild_id] = discord_event.id
|
||||
print(f"Mobilizon event {event['link']} mapped to Discord event {discord_event.id}")
|
||||
|
||||
print(f"Dumping event mapping to a pickle: {event_mapping}")
|
||||
with open(event_mapping_file, "wb") as f:
|
||||
pickle.dump(event_mapping, f)
|
||||
except Exception:
|
||||
print("There are problems processing the events, mate")
|
||||
|
||||
print("So long, and thanks for all the fish!")
|
||||
await self.close()
|
||||
|
||||
async def on_message(self, message):
|
||||
print(f"Message from {message.author}: {message.content}")
|
||||
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = False
|
||||
|
||||
client = KronosBot(intents=intents)
|
||||
253
src/kronos/main.py
Executable file
253
src/kronos/main.py
Executable file
|
|
@ -0,0 +1,253 @@
|
|||
import datetime
|
||||
import os
|
||||
import sys
|
||||
from zoneinfo import ZoneInfo
|
||||
from argparse import ArgumentParser
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from dotenv import load_dotenv
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
from premailer import transform
|
||||
|
||||
from .discordbot import KronosBot, intents
|
||||
|
||||
|
||||
MOBILIZON_API_URL = "https://events.hs3.pl/api"
|
||||
QUERY_LIMIT = 100
|
||||
TIME_WINDOW_DAYS = 365
|
||||
TRUNCATE_AFTER_CHARACTERS = 300
|
||||
TEMPLATE_FILENAME = "templates/newsletter_template.html"
|
||||
OUTPUT_FILENAME = "newsletter_events.html"
|
||||
|
||||
|
||||
def sanitize_html(raw_html: str) -> str:
|
||||
wrapper = f"<div>{raw_html}</div>"
|
||||
soup = BeautifulSoup(wrapper, "html5lib")
|
||||
div = soup.find("div")
|
||||
cleaned = "".join(str(child) for child in div.contents)
|
||||
return cleaned
|
||||
|
||||
|
||||
def get_time_window(days: int = 365):
|
||||
now_utc = datetime.datetime.now(datetime.timezone.utc)
|
||||
ends_utc = now_utc + datetime.timedelta(days=days)
|
||||
|
||||
begins_iso = now_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
ends_iso = ends_utc.replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
return begins_iso, ends_iso
|
||||
|
||||
|
||||
def build_graphql_query():
|
||||
return """
|
||||
query SearchEventsInWindow($beginsOn: DateTime, $endsOn: DateTime, $limit: Int) {
|
||||
searchEvents(beginsOn: $beginsOn, endsOn: $endsOn, limit: $limit) {
|
||||
total
|
||||
elements {
|
||||
__typename
|
||||
... on Event {
|
||||
id
|
||||
title
|
||||
description
|
||||
beginsOn
|
||||
picture {
|
||||
url
|
||||
}
|
||||
url
|
||||
physicalAddress {
|
||||
description
|
||||
locality
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
def fetch_events(begins_on: str, ends_on: str, limit: int = QUERY_LIMIT):
|
||||
query = build_graphql_query()
|
||||
variables = {"beginsOn": begins_on, "endsOn": ends_on, "limit": limit}
|
||||
payload = {"query": query, "variables": variables}
|
||||
headers = {"Content-Type": "application/json"}
|
||||
|
||||
resp = requests.post(MOBILIZON_API_URL, json=payload, headers=headers)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if "errors" in data:
|
||||
raise RuntimeError(f"GraphQL error: {data['errors']}")
|
||||
|
||||
events = []
|
||||
for elem in data["data"]["searchEvents"]["elements"]:
|
||||
if elem.get("__typename") == "Event":
|
||||
begins_iso = elem.get("beginsOn")
|
||||
if begins_iso:
|
||||
dt = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
|
||||
dt_begins = datetime.datetime.fromisoformat(begins_on.replace("Z", "+00:00"))
|
||||
dt_ends = datetime.datetime.fromisoformat(ends_on.replace("Z", "+00:00"))
|
||||
if dt_begins <= dt < dt_ends:
|
||||
events.append(elem)
|
||||
return events
|
||||
|
||||
|
||||
def prepare_events_for_template(raw_events: list) -> list:
|
||||
jours_semaine = [
|
||||
"poniedziałek",
|
||||
"wtorek",
|
||||
"środa",
|
||||
"czwartek",
|
||||
"piątek",
|
||||
"sobota",
|
||||
"niedziela",
|
||||
]
|
||||
mois_annee = [
|
||||
"",
|
||||
"stycznia",
|
||||
"lutego",
|
||||
"marca",
|
||||
"kwietnia",
|
||||
"maja",
|
||||
"czerwca",
|
||||
"lipca",
|
||||
"sierpnia",
|
||||
"września",
|
||||
"października",
|
||||
"listopada",
|
||||
"grudnia",
|
||||
]
|
||||
|
||||
prepared = []
|
||||
for ev in raw_events:
|
||||
title = ev.get("title", "Untitled")
|
||||
|
||||
raw_desc = ev.get("description") or ""
|
||||
cleaned_html = sanitize_html(raw_desc)
|
||||
|
||||
text_only = BeautifulSoup(cleaned_html, "html.parser").get_text()
|
||||
if len(text_only) > TRUNCATE_AFTER_CHARACTERS:
|
||||
truncated = text_only[:300].rstrip() + " …"
|
||||
else:
|
||||
truncated = text_only
|
||||
|
||||
begins_iso = ev.get("beginsOn")
|
||||
picture = ev.get("picture")
|
||||
picture_url = picture.get("url") if picture else None
|
||||
link = ev.get("url") or ""
|
||||
|
||||
if picture_url and "?" in picture_url:
|
||||
picture_url = picture_url.split("?", 1)[0]
|
||||
|
||||
phys = ev.get("physicalAddress")
|
||||
if phys:
|
||||
part1 = phys.get("description") or ""
|
||||
part2 = phys.get("locality") or ""
|
||||
location = ", ".join(x for x in (part1, part2) if x.strip())
|
||||
else:
|
||||
location = ""
|
||||
|
||||
dt_utc = datetime.datetime.fromisoformat(begins_iso.replace("Z", "+00:00"))
|
||||
dt_paris = dt_utc.astimezone(ZoneInfo("Europe/Paris"))
|
||||
jour_nom = jours_semaine[dt_paris.weekday()]
|
||||
jour_num = dt_paris.day
|
||||
mois_nom = mois_annee[dt_paris.month]
|
||||
annee = dt_paris.year
|
||||
heure = dt_paris.hour
|
||||
minute = dt_paris.minute
|
||||
minute_str = f"{minute:02d}"
|
||||
full_date = f"{jour_nom}, {jour_num} {mois_nom} {annee} @ {heure}:{minute_str}"
|
||||
|
||||
prepared.append(
|
||||
{
|
||||
"title": title,
|
||||
"description": truncated,
|
||||
"full_date": full_date,
|
||||
"picture_url": picture_url,
|
||||
"location": location,
|
||||
"link": link,
|
||||
"start_time": dt_utc,
|
||||
"end_time": dt_utc + datetime.timedelta(hours=1),
|
||||
}
|
||||
)
|
||||
|
||||
return prepared
|
||||
|
||||
|
||||
def render_newsletter(events: list, template_dir: str, template_name: str) -> str:
|
||||
env = Environment(
|
||||
loader=FileSystemLoader(searchpath=template_dir),
|
||||
autoescape=select_autoescape(["html", "xml"]),
|
||||
)
|
||||
template = env.get_template(template_name)
|
||||
|
||||
now_paris = datetime.datetime.now(ZoneInfo("Europe/Paris")).strftime("%Y-%m-%d %H:%M")
|
||||
return template.render(events=events, date_now=now_paris)
|
||||
|
||||
|
||||
def inline_css(input_html_path, output_html_path):
|
||||
with open(input_html_path, "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
inlined_html = transform(html)
|
||||
with open(output_html_path, "w", encoding="utf-8") as f:
|
||||
f.write(inlined_html)
|
||||
|
||||
|
||||
def _main():
|
||||
load_dotenv()
|
||||
|
||||
parser = ArgumentParser(prog="kronos", description="Share events between services.")
|
||||
parser.add_argument("--name")
|
||||
_args = parser.parse_args()
|
||||
|
||||
try:
|
||||
begins_on, ends_on = get_time_window(days=TIME_WINDOW_DAYS)
|
||||
print(
|
||||
f"Fetching events between {begins_on} and {ends_on}...",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
raw_events = list(
|
||||
filter(
|
||||
lambda x: "https://events.hs3.pl/" in x.get("url", ""),
|
||||
fetch_events(begins_on, ends_on),
|
||||
)
|
||||
)
|
||||
if not raw_events:
|
||||
print("No events found in the requested period.", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
for ev in raw_events:
|
||||
print(f"{ev.get('title', 'Untitled')} — {ev.get('beginsOn', 'Unknown date')}")
|
||||
|
||||
raw_events.sort(key=lambda ev: ev.get("beginsOn", ""))
|
||||
|
||||
events = prepare_events_for_template(raw_events)
|
||||
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
html_output = render_newsletter(
|
||||
events=events, template_dir=script_dir, template_name=TEMPLATE_FILENAME
|
||||
)
|
||||
|
||||
output_path = os.path.join(script_dir, OUTPUT_FILENAME)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
f.write(html_output)
|
||||
print(f"File '{output_path}' generated successfully.", file=sys.stderr)
|
||||
|
||||
inlined_output_path = os.path.join(script_dir, "newsletter_events_inlined.html")
|
||||
inline_css(output_path, inlined_output_path)
|
||||
print(
|
||||
f"File '{inlined_output_path}' (inline CSS) generated.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
discord_token = os.getenv("DISCORD_TOKEN")
|
||||
client = KronosBot(intents=intents)
|
||||
client.attach_events(events)
|
||||
client.run(discord_token)
|
||||
|
||||
except Exception as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
103
src/kronos/templates/newsletter_template.html
Normal file
103
src/kronos/templates/newsletter_template.html
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="plr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Najbliższe wydarzenia w Hackerspace Trójmiasto (HS3)</title>
|
||||
<style>
|
||||
/* Dark mode */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.dark-mode-button {
|
||||
background: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
border: 1px solid #cccccc !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
.event-table {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.event-table tbody,
|
||||
.event-table tr,
|
||||
.event-table td {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
.event-image {
|
||||
text-align: center !important;
|
||||
padding: 20px 20px 10px 20px !important;
|
||||
}
|
||||
.event-content {
|
||||
padding: 10px 10px 20px 10px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.event-image img {
|
||||
width: 100% !important;
|
||||
height: auto !important;
|
||||
max-width: 300px !important;
|
||||
}
|
||||
/* Simplification mobile */
|
||||
.event-content * {
|
||||
max-width: none !important;
|
||||
width: auto !important;
|
||||
display: inline-block !important;
|
||||
white-space: normal !important;
|
||||
}
|
||||
.event-content h2 {
|
||||
display: block !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
.event-content a {
|
||||
display: inline-block !important;
|
||||
width: auto !important;
|
||||
max-width: 200px !important;
|
||||
}
|
||||
.event-content div[style*="background:#ff7105"] {
|
||||
white-space: nowrap !important;
|
||||
font-size: 12px !important;
|
||||
padding: 4px 8px !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="background-color:#f0f2f5; font-family:Helvetica,Arial,sans-serif; color:#333; padding:20px;">
|
||||
<div style="max-width:800px; margin:0 auto; background:#fff; border-radius:8px; overflow:hidden; box-shadow:0 4px 16px #eee;">
|
||||
<div style="text-align:center; padding:40px 20px 20px 20px; background:#4B64F2;">
|
||||
<img src="https://img.notionusercontent.com/s3/prod-files-secure%2F00deef52-50e0-4f54-b2ee-5c2fbc2c4c6d%2F2ad5c423-a798-4a76-9a07-9be972f119cb%2FHS_LOGO_COLOR_BLACK.png/size/w=1440?exp=1773155386&sig=QdEp5ee-WNVt8umc9b3zwnO4lrUpbwRKYym5aG0zyL4&id=89cbb915-e2d5-4d18-85d7-2c8cf5bae1b5&table=block" alt="Hackerspace Trójmiasto" style="width:230px; display:block; margin:0 auto 24px auto;">
|
||||
<h1 style="color:#fff; font-size:24px; font-weight:500; margin:0;">NAJBLIŻSZE WYDARZENIA W HACKERSPACE TRÓJMIASTO (HS3)</h1>
|
||||
</div>
|
||||
<div style="padding:32px 10px 10px 10px; overflow-x:hidden;">
|
||||
{% for event in events %}
|
||||
<table class="event-table" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:32px; background:#fff; border-radius:8px; border:1px solid #eee; table-layout:fixed;">
|
||||
<tr>
|
||||
{% if event.picture_url %}
|
||||
<td class="event-image" width="220" valign="top" style="padding:20px;">
|
||||
<img src="{{ event.picture_url }}" alt="Photo de l'événement" style="display:block; max-width:200px; height:auto;">
|
||||
</td>
|
||||
{% endif %}
|
||||
<td class="event-content" valign="top" style="padding:20px; overflow:hidden;">
|
||||
<h2 style="font-size:20px; color:#2a2a2a; margin:0 0 8px 0; line-height:1.2; max-width:100%; white-space:normal;">{{ event.title }}</h2>
|
||||
<div style="background:#ff7105; color:#fff; padding:6px 12px; border-radius:20px; font-size:14px; margin-bottom:12px; margin-top:4px; display:inline-block; font-weight:500; white-space:nowrap;">
|
||||
{{ event.full_date }}
|
||||
</div>
|
||||
{% if event.location %}
|
||||
<div style="font-size:15px; color:#000; font-weight:500; margin-bottom:6px; max-width:100%; white-space:normal;">Gdzie : {{ event.location }}</div>
|
||||
{% endif %}
|
||||
<div style="font-size:15px; color:#444; margin-bottom:12px; max-width:100%; white-space:normal;">{{ event.description }}</div>
|
||||
{% if event.link %}
|
||||
<a href="{{ event.link }}" class="dark-mode-button" style="display:inline-block; background:#4B64F2; color:#fff; text-decoration:none; padding:10px 20px; border-radius:4px; font-weight:500;">Sprawdź na HS3 Events</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div style="padding:20px; text-align:center; font-size:14px; color:#777; background:#fafbfd;">
|
||||
<p>Otrzymujesz ten newsletter ponieważ jesteś super cool 1337 osobą subskrybującą info HS3.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue