#!/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}

{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'''
{name}
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)