#!/usr/bin/env python3
"""
HermitHire Campaign GUI — Flask web dashboard
Run: python3 gui.py
Then open: http://localhost:5001
"""
import csv, json, os, re, subprocess, sys, time, threading, smtplib
from functools import wraps
from pathlib import Path
from flask import Flask, render_template, jsonify, request, Response, redirect, url_for, session
from datetime import datetime, timedelta
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from html import escape as html_escape
import urllib.request, urllib.error
BASE = Path(__file__).parent
ENV_FILE = BASE / '.env'
CONTACTS_FILE = BASE / 'contacts' / 'master.csv'
LOG_FILE = BASE / 'tracking' / 'log.json'
CAMPAIGN_LOG = BASE / 'tracking' / 'campaign.log'
app = Flask(__name__, template_folder='gui_templates')
app.secret_key = os.urandom(24) # session encryption
def login_required(f):
@wraps(f)
def decorated(*args, **kwargs):
if not session.get('authed'):
return redirect(url_for('login_page', next=request.path))
return f(*args, **kwargs)
return decorated
# ── helpers ──────────────────────────────────────────────────────────────────
def load_env():
env = {}
if ENV_FILE.exists():
with open(ENV_FILE) as f:
for line in f:
line = line.strip()
if line and not line.startswith('#') and '=' in line:
k, v = line.split('=', 1)
env[k.strip()] = v.strip()
return env
def load_contacts():
contacts = []
if CONTACTS_FILE.exists():
with open(CONTACTS_FILE, newline='', encoding='utf-8') as f:
for row in csv.DictReader(f):
contacts.append(dict(row))
return contacts
def load_log():
if LOG_FILE.exists():
with open(LOG_FILE) as f:
try:
return json.load(f)
except Exception:
return {}
return {}
def save_log(log):
with open(LOG_FILE, 'w') as f:
json.dump(log, f, indent=2)
def get_stats():
contacts = load_contacts()
log = load_log()
total = len([c for c in contacts if c.get('skip', '').lower() != 'yes'])
t1_sent = sum(1 for v in log.values() if v.get('touch_1_sent'))
t2_sent = sum(1 for v in log.values() if v.get('touch_2_sent'))
replied = sum(1 for v in log.values() if v.get('replied'))
pending_t1 = total - t1_sent
pending_t2 = t1_sent - t2_sent - replied
segments = {}
for c in contacts:
if c.get('skip', '').lower() == 'yes':
continue
seg = c.get('segment', 'default')
segments[seg] = segments.get(seg, 0) + 1
return {
'total': total,
'touch_1_sent': t1_sent,
'touch_2_sent': t2_sent,
'replied': replied,
'pending_t1': max(0, pending_t1),
'pending_t2': max(0, pending_t2),
'segments': segments,
}
def get_contacts_with_status():
contacts = load_contacts()
log = load_log()
result = []
for c in contacts:
email = c.get('email', '').strip().lower()
entry = log.get(email, {})
c['_t1'] = '✓' if entry.get('touch_1_sent') else '—'
c['_t1_date'] = entry.get('touch_1_date', '')
c['_t2'] = '✓' if entry.get('touch_2_sent') else '—'
c['_t2_date'] = entry.get('touch_2_date', '')
c['_replied'] = entry.get('replied', False)
c['_reply_note'] = entry.get('reply_note', '')
result.append(c)
return result
# ── routes ────────────────────────────────────────────────────────────────────
@app.route('/login', methods=['GET', 'POST'])
def login_page():
error = None
if request.method == 'POST':
env = load_env()
pw = env.get('GUI_PASSWORD', 'change-me')
if request.form.get('password') == pw:
session['authed'] = True
return redirect(request.args.get('next') or '/')
error = 'Incorrect password.'
return render_template('login.html', error=error)
@app.route('/logout')
def logout():
session.clear()
return redirect('/login')
@app.route('/')
@login_required
def dashboard():
stats = get_stats()
env = load_env()
return render_template('dashboard.html', stats=stats, env=env)
@app.route('/contacts')
@login_required
def contacts_page():
rows = get_contacts_with_status()
segment_filter = request.args.get('segment', '')
search = request.args.get('q', '').lower()
if segment_filter:
rows = [r for r in rows if r.get('segment') == segment_filter]
if search:
rows = [r for r in rows if search in r.get('name','').lower()
or search in r.get('email','').lower()
or search in r.get('organisation','').lower()]
segments = sorted(set(r.get('segment','') for r in get_contacts_with_status()))
return render_template('contacts.html', rows=rows, segments=segments,
segment_filter=segment_filter, search=search)
@app.route('/send')
@login_required
def send_page():
stats = get_stats()
return render_template('send.html', stats=stats)
@app.route('/inbox')
@login_required
def inbox_page():
env = load_env()
token = env.get('NETLIFY_TOKEN', '')
site_id = env.get('NETLIFY_SITE_ID', '')
def fetch_submissions(form_id):
if not token or not form_id:
return []
url = f'https://api.netlify.com/api/v1/forms/{form_id}/submissions?per_page=100'
req = urllib.request.Request(url, headers={'Authorization': f'Bearer {token}'})
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
except Exception as e:
return []
# Form IDs (hardcoded for this site)
FORM_IDS = {
'hermit-application': '6992a266724dbd00084a9427',
'estate-offer': '69a9f30ecd3f7b000872c715',
'hermit-offer': '69a9e6ce815b39000875be1d',
}
applications = fetch_submissions(FORM_IDS['hermit-application'])
estate_offers = fetch_submissions(FORM_IDS['estate-offer'])
hermit_offers = fetch_submissions(FORM_IDS['hermit-offer'])
# Check which hermit profiles are live on the site
site_dir = Path(env.get('SITE_DIR', '/tmp/hermithire_deploy2'))
live_slugs = set()
hermit_dir = site_dir / 'hermit'
if hermit_dir.exists():
live_slugs = {p.name for p in hermit_dir.iterdir() if p.is_dir()}
# Annotate each application with live status
for app in applications:
name = (app.get('data') or {}).get('hermit-name', '')
app['_live'] = make_slug(name) in live_slugs if name else False
return render_template('inbox.html',
applications=applications,
estate_offers=estate_offers,
hermit_offers=hermit_offers)
@app.route('/api/delete_submission', methods=['POST'])
@login_required
def api_delete_submission():
data = request.json
submission_id = data.get('id', '').strip()
if not submission_id:
return jsonify({'ok': False, 'error': 'No submission ID'})
env = load_env()
token = env.get('NETLIFY_TOKEN', '')
if not token:
return jsonify({'ok': False, 'error': 'No Netlify token'})
url = f'https://api.netlify.com/api/v1/submissions/{submission_id}'
req = urllib.request.Request(url, method='DELETE',
headers={'Authorization': f'Bearer {token}'})
try:
with urllib.request.urlopen(req) as r:
return jsonify({'ok': True})
except Exception as e:
return jsonify({'ok': False, 'error': str(e)})
# ── Publish a hermit to the live site ────────────────────────────────────────
SPECIALTY_IMAGES = {
'coastal': 'latour-magdalene.jpg',
'woodland': 'lorrain-landscape.jpg',
'forest': 'rembrandt-philosopher.jpg',
'mountain': 'spitzweg-hermit.jpg',
'garden': 'rembrandt-paul-writing.jpg',
'urban': 'wright-alchemist.jpg',
'desert': 'rosa-democritus.jpg',
'library': 'dou-hermit-reading.jpg',
'default': 'rembrandt-philosopher.jpg',
}
def make_slug(name):
return re.sub(r'[^a-z0-9]+', '-', name.lower()).strip('-')
def generate_profile_html(data):
name = data.get('hermit-name', 'Unknown Hermit')
specialty = data.get('specialty', 'default')
years = data.get('years', '?')
habitat = data.get('habitat', '')
skills_raw = data.get('skills', [])
if isinstance(skills_raw, str):
try: skills_raw = json.loads(skills_raw)
except: skills_raw = [skills_raw]
bio = data.get('bio', '') or f'A hermit of {years} years contemplative practice, specialising in {specialty} solitude.'
seasons_raw = data.get('seasons', [])
if isinstance(seasons_raw, str):
try: seasons_raw = json.loads(seasons_raw)
except: seasons_raw = [seasons_raw]
stipend_raw = data.get('stipend', '')
try:
stipend = f"€{int(float(stipend_raw)):,}" if stipend_raw else ''
except:
stipend = f"€{stipend_raw}"
stipend_line = f'
Stipend{stipend} / year
' if stipend else ''
slug = make_slug(name)
# Editor can override image and tagline
img = data.get('_image') or SPECIALTY_IMAGES.get(specialty, SPECIALTY_IMAGES['default'])
tagline = data.get('tagline') or f'{specialty.title()} Solitude | {years} Years Contemplative Practice'
availability = data.get('availability', 'available')
skill_tags = ''.join(f'{s.replace("-"," ").title()}' for s in skills_raw)
season_list = ', '.join(s.title() for s in seasons_raw) or 'Year-round'
# For preview, use absolute image URLs so iframe can load them
img_url = f'https://hermithire.com/images/{img}'
css_url = 'https://hermithire.com/css/luxury-styles-lp.css'
return f'''
{name} — Hermithire
{name}
{tagline}
Hermit Profile
Biography
{bio}
Preferred habitat: {habitat}. Available seasons: {season_list}.
Specialties
{skill_tags}
'''
def fetch_single_submission(submission_id):
env = load_env()
token = env.get('NETLIFY_TOKEN', '')
if not token:
return None
url = f'https://api.netlify.com/api/v1/submissions/{submission_id}'
req = urllib.request.Request(url, headers={'Authorization': f'Bearer {token}'})
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
except Exception:
return None
ALL_IMAGES = [
'rembrandt-philosopher.jpg', 'rembrandt-paul-writing.jpg',
'dou-hermit-reading.jpg', 'rosa-democritus.jpg',
'wright-alchemist.jpg', 'latour-magdalene.jpg',
'waterhouse-shalott.jpg', 'spitzweg-hermit.jpg',
'lorrain-landscape.jpg', 'friedrich-wanderer.jpg',
'quill-ink.jpg',
]
SPECIALTY_QUIPS = {
'forest': 'few have listened so attentively to what the trees have been trying to say',
'woodland': 'few have listened so attentively to what the trees have been trying to say',
'coastal': 'few have kept such faithful and unhurried vigil at the edge of the knowable world',
'mountain': 'few have climbed so purposefully and so far away from everyone',
'garden': 'few have attended so devotedly to what grows only in silence',
'urban': 'fewer still have found genuine solitude in the most densely inhabited of places',
'desert': 'few have made such thorough and voluntary acquaintance with the void',
'library': 'few have so thoroughly and deliberately disappeared into the written word',
'default': 'few have demonstrated such committed devotion to the art of being elsewhere',
}
def send_acceptance_email(submission_data, applicant_email, slug):
"""Send a personalised acceptance email to a newly published hermit."""
if not applicant_email:
return False, 'No email address'
env = load_env()
smtp_server = env.get('SMTP_SERVER', '')
smtp_port = int(env.get('SMTP_PORT', 587))
smtp_user = env.get('SMTP_USERNAME', '')
smtp_pass = env.get('SMTP_PASSWORD', '')
from_email = env.get('FROM_EMAIL', smtp_user)
if not smtp_server or not smtp_user:
return False, 'SMTP not configured'
name = submission_data.get('hermit-name', 'Hermit')
specialty = submission_data.get('specialty', 'default').lower()
years = submission_data.get('years', '?')
bio = submission_data.get('bio', '') or f'{years} years of {specialty} contemplative practice.'
first_sent = re.split(r'(?<=[.!?])\s', bio.strip())[0]
if len(first_sent) > 200:
first_sent = first_sent[:197] + '\u2026'
pull_quote = html_escape(first_sent)
name_safe = html_escape(name)
quip = SPECIALTY_QUIPS.get(specialty, SPECIALTY_QUIPS['default'])
html_body = f"""
Est. in the Spirit of the 18th Century
Hermithire
From the Curator’s Desk
Your Application — Accepted
Dear {name_safe},
In the long and contemplative history of Hermithire, we have received applications from wanderers and sages, from penitents and prophets. Yet {quip}.
It is my considerable pleasure to inform you that your application has been accepted into the Hermithire collection.
“{pull_quote}”
We shall now set about the business of finding you a suitable estate — one equal to your particular gifts and prepared for the unusual privilege of hosting them. The right hermit and the right estate must arrive at one another in something approximating destiny; we do not rush it.
To assist us in matching you well, it would be most helpful if you could reply with the following:
Please furnish us with
— A telephone number at which we may reach you when an estate of interest presents itself
— Any additional information you wish kept in the strictest confidence — personal constraints, preferred geography, health considerations, or anything the curator ought to know
— Any thoughts on the minimum engagement term you would be prepared to accept
We assure you that all correspondence is handled with the discretion befitting both our practice and yours. What passes between hermit and curator stays between hermit and curator.
You may view your profile at hermithire.com/hermit/{slug}. Until then — may your solitude be productive and your next audience attentive.
The Curator
Hermithire
briankenny94@gmail.com
Hermithire · Discretion Assured · hermithire.com
Hermitage is not a hobby. It is a calling.
"""
plain = (
f"Dear {name},\n\n"
f"Your application to Hermithire has been accepted.\n\n"
f"Please reply with: a telephone number, any confidential information "
f"the curator should know, and your preferred minimum engagement term.\n\n"
f"Your profile: https://hermithire.com/hermit/{slug}\n\n"
f"With anticipation,\n\u2014 The Curator, Hermithire\nbriankenny94@gmail.com"
)
msg = MIMEMultipart('alternative')
msg['Subject'] = f'Your Application to Hermithire \u2014 Accepted with Considerable Anticipation'
msg['From'] = f'The Curator, Hermithire <{from_email}>'
msg['To'] = applicant_email
msg.attach(MIMEText(plain, 'plain'))
msg.attach(MIMEText(html_body, 'html'))
try:
with smtplib.SMTP(smtp_server, smtp_port) as s:
s.ehlo()
s.starttls()
s.login(smtp_user, smtp_pass)
s.sendmail(from_email, [applicant_email], msg.as_string())
return True, 'Email sent'
except Exception as e:
return False, str(e)
@app.route('/inbox/edit/')
@login_required
def edit_hermit(submission_id):
sub = fetch_single_submission(submission_id)
if not sub:
return "Submission not found", 404
return render_template('edit_hermit.html', sub=sub,
all_images=ALL_IMAGES,
specialty_images=SPECIALTY_IMAGES)
@app.route('/api/preview_html', methods=['POST'])
@login_required
def api_preview_html():
"""Returns full profile HTML for the iframe preview."""
data = request.json or {}
html = generate_profile_html(data)
return html, 200, {'Content-Type': 'text/html; charset=utf-8'}
@app.route('/api/publish_hermit', methods=['POST'])
@login_required
def api_publish_hermit():
env = load_env()
site_dir = Path(env.get('SITE_DIR', '/tmp/hermithire_deploy2'))
data = request.json
submission_data = data.get('data', {})
name = submission_data.get('hermit-name', '').strip()
if not name:
return jsonify({'ok': False, 'error': 'No hermit name'})
slug = make_slug(name)
specialty = submission_data.get('specialty', 'default')
# Resolve final image (editor sends _image key)
img = submission_data.get('_image') or SPECIALTY_IMAGES.get(specialty, SPECIALTY_IMAGES['default'])
# Strip any absolute URL prefix the editor may have set
img = img.replace('https://hermithire.com/images/', '')
tagline = submission_data.get('tagline', '')
# Write profile page using relative paths for the live site
publish_data = {**submission_data, '_image': img, 'tagline': tagline}
# Override absolute URL vars that generate_profile_html sets for preview
profile_dir = site_dir / 'hermit' / slug
profile_dir.mkdir(parents=True, exist_ok=True)
html = generate_profile_html(publish_data)
# Fix absolute URLs back to relative for the deployed file
html = html.replace('https://hermithire.com/images/', '/images/')
html = html.replace('https://hermithire.com/css/', '/css/')
(profile_dir / 'index.html').write_text(html)
# Insert card into collections grid (before end-of-grid marker)
collections_file = site_dir / 'collections' / 'index.html'
content = collections_file.read_text()
years = submission_data.get('years', '?')
skills_raw = submission_data.get('skills', [])
if isinstance(skills_raw, str):
try: skills_raw = json.loads(skills_raw)
except: skills_raw = [skills_raw]
tag1 = skills_raw[0].replace('-',' ').title() if skills_raw else specialty.title()
tag2 = skills_raw[1].replace('-',' ').title() if len(skills_raw) > 1 else 'Contemplation'
bio_snippet = (submission_data.get('bio', '') or f'{years} years of {specialty} contemplative practice.')[:120]
avail = submission_data.get('availability', 'available')
new_until = (datetime.now() + timedelta(days=7)).strftime('%Y-%m-%d')
new_card = f'''
{bio_snippet}
New to the Collection
{name}
{tagline or specialty.title() + ' Hermit & Contemplative'}
{tag1}{tag2}Available
View Profile
'''
# Insert at top of grid so newest appears first
content = content.replace(' \n', ' \n' + new_card)
collections_file.write_text(content)
# Update homepage featured carousel from top 6 in collections
homepage_file = site_dir / 'index.html'
if homepage_file.exists():
hp = homepage_file.read_text()
article_pat = re.compile(r'\s*', re.DOTALL)
all_articles = [a.strip() for a in article_pat.findall(content)]
top6 = all_articles[:6]
if len(top6) >= 3:
indent = ' '
slide1 = f'\n{indent}'.join(top6[:3])
hp = re.sub(
r'.*?',
f'\n\n{indent}{slide1}\n\n ',
hp, flags=re.DOTALL
)
if len(top6) >= 4:
indent = ' '
slide2 = f'\n{indent}'.join(top6[3:6])
hp = re.sub(
r'.*?',
f'\n\n{indent}{slide2}\n\n ',
hp, flags=re.DOTALL
)
homepage_file.write_text(hp)
# Deploy
try:
result = subprocess.run(
['netlify', 'deploy', '--prod', '--dir', str(site_dir), '--site', '2a6bbadd-7220-42ca-903b-3b071e767bc9'],
capture_output=True, text=True, timeout=120, cwd=str(site_dir)
)
deployed = result.returncode == 0
msg = 'Deployed successfully' if deployed else result.stderr[:200]
except Exception as e:
deployed = False
msg = str(e)
# Send acceptance email (non-blocking — don't fail publish if email fails)
email_status = 'not sent'
if deployed:
applicant_email = submission_data.get('email', '').strip()
if not applicant_email:
sub_id = data.get('id', '')
if sub_id:
full_sub = fetch_single_submission(sub_id)
if full_sub:
applicant_email = full_sub.get('email', '').strip()
if applicant_email:
ok_e, msg_e = send_acceptance_email(submission_data, applicant_email, slug)
email_status = 'sent' if ok_e else f'failed: {msg_e}'
return jsonify({'ok': deployed, 'slug': slug, 'url': f'https://hermithire.com/hermit/{slug}', 'msg': msg, 'email': email_status})
@app.route('/log')
@login_required
def log_page():
lines = []
if CAMPAIGN_LOG.exists():
with open(CAMPAIGN_LOG) as f:
lines = f.readlines()[-200:] # last 200 lines
return render_template('log.html', lines=lines)
@app.route('/api/stats')
@login_required
def api_stats():
return jsonify(get_stats())
@app.route('/api/mark_replied', methods=['POST'])
@login_required
def api_mark_replied():
data = request.json
email = data.get('email', '').strip().lower()
note = data.get('note', '')
if not email:
return jsonify({'ok': False, 'error': 'No email'})
log = load_log()
if email not in log:
log[email] = {}
log[email]['replied'] = True
log[email]['replied_date'] = datetime.now().strftime('%Y-%m-%d')
log[email]['reply_note'] = note
save_log(log)
return jsonify({'ok': True})
@app.route('/api/unmark_replied', methods=['POST'])
@login_required
def api_unmark_replied():
data = request.json
email = data.get('email', '').strip().lower()
log = load_log()
if email in log:
log[email]['replied'] = False
log[email]['reply_note'] = ''
save_log(log)
return jsonify({'ok': True})
# live streaming send output
_send_lock = threading.Lock()
_send_output = []
_send_running = False
@app.route('/api/send_stream')
@login_required
def send_stream():
touch = request.args.get('touch', '1')
segment = request.args.get('segment', '')
dry_run = request.args.get('dry_run', 'false') == 'true'
limit = request.args.get('limit', '80')
def generate():
global _send_running, _send_output
with _send_lock:
if _send_running:
yield "data: Already running\n\n"
return
_send_running = True
_send_output = []
cmd = [sys.executable, str(BASE / 'campaign.py'), 'send',
'--touch', touch, '--limit', limit]
if segment:
cmd += ['--segment', segment]
if dry_run:
cmd += ['--dry-run']
try:
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True, bufsize=1)
for line in proc.stdout:
line = line.rstrip()
_send_output.append(line)
yield f"data: {line}\n\n"
proc.wait()
yield f"data: [DONE] Exit code {proc.returncode}\n\n"
except Exception as e:
yield f"data: ERROR: {e}\n\n"
finally:
_send_running = False
return Response(generate(), mimetype='text/event-stream',
headers={'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no'})
@app.route('/api/send_status')
@login_required
def send_status():
return jsonify({'running': _send_running})
if __name__ == '__main__':
print('\n ╔═══════════════════════════════════════╗')
print(' ║ HermitHire Campaign Dashboard ║')
print(' ║ http://localhost:5001 ║')
print(' ╚═══════════════════════════════════════╝\n')
app.run(port=5001, debug=False)