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
|
#!/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
|
export DIRENV_WARN_TIMEOUT=20s
|
||||||
|
|
||||||
eval "$(devenv direnvrc)"
|
eval "$(devenv direnvrc)"
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -4,3 +4,6 @@ devenv.lock
|
||||||
uv.lock
|
uv.lock
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
|
.env*
|
||||||
|
*.pickle
|
||||||
|
src/kronos/*events*.html
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ repos:
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
name: Ruff format
|
name: Ruff format
|
||||||
description: Format.
|
description: Format.
|
||||||
entry: ruff format --check --config pyproject.toml
|
entry: ruff format --config pyproject.toml
|
||||||
language: system
|
language: system
|
||||||
types: [python]
|
types: [python]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ pre-commit install
|
||||||
- Install as editable package.
|
- Install as editable package.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e .
|
uv pip install -e .
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run
|
## Run
|
||||||
|
|
|
||||||
23
devenv.nix
23
devenv.nix
|
|
@ -1,3 +1,4 @@
|
||||||
|
{ pkgs, ... }:
|
||||||
{
|
{
|
||||||
name = "kronos";
|
name = "kronos";
|
||||||
|
|
||||||
|
|
@ -11,4 +12,26 @@
|
||||||
sync.allExtras = true;
|
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."
|
description = "Share events between services."
|
||||||
authors = [{ name = "Hackerspace Trójmiasto" }]
|
authors = [{ name = "Hackerspace Trójmiasto" }]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license-files = ["LICENSE"]
|
||||||
|
|
||||||
requires-python = ">=3.13"
|
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]
|
[dependency-groups]
|
||||||
dev = ["pre-commit", "pre-commit-hooks", "pytest", "ruff"]
|
dev = ["pre-commit", "pre-commit-hooks", "pytest", "ruff"]
|
||||||
|
|
@ -47,7 +72,7 @@ exclude = [
|
||||||
]
|
]
|
||||||
|
|
||||||
line-length = 100
|
line-length = 100
|
||||||
indent-width = 4
|
indent-width = 2
|
||||||
target-version = "py313"
|
target-version = "py313"
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
|
|
|
||||||
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