feat(admin): add mapping workflow for unknown CSVs
- New POST /admin/map-files to reclassify unknown files to a chosen import type - Centralize VALID_IMPORT_TYPES and pass to admin template - UI: dropdown + 'Map Selected' button in Unknown card - JS: mapSelectedFiles() posts selection and reloads on success - Keeps UUID suffix, prevents traversal, logs actions
This commit is contained in:
118
app/main.py
118
app/main.py
@@ -236,6 +236,19 @@ app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY)
|
|||||||
# Mount static files directory
|
# Mount static files directory
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
|
|
||||||
|
# Canonical list of valid legacy import types used across the admin features
|
||||||
|
VALID_IMPORT_TYPES: List[str] = [
|
||||||
|
# Reference tables
|
||||||
|
'trnstype', 'trnslkup', 'footers', 'filestat', 'employee',
|
||||||
|
'gruplkup', 'filetype', 'fvarlkup', 'rvarlkup',
|
||||||
|
# Core data tables
|
||||||
|
'rolodex', 'phone', 'rolex_v', 'files', 'files_r', 'files_v',
|
||||||
|
'filenots', 'ledger', 'deposits', 'payments',
|
||||||
|
# Specialized tables
|
||||||
|
'planinfo', 'qdros', 'pensions', 'pension_marriage',
|
||||||
|
'pension_death', 'pension_schedule', 'pension_separate', 'pension_results',
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_import_type_from_filename(filename: str) -> str:
|
def get_import_type_from_filename(filename: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -314,6 +327,95 @@ def get_import_type_from_filename(filename: str) -> str:
|
|||||||
raise ValueError(f"Unknown file type for filename: {filename}")
|
raise ValueError(f"Unknown file type for filename: {filename}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/admin/map-files")
|
||||||
|
async def admin_map_unknown_files(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Map selected unknown files to a chosen import type by renaming with the
|
||||||
|
appropriate prefix used through the system (e.g., files are stored as
|
||||||
|
"{import_type}_{uuid}.csv").
|
||||||
|
|
||||||
|
Expects JSON body: { "target_type": str, "filenames": [str, ...] }
|
||||||
|
"""
|
||||||
|
# Auth check
|
||||||
|
user = get_current_user_from_session(request.session)
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
||||||
|
|
||||||
|
target_type = (payload.get("target_type") or "").strip()
|
||||||
|
filenames = payload.get("filenames") or []
|
||||||
|
|
||||||
|
if target_type not in VALID_IMPORT_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid target import type")
|
||||||
|
if not isinstance(filenames, list) or not filenames:
|
||||||
|
raise HTTPException(status_code=400, detail="No filenames provided")
|
||||||
|
|
||||||
|
import_dir = "data-import"
|
||||||
|
if not os.path.isdir(import_dir):
|
||||||
|
raise HTTPException(status_code=400, detail="Import directory not found")
|
||||||
|
|
||||||
|
mapped: List[Dict[str, str]] = []
|
||||||
|
errors: List[Dict[str, str]] = []
|
||||||
|
|
||||||
|
for name in filenames:
|
||||||
|
# Basic traversal protection
|
||||||
|
if not isinstance(name, str) or any(x in name for x in ["..", "/", "\\"]):
|
||||||
|
errors.append({"filename": name, "error": "Invalid filename"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_path = os.path.join(import_dir, name)
|
||||||
|
if not os.path.isfile(old_path):
|
||||||
|
errors.append({"filename": name, "error": "File not found"})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine new name: keep UUID suffix/extension from existing stored filename
|
||||||
|
try:
|
||||||
|
_, ext = os.path.splitext(name)
|
||||||
|
# If name already follows pattern <type>_<uuid>.<ext>, keep suffix
|
||||||
|
suffix = name
|
||||||
|
if "_" in name:
|
||||||
|
parts = name.split("_", 1)
|
||||||
|
if len(parts) == 2:
|
||||||
|
suffix = parts[1]
|
||||||
|
new_name = f"{target_type}_{suffix}"
|
||||||
|
new_path = os.path.join(import_dir, new_name)
|
||||||
|
|
||||||
|
# Avoid overwriting: if exists, add short random suffix
|
||||||
|
if os.path.exists(new_path):
|
||||||
|
rand = uuid.uuid4().hex[:6]
|
||||||
|
base_no_ext, _ = os.path.splitext(new_name)
|
||||||
|
new_name = f"{base_no_ext}-{rand}{ext}"
|
||||||
|
new_path = os.path.join(import_dir, new_name)
|
||||||
|
|
||||||
|
os.replace(old_path, new_path)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"admin_map_file",
|
||||||
|
old_filename=name,
|
||||||
|
new_filename=new_name,
|
||||||
|
target_type=target_type,
|
||||||
|
username=user.username,
|
||||||
|
)
|
||||||
|
mapped.append({"old": name, "new": new_name})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
"admin_map_file_error",
|
||||||
|
filename=name,
|
||||||
|
target_type=target_type,
|
||||||
|
error=str(e),
|
||||||
|
)
|
||||||
|
errors.append({"filename": name, "error": str(e)})
|
||||||
|
|
||||||
|
return {"mapped": mapped, "errors": errors}
|
||||||
|
|
||||||
|
|
||||||
def validate_csv_headers(headers: List[str], expected_fields: Dict[str, str]) -> Dict[str, Any]:
|
def validate_csv_headers(headers: List[str], expected_fields: Dict[str, str]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Validate CSV headers against expected model fields.
|
Validate CSV headers against expected model fields.
|
||||||
@@ -1626,18 +1728,7 @@ async def admin_import_data(
|
|||||||
return RedirectResponse(url="/login", status_code=302)
|
return RedirectResponse(url="/login", status_code=302)
|
||||||
|
|
||||||
# Validate data type
|
# Validate data type
|
||||||
valid_types = [
|
if data_type not in VALID_IMPORT_TYPES:
|
||||||
# Reference tables
|
|
||||||
'trnstype', 'trnslkup', 'footers', 'filestat', 'employee',
|
|
||||||
'gruplkup', 'filetype', 'fvarlkup', 'rvarlkup',
|
|
||||||
# Core data tables
|
|
||||||
'rolodex', 'phone', 'rolex_v', 'files', 'files_r', 'files_v',
|
|
||||||
'filenots', 'ledger', 'deposits', 'payments',
|
|
||||||
# Specialized tables
|
|
||||||
'planinfo', 'qdros', 'pensions', 'pension_marriage',
|
|
||||||
'pension_death', 'pension_schedule', 'pension_separate', 'pension_results'
|
|
||||||
]
|
|
||||||
if data_type not in valid_types:
|
|
||||||
return templates.TemplateResponse("admin.html", {
|
return templates.TemplateResponse("admin.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"user": user,
|
"user": user,
|
||||||
@@ -1964,7 +2055,8 @@ async def admin_panel(request: Request, db: Session = Depends(get_db)):
|
|||||||
"recent_imports": recent_imports,
|
"recent_imports": recent_imports,
|
||||||
"available_files": available_files,
|
"available_files": available_files,
|
||||||
"table_counts": table_counts,
|
"table_counts": table_counts,
|
||||||
"files_by_type": files_by_type
|
"files_by_type": files_by_type,
|
||||||
|
"valid_import_types": VALID_IMPORT_TYPES
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -463,6 +463,21 @@
|
|||||||
</h6>
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
{% if import_type == 'unknown' and valid_import_types %}
|
||||||
|
<div class="mb-3 d-flex align-items-end gap-2">
|
||||||
|
<div>
|
||||||
|
<label class="form-label mb-1">Map selected to:</label>
|
||||||
|
<select class="form-select form-select-sm" id="mapTypeSelect-{{ loop.index }}">
|
||||||
|
{% for t in valid_import_types %}
|
||||||
|
<option value="{{ t }}">{{ t.title().replace('_', ' ') }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-sm btn-warning" onclick="mapSelectedFiles(this, '{{ import_type }}')">
|
||||||
|
<i class="bi bi-tags"></i> Map Selected
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<form action="/admin/import/{{ import_type }}" method="post">
|
<form action="/admin/import/{{ import_type }}" method="post">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
@@ -783,5 +798,48 @@ async function deleteFile(filename, event) {
|
|||||||
alert(`Error deleting file: ${error.message}`);
|
alert(`Error deleting file: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map selected unknown files to a chosen import type
|
||||||
|
async function mapSelectedFiles(buttonEl, importType) {
|
||||||
|
// Find the surrounding card and form
|
||||||
|
const cardBody = buttonEl.closest('.card-body');
|
||||||
|
const form = cardBody.querySelector('form');
|
||||||
|
const selectEl = cardBody.querySelector('select.form-select');
|
||||||
|
if (!form || !selectEl) return;
|
||||||
|
|
||||||
|
// Collect selected filenames
|
||||||
|
const checked = Array.from(form.querySelectorAll('.file-checkbox:checked'))
|
||||||
|
.map(cb => cb.value);
|
||||||
|
if (checked.length === 0) {
|
||||||
|
alert('Select at least one file to map.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetType = selectEl.value;
|
||||||
|
if (!targetType) {
|
||||||
|
alert('Choose a target type.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
buttonEl.disabled = true;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/admin/map-files', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ target_type: targetType, filenames: checked })
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json().catch(() => ({}));
|
||||||
|
throw new Error(err.detail || 'Mapping failed');
|
||||||
|
}
|
||||||
|
// Refresh UI
|
||||||
|
window.location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert(`Mapping failed: ${e.message}`);
|
||||||
|
} finally {
|
||||||
|
buttonEl.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user