feat: Add initial Mobilizon => Discord sync #1
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
|
||||
|
leming
commented
This was done on purpose - check only in pre-commit hook, force dev to know what formatting has happened. This was done on purpose - check only in pre-commit hook, force dev to know what formatting has happened.
doomhammer
commented
Then we should have a command that runs all the linters and formatters as we seem to have checks that enforce certain rules but don't have a command to apply those rules. Then we should have a command that runs all the linters and formatters as we seem to have checks that enforce certain rules but don't have a command to apply those rules.
leming
commented
https://forge.hs3.pl/leming/kronos/issues/19
|
||||
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"]
|
||||
|
leming
commented
What is the reason for change? What is the reason for change?
doomhammer
commented
uv made me do it uv made me do it
|
||||
|
||||
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"
|
|||
|
||||
|
leming
commented
Does Does `kronos` command still work?
doomhammer
commented
yes yes
|
||||
[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
|
||||
|
leming
commented
What is the reason for change? What is the reason for change?
doomhammer
commented
1. I prefer smaller files
2. For some reason ruff seemed to like the line length when formatting but not when linting. Using 2 spaces for indent made it shut its yap. I know, ugly. And disgusting. But I spent too much time looking for a solution...
|
||||
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 @@
|
|||
{
|
||||
|
leming
commented
Not an issue, but let me know what happens here. Not an issue, but let me know what happens here.
doomhammer
commented
I'll add the comments to the file I'll add the comments to the file
|
||||
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 = [
|
||||
|
doomhammer marked this conversation as resolved
leming
commented
What's with the french? What's with the french?
doomhammer
commented
It was stolen from the French: https://github.com/cleming/newsletter-kalepin It was stolen from the French: https://github.com/cleming/newsletter-kalepin
|
||||
"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
Check if actually needed.
Add explanation comment.