feat: add manual SD card data upload for offline NRLs; rename RecordingSession to MonitoringSession
- Add POST /api/projects/{project_id}/nrl/{location_id}/upload-data endpoint
accepting a ZIP or multi-file select of .rnd/.rnh files from an SD card.
Parses .rnh metadata for session start/stop times, serial number, and store
name. Creates a MonitoringSession (no unit assignment required) and DataFile
records for each measurement file.
- Add Upload Data button and collapsible upload panel to the NRL detail Data
Files tab, with inline success/error feedback and automatic file list refresh
via HTMX after import.
- Rename RecordingSession -> MonitoringSession throughout the codebase
(models.py, projects.py, project_locations.py, scheduler.py, roster_rename.py,
main.py, init_projects_db.py, scripts/rename_unit.py). DB table renamed from
recording_sessions to monitoring_sessions; old indexes dropped and recreated.
- Update all template UI copy from Recording Sessions to Monitoring Sessions
(nrl_detail, projects/detail, session_list, schedule_oneoff, roster).
- Add backend/migrate_rename_recording_to_monitoring_sessions.py for applying
the table rename on production databases before deploying this build.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -80,7 +80,7 @@
|
||||
<button onclick="switchTab('sessions')"
|
||||
data-tab="sessions"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||
Recording Sessions
|
||||
Monitoring Sessions
|
||||
</button>
|
||||
<button onclick="switchTab('data')"
|
||||
data-tab="data"
|
||||
@@ -302,11 +302,11 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Recording Sessions Tab -->
|
||||
<!-- Monitoring Sessions Tab -->
|
||||
<div id="sessions-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||
{% if assigned_unit %}
|
||||
<button onclick="openScheduleModal()"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
@@ -329,8 +329,40 @@
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
|
||||
<div class="text-sm text-gray-500">
|
||||
<span class="font-medium">{{ file_count }}</span> files
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-500"><span class="font-medium">{{ file_count }}</span> files</span>
|
||||
<button onclick="toggleUploadPanel()"
|
||||
class="px-3 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1.5">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||
</svg>
|
||||
Upload Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Panel -->
|
||||
<div id="upload-panel" class="hidden mb-6 p-4 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl bg-gray-50 dark:bg-gray-800/50">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Upload SD Card Data</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
Select a ZIP file, or select all files from inside an <code class="bg-gray-200 dark:bg-gray-700 px-1 rounded">Auto_####</code> folder. File types (.rnd, .rnh) are auto-detected.
|
||||
</p>
|
||||
<input type="file" id="upload-input" multiple
|
||||
accept=".zip,.rnd,.rnh,.RND,.RNH"
|
||||
class="block w-full text-sm text-gray-500 dark:text-gray-400
|
||||
file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0
|
||||
file:text-sm file:font-medium file:bg-seismo-orange file:text-white
|
||||
hover:file:bg-seismo-navy file:cursor-pointer" />
|
||||
<div class="flex items-center gap-3 mt-3">
|
||||
<button onclick="submitUpload()"
|
||||
class="px-4 py-1.5 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
||||
Import Files
|
||||
</button>
|
||||
<button onclick="toggleUploadPanel()"
|
||||
class="px-4 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<span id="upload-status" class="text-sm hidden"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -559,5 +591,64 @@ document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
||||
closeAssignModal();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Upload Data ─────────────────────────────────────────────────────────────
|
||||
|
||||
function toggleUploadPanel() {
|
||||
const panel = document.getElementById('upload-panel');
|
||||
const status = document.getElementById('upload-status');
|
||||
panel.classList.toggle('hidden');
|
||||
// Reset status when reopening
|
||||
if (!panel.classList.contains('hidden')) {
|
||||
status.textContent = '';
|
||||
status.className = 'text-sm hidden';
|
||||
document.getElementById('upload-input').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function submitUpload() {
|
||||
const input = document.getElementById('upload-input');
|
||||
const status = document.getElementById('upload-status');
|
||||
|
||||
if (!input.files.length) {
|
||||
alert('Please select files to upload.');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
for (const file of input.files) {
|
||||
formData.append('files', file);
|
||||
}
|
||||
|
||||
status.textContent = 'Uploading\u2026';
|
||||
status.className = 'text-sm text-gray-500';
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/projects/${projectId}/nrl/${locationId}/upload-data`,
|
||||
{ method: 'POST', body: formData }
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
const parts = [`Imported ${data.files_imported} file${data.files_imported !== 1 ? 's' : ''}`];
|
||||
if (data.leq_files || data.lp_files) {
|
||||
parts.push(`(${data.leq_files} Leq, ${data.lp_files} Lp)`);
|
||||
}
|
||||
if (data.store_name) parts.push(`\u2014 ${data.store_name}`);
|
||||
status.textContent = parts.join(' ');
|
||||
status.className = 'text-sm text-green-600 dark:text-green-400';
|
||||
input.value = '';
|
||||
// Refresh the file list
|
||||
htmx.trigger(document.getElementById('data-files-list'), 'load');
|
||||
} else {
|
||||
status.textContent = `Error: ${data.detail || 'Upload failed'}`;
|
||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
}
|
||||
} catch (err) {
|
||||
status.textContent = `Error: ${err.message}`;
|
||||
status.className = 'text-sm text-red-600 dark:text-red-400';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -151,9 +151,9 @@
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No files downloaded yet</p>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No data files yet</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">
|
||||
Files will appear here once they are downloaded from the sound level meter
|
||||
Files appear here after an FTP download from a connected meter, or after uploading SD card data manually.
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="mb-4">
|
||||
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">One-Off Recording</h4>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Schedule a single recording session with a specific start and end time.
|
||||
Schedule a single monitoring session with a specific start and end time.
|
||||
Duration can be between 15 minutes and 24 hours.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- Recording Sessions List -->
|
||||
<!-- Monitoring Sessions List -->
|
||||
{% if sessions %}
|
||||
<div class="space-y-4">
|
||||
{% for item in sessions %}
|
||||
@@ -87,7 +87,7 @@
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No recording sessions yet</p>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No monitoring sessions yet</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -99,7 +99,7 @@ function viewSession(sessionId) {
|
||||
}
|
||||
|
||||
function stopRecording(sessionId) {
|
||||
if (!confirm('Stop this recording session?')) return;
|
||||
if (!confirm('Stop this monitoring session?')) return;
|
||||
|
||||
// TODO: Implement stop recording API call
|
||||
alert('Stop recording API coming soon for session: ' + sessionId);
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<button id="sessions-tab-btn" onclick="switchTab('sessions')"
|
||||
data-tab="sessions"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||
Recording Sessions
|
||||
Monitoring Sessions
|
||||
</button>
|
||||
<button id="data-tab-btn" onclick="switchTab('data')"
|
||||
data-tab="data"
|
||||
@@ -185,11 +185,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recording Sessions Tab -->
|
||||
<!-- Monitoring Sessions Tab -->
|
||||
<div id="sessions-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Monitoring Sessions</h2>
|
||||
<div class="flex items-center gap-4">
|
||||
<select id="sessions-filter" onchange="filterSessions()"
|
||||
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||
@@ -521,7 +521,7 @@
|
||||
<span class="font-medium text-gray-900 dark:text-white">One-Off Recording</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Single recording session with a specific start and end date/time (15 min - 24 hrs).
|
||||
Single monitoring session with a specific start and end date/time (15 min - 24 hrs).
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
@@ -721,7 +721,7 @@ async function loadProjectDetails() {
|
||||
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
||||
document.getElementById('add-location-label').textContent = 'Add NRL';
|
||||
}
|
||||
// Recording Sessions and Data Files tabs are SLM-only
|
||||
// Monitoring Sessions and Data Files tabs are SLM-only
|
||||
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
|
||||
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
|
||||
|
||||
@@ -808,6 +808,10 @@ function openLocationModal(defaultType) {
|
||||
locationTypeSelect.value = 'sound';
|
||||
locationTypeSelect.disabled = true;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||
} else if (projectTypeId === 'vibration_monitoring') {
|
||||
locationTypeSelect.value = 'vibration';
|
||||
locationTypeSelect.disabled = true;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||
} else {
|
||||
locationTypeSelect.disabled = false;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||
@@ -832,6 +836,10 @@ function openEditLocationModal(button) {
|
||||
locationTypeSelect.value = 'sound';
|
||||
locationTypeSelect.disabled = true;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||
} else if (projectTypeId === 'vibration_monitoring') {
|
||||
locationTypeSelect.value = 'vibration';
|
||||
locationTypeSelect.disabled = true;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||
} else {
|
||||
locationTypeSelect.disabled = false;
|
||||
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||
@@ -855,6 +863,8 @@ document.getElementById('location-form').addEventListener('submit', async functi
|
||||
let locationType = document.getElementById('location-type').value;
|
||||
if (projectTypeId === 'sound_monitoring') {
|
||||
locationType = 'sound';
|
||||
} else if (projectTypeId === 'vibration_monitoring') {
|
||||
locationType = 'vibration';
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -78,9 +78,16 @@
|
||||
<!-- Create Project Modal -->
|
||||
<div id="createProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p>
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-start justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p>
|
||||
</div>
|
||||
<button onclick="hideCreateProjectModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 ml-4">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6" id="createProjectContent">
|
||||
@@ -97,6 +104,12 @@
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button type="button" onclick="hideCreateProjectModal()"
|
||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Project Details Form (hidden initially) -->
|
||||
|
||||
@@ -1504,7 +1504,7 @@
|
||||
`• Unit roster entry\n` +
|
||||
`• All history records\n` +
|
||||
`• Project assignments\n` +
|
||||
`• Recording sessions\n` +
|
||||
`• Monitoring sessions\n` +
|
||||
`• Modem references\n\n` +
|
||||
`This action cannot be undone.`
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user