| |
| """ |
| 雙向同步: |
| - pull: 雲端 → Local(雲端為主,覆蓋 Local) |
| - push: Local → 雲端(Append-only merge,不刪雲端資料) |
| 用法: python sync.py # pull(預設) |
| python sync.py push # push |
| """ |
|
|
| import configparser |
| import sqlite3 |
| import shutil |
| import os |
| import tempfile |
| from pathlib import Path |
| from datetime import datetime |
|
|
| TOKEN = os.environ.get('HF_TOKEN', '') |
| USERNAME = os.environ.get('HF_USERNAME', '') |
| if not TOKEN: |
| cfg = configparser.ConfigParser() |
| cfg.read('Myconfig.ini') |
| try: |
| TOKEN = cfg['Hugging Face']['Token'].strip() |
| USERNAME = cfg['Hugging Face']['Username'].strip() |
| except KeyError: |
| pass |
| REPO_ID = f'{USERNAME}/pradsa' |
|
|
| LOCAL_DB = Path('pra_data.db') |
| BACKUP_DIR = LOCAL_DB.parent / 'backups' |
|
|
|
|
| def get_db_info(db_path): |
| if not Path(db_path).exists(): |
| return {'patients': 0, 'reports': 0, 'users': 0, 'latest': ''} |
| conn = sqlite3.connect(str(db_path)) |
| conn.row_factory = sqlite3.Row |
| try: |
| patients = conn.execute('SELECT COUNT(*) as c FROM patients').fetchone()['c'] |
| reports = conn.execute('SELECT COUNT(*) as c FROM reports WHERE COALESCE(is_deleted,0)=0').fetchone()['c'] |
| users = conn.execute('SELECT COUNT(*) as c FROM users').fetchone()['c'] |
| latest = conn.execute( |
| "SELECT MAX(COALESCE(updated_at, created_at)) as t FROM reports" |
| ).fetchone()['t'] or '' |
| except Exception: |
| patients, reports, users, latest = 0, 0, 0, '' |
| conn.close() |
| return {'patients': patients, 'reports': reports, 'users': users, 'latest': latest} |
|
|
|
|
| def download_cloud_db(): |
| from huggingface_hub import hf_hub_download |
| try: |
| path = hf_hub_download( |
| repo_id=REPO_ID, filename='pra_data.db', |
| repo_type='space', token=TOKEN, |
| local_dir=tempfile.gettempdir(), |
| force_download=True, |
| ) |
| return path |
| except Exception as e: |
| print(f' 雲端 DB 下載失敗: {e}') |
| return None |
|
|
|
|
| def upload_db(db_path): |
| from huggingface_hub import HfApi |
| api = HfApi(token=TOKEN) |
| api.upload_file( |
| path_or_fileobj=str(db_path), |
| path_in_repo='pra_data.db', |
| repo_id=REPO_ID, |
| repo_type='space', |
| ) |
|
|
|
|
| def _backup_local(tag='sync'): |
| """備份 Local DB(當天最多 5 個,總共最多 15 個)""" |
| if not LOCAL_DB.exists(): |
| return None |
| BACKUP_DIR.mkdir(exist_ok=True) |
| today = datetime.now().strftime('%Y%m%d') |
| ts = datetime.now().strftime('%Y%m%d_%H%M%S') |
| bk = BACKUP_DIR / f'pra_data_before_{tag}_{ts}.db' |
| shutil.copy2(str(LOCAL_DB), str(bk)) |
| print(f'已備份 Local → {bk.name}') |
|
|
| today_bks = sorted(BACKUP_DIR.glob(f'pra_data_before_{tag}_{today}_*.db')) |
| for old in today_bks[:-5]: |
| old.unlink() |
| all_bks = sorted(BACKUP_DIR.glob(f'pra_data_before_{tag}_*.db')) |
| for old in all_bks[:-15]: |
| old.unlink() |
| return bk |
|
|
|
|
| def merge_local_into_cloud(local_path, cloud_path, merged_path): |
| """ |
| Append-only merge:以雲端為底,把 Local 的新增/更新資料合併進去。 |
| 絕不刪除雲端已有的 patients / reports / users。 |
| """ |
| shutil.copy2(cloud_path, merged_path) |
|
|
| merged = sqlite3.connect(str(merged_path)) |
| merged.row_factory = sqlite3.Row |
| local = sqlite3.connect(str(local_path)) |
| local.row_factory = sqlite3.Row |
|
|
| |
| local_patients = local.execute('SELECT * FROM patients').fetchall() |
| for p in local_patients: |
| existing = merged.execute('SELECT id FROM patients WHERE chart_no=?', (p['chart_no'],)).fetchone() |
| if existing: |
| |
| merged.execute('UPDATE patients SET patient_name=?, donor_hla=? WHERE chart_no=?', |
| (p['patient_name'], p['donor_hla'] or '', p['chart_no'])) |
| else: |
| merged.execute( |
| 'INSERT INTO patients (patient_name, chart_no, created_at, donor_hla) VALUES (?,?,?,?)', |
| (p['patient_name'], p['chart_no'], p['created_at'], p['donor_hla'] or '')) |
|
|
| |
| merged_patients = {r['chart_no']: r['id'] for r in merged.execute('SELECT id, chart_no FROM patients').fetchall()} |
| |
| local_pt_map = {r['id']: r['chart_no'] for r in local.execute('SELECT id, chart_no FROM patients').fetchall()} |
|
|
| |
| local_reports = local.execute('SELECT * FROM reports').fetchall() |
| for r in local_reports: |
| chart_no = local_pt_map.get(r['patient_id']) |
| if not chart_no: |
| continue |
| merged_pid = merged_patients.get(chart_no) |
| if not merged_pid: |
| continue |
|
|
| existing = merged.execute( |
| 'SELECT id, COALESCE(is_deleted,0) as is_deleted, updated_at FROM reports WHERE patient_id=? AND report_date=? AND pra_class=?', |
| (merged_pid, r['report_date'], r['pra_class']) |
| ).fetchone() |
|
|
| if existing: |
| |
| cloud_deleted = existing['is_deleted'] |
| local_deleted = r['is_deleted'] if r['is_deleted'] is not None else 0 |
| |
| new_deleted = 0 if (cloud_deleted == 0 and local_deleted == 1) else local_deleted |
| merged.execute('''UPDATE reports SET pra_percent=?, overall=?, specificity=?, comment=?, |
| status=?, submitted_by=?, upload_file=?, updated_at=?, |
| is_deleted=?, deleted_at=? WHERE id=?''', |
| (r['pra_percent'], r['overall'], r['specificity'], r['comment'], |
| r['status'] or 'draft', r['submitted_by'] or '', |
| r['upload_file'] or '', r['updated_at'], |
| new_deleted, r['deleted_at'] if new_deleted else None, |
| existing['id'])) |
| merged_rid = existing['id'] |
| else: |
| |
| cur = merged.execute( |
| '''INSERT INTO reports (patient_id, report_date, pra_class, pra_percent, overall, |
| specificity, comment, status, submitted_by, upload_file, created_at, updated_at, |
| is_deleted, deleted_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)''', |
| (merged_pid, r['report_date'], r['pra_class'], r['pra_percent'], r['overall'], |
| r['specificity'], r['comment'], r['status'] or 'draft', |
| r['submitted_by'] or '', r['upload_file'] or '', |
| r['created_at'], r['updated_at'], |
| r['is_deleted'] if r['is_deleted'] is not None else 0, r['deleted_at'])) |
| merged_rid = cur.lastrowid |
|
|
| |
| local_rid = r['id'] |
| local_abs = local.execute('SELECT * FROM antibody_strength WHERE report_id=?', (local_rid,)).fetchall() |
| if local_abs: |
| merged.execute('DELETE FROM antibody_strength WHERE report_id=?', (merged_rid,)) |
| for a in local_abs: |
| merged.execute( |
| '''INSERT INTO antibody_strength (report_id, antigen, allele, max_mfi, mean_mfi, no_of_beads, bead_ids) |
| VALUES (?,?,?,?,?,?,?)''', |
| (merged_rid, a['antigen'], a['allele'], a['max_mfi'], a['mean_mfi'], |
| a['no_of_beads'], a['bead_ids'])) |
|
|
| |
| local_users = local.execute('SELECT * FROM users').fetchall() |
| for u in local_users: |
| existing = merged.execute('SELECT id FROM users WHERE username=?', (u['username'],)).fetchone() |
| if existing: |
| |
| merged.execute('UPDATE users SET display_name=?, password_hash=?, password_plain=?, role=?, edit_time=? WHERE id=?', |
| (u['display_name'] or '', u['password_hash'], u['password_plain'] or '', |
| u['role'] or 'user', u['edit_time'], existing['id'])) |
| else: |
| merged.execute( |
| 'INSERT INTO users (username, display_name, password_hash, password_plain, role, created_at, edit_time) VALUES (?,?,?,?,?,?,?)', |
| (u['username'], u['display_name'] or '', u['password_hash'], |
| u['password_plain'] or '', u['role'] or 'user', u['created_at'], u['edit_time'])) |
|
|
| merged.commit() |
| local.close() |
| merged.close() |
|
|
|
|
| def push(): |
| """Local → 雲端 (Append-only merge)""" |
| print('=== Local → 雲端 Append-only Push ===\n') |
|
|
| local_info = get_db_info(LOCAL_DB) |
| print(f'Local: {local_info["patients"]} 病患, {local_info["reports"]} 報告, {local_info["users"]} 使用者') |
|
|
| print('下載雲端 DB...') |
| cloud_path = download_cloud_db() |
| if not cloud_path: |
| print('無法取得雲端 DB,改為直接上傳 Local DB') |
| upload_db(LOCAL_DB) |
| print('已直接上傳') |
| return |
|
|
| cloud_info = get_db_info(cloud_path) |
| print(f'Cloud: {cloud_info["patients"]} 病患, {cloud_info["reports"]} 報告, {cloud_info["users"]} 使用者') |
| print() |
|
|
| |
| merged_path = Path(tempfile.gettempdir()) / 'pra_data_merged.db' |
| merge_local_into_cloud(str(LOCAL_DB), cloud_path, str(merged_path)) |
|
|
| merged_info = get_db_info(merged_path) |
| print(f'Merged: {merged_info["patients"]} 病患, {merged_info["reports"]} 報告, {merged_info["users"]} 使用者') |
|
|
| |
| upload_db(merged_path) |
| print('\n已推送 merged DB 到雲端') |
|
|
| |
| _backup_local('push') |
| shutil.copy2(str(merged_path), str(LOCAL_DB)) |
| print('Local DB 已同步為 merged 版本') |
|
|
|
|
| def sync(): |
| """雲端 → Local(pull,雲端為主)""" |
| print('=== 雲端 → Local 單向同步 ===\n') |
|
|
| local_info = get_db_info(LOCAL_DB) |
| print(f'Local: {local_info["patients"]} 病患, {local_info["reports"]} 報告, {local_info["users"]} 使用者, latest: {local_info["latest"] or "(none)"}') |
|
|
| print('下載雲端 DB...') |
| cloud_path = download_cloud_db() |
| if not cloud_path: |
| print('無法取得雲端 DB') |
| return |
|
|
| cloud_info = get_db_info(cloud_path) |
| print(f'Cloud: {cloud_info["patients"]} 病患, {cloud_info["reports"]} 報告, {cloud_info["users"]} 使用者, latest: {cloud_info["latest"] or "(none)"}') |
| print() |
|
|
| if (local_info['latest'] == cloud_info['latest'] |
| and local_info['reports'] == cloud_info['reports'] |
| and local_info['patients'] == cloud_info['patients'] |
| and local_info['users'] == cloud_info['users']): |
| print('兩邊一致,不需要同步') |
| return |
|
|
| _backup_local('sync') |
|
|
| |
| shutil.copy2(cloud_path, str(LOCAL_DB)) |
|
|
| new_info = get_db_info(LOCAL_DB) |
| print(f'\n同步完成: {new_info["patients"]} 病患, {new_info["reports"]} 報告, {new_info["users"]} 使用者') |
|
|
|
|
| if __name__ == '__main__': |
| import sys |
| if len(sys.argv) > 1 and sys.argv[1] == 'push': |
| push() |
| else: |
| sync() |
|
|