Add Django site, Docker packaging, and beta/prod Gitea deploys.
Unignore site/ (was blocked by mkdocs /site rule), add compose/Docker/uv tooling, and split deploys so push to main goes to beta while prod stays manual.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% load static %}
|
||||
{% block title %}{{ contact }} · Contact{% endblock %}
|
||||
{% block topbar_title %}Contact · {{ contact }}{% endblock %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{% static 'css/address-autocomplete.css' %}">
|
||||
{% endblock %}
|
||||
{% block portal_content %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="split">
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Profile</h2></div>
|
||||
<div class="panel-b form-grid">
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field"><label>First name</label><input value="{{ contact.first_name }}" readonly></div>
|
||||
<div class="field"><label>Last name</label><input value="{{ contact.last_name }}" readonly></div>
|
||||
</div>
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field"><label>Email</label><input value="{{ contact.email }}" readonly></div>
|
||||
<div class="field"><label>Phone</label><input value="{{ contact.phone }}" readonly></div>
|
||||
</div>
|
||||
<div class="field"><label>Source</label><input value="{{ contact.get_source_display }}" readonly></div>
|
||||
|
||||
<div data-address-autocomplete data-suggest-url="{% url 'address_suggest' %}">
|
||||
<div class="field address-ac-wrap">
|
||||
<label>Street address</label>
|
||||
<input name="address_line1" data-ac="line1" value="{{ contact.postal_address.line1|default:'' }}" autocomplete="off">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Apt / suite</label>
|
||||
<input name="address_line2" data-ac="line2" value="{{ contact.postal_address.line2|default:'' }}" autocomplete="address-line2">
|
||||
</div>
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field">
|
||||
<label>City</label>
|
||||
<input name="address_city" data-ac="city" value="{{ contact.postal_address.city|default:'' }}" autocomplete="address-level2">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>State</label>
|
||||
<input name="address_state" data-ac="state" value="{{ contact.postal_address.state|default:'' }}" autocomplete="address-level1" maxlength="32">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-grid cols-2">
|
||||
<div class="field">
|
||||
<label>ZIP</label>
|
||||
<input name="address_zip" data-ac="zip" value="{{ contact.postal_address.zip|default:'' }}" autocomplete="postal-code" maxlength="20">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Country</label>
|
||||
<input name="address_country" data-ac="country" value="{{ contact.postal_address.country|default:'US' }}" autocomplete="country" maxlength="2">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field"><label>Notes</label>
|
||||
<textarea name="notes">{{ contact.notes }}</textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<button class="btn btn-primary btn-sm" type="submit">Save</button>
|
||||
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:list' %}">← Mailing list</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Consent</h2></div>
|
||||
<div class="panel-b">
|
||||
<div class="field">
|
||||
<label class="check-row"><input type="checkbox" name="consent_email" value="1" {% if prefs.email %}checked{% endif %}> Email marketing</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="check-row"><input type="checkbox" name="consent_sms" value="1" {% if prefs.sms %}checked{% endif %}> SMS updates</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="check-row"><input type="checkbox" name="consent_postcard" value="1" {% if prefs.postcard %}checked{% endif %}> Postcard mailings</label>
|
||||
</div>
|
||||
<p class="hint-block" style="margin-top:16px">Postcard campaigns need a street address and postcard consent. Opt-outs also write a suppression so campaigns skip this contact.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'js/address-autocomplete.js' %}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Import contacts · Portal{% endblock %}
|
||||
{% block topbar_title %}Import contacts{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="steps">
|
||||
<div class="step active"><span>1</span> Upload</div>
|
||||
<div class="step"><span>2</span> Map columns</div>
|
||||
<div class="step"><span>3</span> Consent</div>
|
||||
<div class="step"><span>4</span> Import</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
<div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Upload</h2></div>
|
||||
<div class="panel-b">
|
||||
<div class="dropzone">
|
||||
<p style="margin:0 0 8px"><strong>Drop CSV or Excel here</strong></p>
|
||||
<p class="muted" style="margin:0">Import processing wires up next. Accepted: .csv, .xlsx</p>
|
||||
<p style="margin:16px 0 0"><button class="btn btn-ghost btn-sm" type="button" disabled>Choose file</button></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Column mapping</h2></div>
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead><tr><th>Your column</th><th>Maps to</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Email</td><td>email</td></tr>
|
||||
<tr><td>First</td><td>first_name</td></tr>
|
||||
<tr><td>Last</td><td>last_name</td></tr>
|
||||
<tr><td>Phone</td><td>phone</td></tr>
|
||||
<tr><td>Street</td><td>postal_address.line1</td></tr>
|
||||
<tr><td>City</td><td>postal_address.city</td></tr>
|
||||
<tr><td>State</td><td>postal_address.state</td></tr>
|
||||
<tr><td>ZIP</td><td>postal_address.zip</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Consent defaults</h2></div>
|
||||
<div class="panel-b form-grid">
|
||||
<label class="check-row"><input type="checkbox" checked disabled> Email marketing</label>
|
||||
<label class="check-row"><input type="checkbox" disabled> SMS</label>
|
||||
<label class="check-row"><input type="checkbox" disabled> Postcard</label>
|
||||
<div class="field"><label>Source</label><input value="Import" disabled></div>
|
||||
<div class="field"><label>Duplicates</label><select disabled><option>Update existing by email</option></select></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-h"><h2>Preview</h2></div>
|
||||
<div class="panel-b">
|
||||
<p class="muted">Sample rows appear after upload.</p>
|
||||
<button class="btn btn-primary" type="button" disabled>Import contacts</button>
|
||||
<p class="hint-block"><a href="{% url 'contacts:list' %}">← Back to mailing list</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends "portal_base.html" %}
|
||||
{% block title %}Mailing list · Portal{% endblock %}
|
||||
{% block topbar_title %}Mailing list{% endblock %}
|
||||
{% block portal_content %}
|
||||
<div class="toolbar">
|
||||
<form class="toolbar-filters" method="get">
|
||||
<input type="search" name="q" value="{{ q }}" placeholder="Search contacts">
|
||||
<button class="btn btn-sm btn-ghost" type="submit">Search</button>
|
||||
</form>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<a class="btn btn-ghost btn-sm" href="{% url 'contacts:import' %}">Import CSV / Excel</a>
|
||||
<a class="btn btn-primary btn-sm" href="{% url 'messaging:campaign_list' %}">New campaign</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-b" style="padding:0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Contact</th>
|
||||
<th>Address</th>
|
||||
<th>Consent</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for contact in contacts %}
|
||||
<tr>
|
||||
<td><input type="checkbox" disabled></td>
|
||||
<td>
|
||||
<a href="{% url 'contacts:detail' contact.pk %}">{{ contact }}</a><br>
|
||||
<span class="muted">
|
||||
{% if contact.email %}{{ contact.email }}{% endif %}
|
||||
{% if contact.email and contact.phone %} · {% endif %}
|
||||
{% if contact.phone %}{{ contact.phone }}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if contact.postal_address.line1 %}
|
||||
{{ contact.postal_address.line1 }}{% if contact.postal_address.city %}, {{ contact.postal_address.city }}{% endif %}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% with c=contact.consent_flags %}
|
||||
<span class="badge {% if c.email %}badge-optin{% else %}badge-optout{% endif %}">E</span>
|
||||
<span class="badge {% if c.sms %}badge-optin{% else %}badge-optout{% endif %}">S</span>
|
||||
<span class="badge {% if c.postcard %}badge-optin{% else %}badge-optout{% endif %}">P</span>
|
||||
{% endwith %}
|
||||
</td>
|
||||
<td>{{ contact.get_source_display }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr><td colspan="5" class="empty-state">No contacts yet.</td></tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted" style="font-size:13px">
|
||||
E = email · S = SMS · P = postcard.
|
||||
<a href="{% url 'contacts:import' %}">Import contacts</a> for bulk CSV/Excel.
|
||||
</p>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user