feat: add data collection mode to projects with UI updates and migration script
This commit is contained in:
53
backend/migrate_add_project_data_collection_mode.py
Normal file
53
backend/migrate_add_project_data_collection_mode.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Migration: Add data_collection_mode column to projects table.
|
||||||
|
|
||||||
|
Values:
|
||||||
|
"remote" — units have modems; data pulled via FTP/scheduler automatically
|
||||||
|
"manual" — no modem; SD cards retrieved daily and uploaded by hand
|
||||||
|
|
||||||
|
All existing projects are backfilled to "manual" (safe conservative default).
|
||||||
|
|
||||||
|
Run once inside the Docker container:
|
||||||
|
docker exec terra-view python3 backend/migrate_add_project_data_collection_mode.py
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = Path("data/seismo_fleet.db")
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
if not DB_PATH.exists():
|
||||||
|
print(f"Database not found at {DB_PATH}. Are you running from /home/serversdown/terra-view?")
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# ── 1. Add column (idempotent) ───────────────────────────────────────────
|
||||||
|
cur.execute("PRAGMA table_info(projects)")
|
||||||
|
existing_cols = {row["name"] for row in cur.fetchall()}
|
||||||
|
|
||||||
|
if "data_collection_mode" not in existing_cols:
|
||||||
|
cur.execute("ALTER TABLE projects ADD COLUMN data_collection_mode TEXT DEFAULT 'manual'")
|
||||||
|
conn.commit()
|
||||||
|
print("✓ Added column data_collection_mode to projects")
|
||||||
|
else:
|
||||||
|
print("○ Column data_collection_mode already exists — skipping ALTER TABLE")
|
||||||
|
|
||||||
|
# ── 2. Backfill NULLs to 'manual' ────────────────────────────────────────
|
||||||
|
cur.execute("UPDATE projects SET data_collection_mode = 'manual' WHERE data_collection_mode IS NULL")
|
||||||
|
updated = cur.rowcount
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
print(f"✓ Backfilled {updated} project(s) to data_collection_mode='manual'.")
|
||||||
|
print("Migration complete.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate()
|
||||||
@@ -157,6 +157,11 @@ class Project(Base):
|
|||||||
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
||||||
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
|
status = Column(String, default="active") # active, on_hold, completed, archived, deleted
|
||||||
|
|
||||||
|
# Data collection mode: how field data reaches Terra-View.
|
||||||
|
# "remote" — units have modems; data pulled via FTP/scheduler automatically
|
||||||
|
# "manual" — no modem; SD cards retrieved daily and uploaded by hand
|
||||||
|
data_collection_mode = Column(String, default="manual") # remote | manual
|
||||||
|
|
||||||
# Project metadata
|
# Project metadata
|
||||||
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
||||||
site_address = Column(String, nullable=True)
|
site_address = Column(String, nullable=True)
|
||||||
|
|||||||
@@ -613,6 +613,7 @@ async def get_project(project_id: str, db: Session = Depends(get_db)):
|
|||||||
"site_coordinates": project.site_coordinates,
|
"site_coordinates": project.site_coordinates,
|
||||||
"start_date": project.start_date.isoformat() if project.start_date else None,
|
"start_date": project.start_date.isoformat() if project.start_date else None,
|
||||||
"end_date": project.end_date.isoformat() if project.end_date else None,
|
"end_date": project.end_date.isoformat() if project.end_date else None,
|
||||||
|
"data_collection_mode": project.data_collection_mode or "manual",
|
||||||
"created_at": project.created_at.isoformat(),
|
"created_at": project.created_at.isoformat(),
|
||||||
"updated_at": project.updated_at.isoformat(),
|
"updated_at": project.updated_at.isoformat(),
|
||||||
}
|
}
|
||||||
@@ -659,6 +660,8 @@ async def update_project(
|
|||||||
project.start_date = datetime.fromisoformat(data["start_date"]) if data["start_date"] else None
|
project.start_date = datetime.fromisoformat(data["start_date"]) if data["start_date"] else None
|
||||||
if "end_date" in data:
|
if "end_date" in data:
|
||||||
project.end_date = datetime.fromisoformat(data["end_date"]) if data["end_date"] else None
|
project.end_date = datetime.fromisoformat(data["end_date"]) if data["end_date"] else None
|
||||||
|
if "data_collection_mode" in data and data["data_collection_mode"] in ("remote", "manual"):
|
||||||
|
project.data_collection_mode = data["data_collection_mode"]
|
||||||
|
|
||||||
project.updated_at = datetime.utcnow()
|
project.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
|||||||
@@ -75,6 +75,32 @@ Include this modal in pages that use the project picker.
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Data Collection <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="flex items-start gap-3 p-3 border-2 border-seismo-orange bg-orange-50 dark:bg-orange-900/20 rounded-lg cursor-pointer" id="qcp-mode-manual-label">
|
||||||
|
<input type="radio" name="data_collection_mode" value="manual" checked
|
||||||
|
onchange="qcpUpdateModeStyles()"
|
||||||
|
class="mt-0.5 accent-seismo-orange shrink-0">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Manual</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">SD card retrieved daily</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-3 p-3 border-2 border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer" id="qcp-mode-remote-label">
|
||||||
|
<input type="radio" name="data_collection_mode" value="remote"
|
||||||
|
onchange="qcpUpdateModeStyles()"
|
||||||
|
class="mt-0.5 accent-seismo-orange shrink-0">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Remote</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Modem, data pulled via FTP</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="qcp-error" class="hidden p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm">
|
<div id="qcp-error" class="hidden p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -98,6 +124,24 @@ Include this modal in pages that use the project picker.
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
function qcpUpdateModeStyles() {
|
||||||
|
const manualChecked = document.querySelector('input[name="data_collection_mode"][value="manual"]')?.checked;
|
||||||
|
const manualLabel = document.getElementById('qcp-mode-manual-label');
|
||||||
|
const remoteLabel = document.getElementById('qcp-mode-remote-label');
|
||||||
|
if (!manualLabel || !remoteLabel) return;
|
||||||
|
if (manualChecked) {
|
||||||
|
manualLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
manualLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
|
||||||
|
remoteLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
remoteLabel.classList.add('border-gray-300', 'dark:border-gray-600');
|
||||||
|
} else {
|
||||||
|
remoteLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
remoteLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
|
||||||
|
manualLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
manualLabel.classList.add('border-gray-300', 'dark:border-gray-600');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Quick create project modal functions
|
// Quick create project modal functions
|
||||||
if (typeof openCreateProjectModal === 'undefined') {
|
if (typeof openCreateProjectModal === 'undefined') {
|
||||||
function openCreateProjectModal(searchQuery, pickerId = '') {
|
function openCreateProjectModal(searchQuery, pickerId = '') {
|
||||||
@@ -113,6 +157,7 @@ if (typeof openCreateProjectModal === 'undefined') {
|
|||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
document.getElementById('quickCreateProjectForm').reset();
|
document.getElementById('quickCreateProjectForm').reset();
|
||||||
|
qcpUpdateModeStyles();
|
||||||
if (errorDiv) errorDiv.classList.add('hidden');
|
if (errorDiv) errorDiv.classList.add('hidden');
|
||||||
|
|
||||||
// Try to parse the search query intelligently
|
// Try to parse the search query intelligently
|
||||||
|
|||||||
@@ -12,6 +12,21 @@
|
|||||||
{% if project_type %}
|
{% if project_type %}
|
||||||
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if project.data_collection_mode == 'remote' %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
Remote
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
|
||||||
|
</svg>
|
||||||
|
Manual
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Project Actions -->
|
<!-- Project Actions -->
|
||||||
|
|||||||
@@ -40,12 +40,12 @@
|
|||||||
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">
|
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">
|
||||||
<span id="locations-tab-label">Locations</span>
|
<span id="locations-tab-label">Locations</span>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('units')"
|
<button id="units-tab-btn" onclick="switchTab('units')"
|
||||||
data-tab="units"
|
data-tab="units"
|
||||||
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">
|
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">
|
||||||
Assigned Units
|
Assigned Units
|
||||||
</button>
|
</button>
|
||||||
<button onclick="switchTab('schedules')"
|
<button id="schedules-tab-btn" onclick="switchTab('schedules')"
|
||||||
data-tab="schedules"
|
data-tab="schedules"
|
||||||
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">
|
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">
|
||||||
Schedules
|
Schedules
|
||||||
@@ -324,6 +324,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Data Collection</label>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<label class="flex items-start gap-3 p-3 border-2 rounded-lg cursor-pointer" id="settings-mode-manual-label">
|
||||||
|
<input type="radio" name="data_collection_mode" id="settings-mode-manual" value="manual"
|
||||||
|
onchange="settingsUpdateModeStyles()"
|
||||||
|
class="mt-0.5 accent-seismo-orange shrink-0">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Manual</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">SD card retrieved daily</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-start gap-3 p-3 border-2 rounded-lg cursor-pointer" id="settings-mode-remote-label">
|
||||||
|
<input type="radio" name="data_collection_mode" id="settings-mode-remote" value="remote"
|
||||||
|
onchange="settingsUpdateModeStyles()"
|
||||||
|
class="mt-0.5 accent-seismo-orange shrink-0">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white">Remote</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Modem, data pulled via FTP</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Site Address</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Site Address</label>
|
||||||
<input type="text" name="site_address" id="settings-site-address"
|
<input type="text" name="site_address" id="settings-site-address"
|
||||||
@@ -775,6 +799,12 @@ async function loadProjectDetails() {
|
|||||||
document.getElementById('settings-start-date').value = formatDate(data.start_date);
|
document.getElementById('settings-start-date').value = formatDate(data.start_date);
|
||||||
document.getElementById('settings-end-date').value = formatDate(data.end_date);
|
document.getElementById('settings-end-date').value = formatDate(data.end_date);
|
||||||
|
|
||||||
|
// Update data collection mode radio
|
||||||
|
const mode = data.data_collection_mode || 'manual';
|
||||||
|
const modeRadio = document.getElementById('settings-mode-' + mode);
|
||||||
|
if (modeRadio) modeRadio.checked = true;
|
||||||
|
settingsUpdateModeStyles();
|
||||||
|
|
||||||
// Update tab labels and visibility based on project type
|
// Update tab labels and visibility based on project type
|
||||||
const isSoundProject = projectTypeId === 'sound_monitoring';
|
const isSoundProject = projectTypeId === 'sound_monitoring';
|
||||||
if (isSoundProject) {
|
if (isSoundProject) {
|
||||||
@@ -782,9 +812,16 @@ async function loadProjectDetails() {
|
|||||||
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
||||||
document.getElementById('add-location-label').textContent = 'Add NRL';
|
document.getElementById('add-location-label').textContent = 'Add NRL';
|
||||||
}
|
}
|
||||||
// Monitoring Sessions and Data Files tabs are SLM-only
|
// Monitoring Sessions and Data Files tabs are sound-only
|
||||||
|
// Data Files also hides the FTP browser section for manual projects
|
||||||
|
const isRemote = mode === 'remote';
|
||||||
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
|
document.getElementById('sessions-tab-btn').classList.toggle('hidden', !isSoundProject);
|
||||||
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
|
document.getElementById('data-tab-btn').classList.toggle('hidden', !isSoundProject);
|
||||||
|
// Schedules and Assigned Units are remote-only (manual projects collect data by hand)
|
||||||
|
document.getElementById('schedules-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
|
||||||
|
document.getElementById('units-tab-btn')?.classList.toggle('hidden', isSoundProject && !isRemote);
|
||||||
|
// FTP browser within Data Files tab
|
||||||
|
document.getElementById('ftp-browser')?.classList.toggle('hidden', !isRemote);
|
||||||
|
|
||||||
document.getElementById('settings-error').classList.add('hidden');
|
document.getElementById('settings-error').classList.add('hidden');
|
||||||
updateDangerZone();
|
updateDangerZone();
|
||||||
@@ -800,6 +837,24 @@ function formatDate(value) {
|
|||||||
return date.toISOString().slice(0, 10);
|
return date.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function settingsUpdateModeStyles() {
|
||||||
|
const manualChecked = document.getElementById('settings-mode-manual')?.checked;
|
||||||
|
const manualLabel = document.getElementById('settings-mode-manual-label');
|
||||||
|
const remoteLabel = document.getElementById('settings-mode-remote-label');
|
||||||
|
if (!manualLabel || !remoteLabel) return;
|
||||||
|
if (manualChecked) {
|
||||||
|
manualLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
manualLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
|
||||||
|
remoteLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
remoteLabel.classList.add('border-gray-300', 'dark:border-gray-600');
|
||||||
|
} else {
|
||||||
|
remoteLabel.classList.add('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
remoteLabel.classList.remove('border-gray-300', 'dark:border-gray-600');
|
||||||
|
manualLabel.classList.remove('border-seismo-orange', 'bg-orange-50', 'dark:bg-orange-900/20');
|
||||||
|
manualLabel.classList.add('border-gray-300', 'dark:border-gray-600');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Project settings form submission
|
// Project settings form submission
|
||||||
document.getElementById('project-settings-form').addEventListener('submit', async function(e) {
|
document.getElementById('project-settings-form').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -812,7 +867,8 @@ document.getElementById('project-settings-form').addEventListener('submit', asyn
|
|||||||
site_address: document.getElementById('settings-site-address').value.trim() || null,
|
site_address: document.getElementById('settings-site-address').value.trim() || null,
|
||||||
site_coordinates: document.getElementById('settings-site-coordinates').value.trim() || null,
|
site_coordinates: document.getElementById('settings-site-coordinates').value.trim() || null,
|
||||||
start_date: document.getElementById('settings-start-date').value || null,
|
start_date: document.getElementById('settings-start-date').value || null,
|
||||||
end_date: document.getElementById('settings-end-date').value || null
|
end_date: document.getElementById('settings-end-date').value || null,
|
||||||
|
data_collection_mode: document.querySelector('input[name="data_collection_mode"]:checked')?.value || 'manual'
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user