rustio-admin 0.30.0

Django Admin, but for Rust. A small, focused admin framework.
Documentation
{% extends "admin/_base.html" %}

{# Redesigned CSV import result. A mismatched header is no longer a dead
 # end: unrecognised columns are skipped, reported here, and turned into a
 # ready-to-run migration + model snippet so the developer can capture that
 # data. Scoped under `.imp`; amber via the accent2 fallback. #}
{% block extra_head %}
<style>
  .pgx-head { margin-bottom: var(--rio-s5); }
  .pgx-head__row { display: flex; align-items: flex-end; justify-content: space-between; gap: var(--rio-s4); flex-wrap: wrap; }
  .pgx-title { font-size: var(--rio-fs-display); font-weight: var(--rio-fw-bold); line-height: var(--rio-lh-tight); color: var(--rio-text-strong); margin-top: var(--rio-s1); }

  .imp-summary { display: flex; align-items: center; gap: var(--rio-s2); margin-top: var(--rio-s3); flex-wrap: wrap; color: var(--rio-text-muted); font-size: var(--rio-fs-md); }
  .imp-pill { display: inline-flex; align-items: center; gap: 0.35rem; font-size: var(--rio-fs-xs); font-weight: var(--rio-fw-bold); padding: 0.2rem 0.6rem; border-radius: 999px; }
  .imp-pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
  .imp-pill--ok { background: var(--rio-success-bg); color: var(--rio-success); }
  .imp-pill--fail { background: var(--rio-danger-bg); color: var(--rio-danger); }
  .imp-pill--skip { background: var(--rio-accent2-soft, var(--rio-accent-soft)); color: var(--rio-accent2-ink, var(--rio-accent)); }

  .imp-advice { display: flex; gap: var(--rio-s4); padding: var(--rio-s5); margin-bottom: var(--rio-s5); background: var(--rio-accent2-soft, var(--rio-accent-soft)); border: 1px solid var(--rio-accent2, var(--rio-accent)); border-radius: var(--rio-radius-lg); }
  .imp-advice__glyph { width: 40px; height: 40px; flex: none; border-radius: var(--rio-radius-lg); display: grid; place-items: center; background: var(--rio-surface); color: var(--rio-accent2-ink, var(--rio-accent)); }
  .imp-advice__glyph .rio-icon { width: 22px; height: 22px; }
  .imp-advice__body { min-width: 0; }
  .imp-advice__title { font-size: var(--rio-fs-lg); font-weight: var(--rio-fw-bold); color: var(--rio-text-strong); }
  .imp-advice__lead { margin-top: 2px; color: var(--rio-text-muted); font-size: var(--rio-fs-sm); }
  .imp-cols { margin-top: var(--rio-s2); display: flex; flex-wrap: wrap; gap: 0.4rem; }
  .imp-col { font-family: var(--rio-font-mono); font-size: var(--rio-fs-xs); padding: 0.15rem 0.5rem; border-radius: var(--rio-radius-sm); background: var(--rio-surface); border: 1px solid var(--rio-border); color: var(--rio-text-strong); }

  .imp-fix { margin-top: var(--rio-s4); border-top: 1px solid var(--rio-accent2, var(--rio-accent)); padding-top: var(--rio-s4); }
  .imp-fix > summary { cursor: pointer; font-weight: var(--rio-fw-bold); color: var(--rio-text-strong); font-size: var(--rio-fs-sm); list-style: none; display: inline-flex; align-items: center; gap: 0.4rem; }
  .imp-fix > summary::-webkit-details-marker { display: none; }
  .imp-fix > summary::before { content: ""; color: var(--rio-accent2-ink, var(--rio-accent)); transition: transform .14s ease; }
  .imp-fix[open] > summary::before { transform: rotate(90deg); }
  .imp-step { margin-top: var(--rio-s4); }
  .imp-step__label { font-size: var(--rio-fs-xs); font-weight: var(--rio-fw-bold); text-transform: uppercase; letter-spacing: 0.04em; color: var(--rio-text-subtle); margin-bottom: var(--rio-s2); }
  .imp-step__note { font-size: var(--rio-fs-sm); color: var(--rio-text-muted); margin-top: var(--rio-s2); }
  .imp-step__note code { font-family: var(--rio-font-mono); font-size: 0.9em; background: var(--rio-surface); padding: 1px 5px; border-radius: var(--rio-radius-sm); }
  .imp-code { margin: 0; padding: var(--rio-s4) var(--rio-s5); background: var(--rio-surface-chrome); border-radius: var(--rio-radius); overflow-x: auto; }
  .imp-code code { font-family: var(--rio-font-mono); font-size: 0.84rem; line-height: 1.65; color: #e6edf6; white-space: pre; }

  .imp-card { background: var(--rio-surface); border: 1px solid var(--rio-border); border-radius: var(--rio-radius-lg); box-shadow: var(--rio-shadow); overflow: hidden; }
  .imp-card__head { padding: var(--rio-s4) var(--rio-s5); border-bottom: 1px solid var(--rio-border-soft); font-weight: var(--rio-fw-bold); color: var(--rio-text-strong); }
  .imp-table { width: 100%; border-collapse: collapse; font-size: var(--rio-fs-base); }
  .imp-table thead th { text-align: left; font-size: var(--rio-fs-xs); font-weight: var(--rio-fw-semibold); text-transform: uppercase; letter-spacing: 0.04em; color: var(--rio-text-muted); background: var(--rio-surface-2); padding: var(--rio-s3) var(--rio-s5); border-bottom: 1px solid var(--rio-border); }
  .imp-table tbody td { padding: var(--rio-s3) var(--rio-s5); border-bottom: 1px solid var(--rio-border-soft); vertical-align: middle; }
  .imp-table tbody tr:last-child td { border-bottom: 0; }
  .imp-table a { color: var(--rio-accent); }
  .imp-errs { margin: 0; padding-left: 1.1em; color: var(--rio-danger); font-size: var(--rio-fs-sm); }
</style>
{% endblock %}

{% block content %}
<header class="pgx-head">
  <div class="pgx-head__row">
    <div>
      <nav class="rio-breadcrumbs"><a href="/admin">Home</a> · <a href="/admin/{{ admin_name }}">{{ display_name }}</a> · <span>CSV import</span></nav>
      <h1 class="pgx-title">Import complete</h1>
    </div>
    <a class="rio-btn rio-btn--subtle rio-btn--md" href="/admin/{{ admin_name }}">← Back to {{ display_name }}</a>
  </div>
  <div class="imp-summary">
    {% if failed == 0 %}<span class="imp-pill imp-pill--ok">All rows OK</span>{% else %}<span class="imp-pill imp-pill--fail">{{ failed }} failed</span>{% endif %}
    {% if ignored_columns %}<span class="imp-pill imp-pill--skip">{{ ignored_columns|length }} column{% if ignored_columns|length != 1 %}s{% endif %} skipped</span>{% endif %}
    <span><strong>{{ inserted }}</strong> inserted of <strong>{{ total }}</strong> row{% if total != 1 %}s{% endif %}.</span>
  </div>
</header>

{# ---- Skipped-columns advisory + generated fix ---------------------- #}
{% if ignored_columns %}
<div class="imp-advice">
  <span class="imp-advice__glyph">{{ icon("circle-alert", class="rio-icon") }}</span>
  <div class="imp-advice__body">
    <div class="imp-advice__title">{{ ignored_columns|length }} column{% if ignored_columns|length != 1 %}s aren't{% else %} isn't{% endif %} on the {{ display_name }} model — skipped</div>
    <div class="imp-advice__lead">
      The rows imported, but these column{% if ignored_columns|length != 1 %}s{% endif %} weren't matched to a field, so their values weren't saved:
    </div>
    <div class="imp-cols">
      {% for c in ignored_columns %}<span class="imp-col">{{ c }}</span>{% endfor %}
    </div>

    <details class="imp-fix">
      <summary>Keep this data — add these fields to your model</summary>

      <div class="imp-step">
        <div class="imp-step__label">1 · Migration</div>
        <pre class="imp-code"><code>{{ migration_sql }}</code></pre>
        <p class="imp-step__note">Save as <code>migrations/&lt;next-number&gt;_add_{{ table }}_fields.sql</code>, then run <code>rustio-admin migrate apply</code>. Types are guessed from the column names — adjust before committing.</p>
      </div>

      <div class="imp-step">
        <div class="imp-step__label">2 · Model struct (src/{{ admin_name }}.rs)</div>
        <pre class="imp-code"><code>{% for f in suggested_fields %}{{ f.rust_field }}
{% endfor %}</code></pre>
      </div>

      <div class="imp-step">
        <div class="imp-step__label">3 · Wire the field into the Model impl</div>
        <pre class="imp-code"><code>// add to COLUMNS and INSERT_COLUMNS: {% for f in suggested_fields %}"{{ f.column }}"{% if not loop.last %}, {% endif %}{% endfor %}

// in from_row:
{% for f in suggested_fields %}{{ f.from_row }}
{% endfor %}
// in insert_values:
{% for f in suggested_fields %}{{ f.insert_value }}
{% endfor %}</code></pre>
        <p class="imp-step__note">Re-run the import afterwards and these columns will be saved.</p>
      </div>
    </details>
  </div>
</div>
{% endif %}

{# ---- Per-row outcomes --------------------------------------------- #}
<section class="imp-card">
  <div class="imp-card__head">Row detail</div>
  {% if outcomes %}
  <table class="imp-table">
    <thead><tr><th>Row</th><th>Status</th><th>Detail</th></tr></thead>
    <tbody>
      {% for o in outcomes %}
      <tr>
        <td>{{ o.row_number }}</td>
        <td>{% if o.status == "inserted" %}<span class="imp-pill imp-pill--ok">Inserted</span>{% else %}<span class="imp-pill imp-pill--fail">Failed</span>{% endif %}</td>
        <td>
          {% if o.id %}<a href="/admin/{{ admin_name }}/{{ o.id }}/edit">#{{ o.id }}</a>
          {% else %}<ul class="imp-errs">{% for err in o.errors %}<li>{{ err }}</li>{% endfor %}</ul>{% endif %}
        </td>
      </tr>
      {% endfor %}
    </tbody>
  </table>
  {% else %}
  <div class="rio-empty-state">
    <div class="rio-empty-state__icon">{{ icon("upload", class="rio-icon") }}</div>
    <h3 class="rio-empty-state__title">CSV had no data rows</h3>
    <p class="rio-empty-state__lead">The file parsed but contained only a header (or was empty). Add at least one data row and try again.</p>
  </div>
  {% endif %}
</section>
{% endblock %}