Upload db.py with huggingface_hub
Browse files
db.py
CHANGED
|
@@ -248,6 +248,42 @@ def init_db():
|
|
| 248 |
CREATE INDEX IF NOT EXISTS idx_reports_patient ON reports(patient_id);
|
| 249 |
CREATE INDEX IF NOT EXISTS idx_reports_date ON reports(report_date);
|
| 250 |
CREATE INDEX IF NOT EXISTS idx_ab_report ON antibody_strength(report_id);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
''')
|
| 252 |
# migrate patients table
|
| 253 |
pat_cols = [r[1] for r in conn.execute('PRAGMA table_info(patients)').fetchall()]
|
|
@@ -603,6 +639,267 @@ def get_mfi_comparison(chart_no, pra_class=None):
|
|
| 603 |
return dates, antigens_out, pra_by_date, labels_by_rid
|
| 604 |
|
| 605 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
def delete_report(report_id):
|
| 607 |
"""軟刪除報告(標記 is_deleted=1,不真的刪)。
|
| 608 |
若 upload_file 不再被任何 active report 引用,同步刪除 uploads/ 下的檔案。"""
|
|
|
|
| 248 |
CREATE INDEX IF NOT EXISTS idx_reports_patient ON reports(patient_id);
|
| 249 |
CREATE INDEX IF NOT EXISTS idx_reports_date ON reports(report_date);
|
| 250 |
CREATE INDEX IF NOT EXISTS idx_ab_report ON antibody_strength(report_id);
|
| 251 |
+
|
| 252 |
+
CREATE TABLE IF NOT EXISTS dsa_reports (
|
| 253 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 254 |
+
patient_id INTEGER NOT NULL,
|
| 255 |
+
report_date TEXT NOT NULL,
|
| 256 |
+
dsa_class TEXT NOT NULL,
|
| 257 |
+
pct_sa INTEGER NOT NULL,
|
| 258 |
+
overall TEXT NOT NULL,
|
| 259 |
+
specificity TEXT,
|
| 260 |
+
comment TEXT,
|
| 261 |
+
status TEXT DEFAULT 'draft',
|
| 262 |
+
submitted_by TEXT DEFAULT '',
|
| 263 |
+
upload_file TEXT DEFAULT '',
|
| 264 |
+
is_deleted INTEGER DEFAULT 0,
|
| 265 |
+
deleted_at TEXT,
|
| 266 |
+
created_at TEXT DEFAULT (datetime('now','localtime')),
|
| 267 |
+
updated_at TEXT DEFAULT (datetime('now','localtime')),
|
| 268 |
+
FOREIGN KEY (patient_id) REFERENCES patients(id)
|
| 269 |
+
);
|
| 270 |
+
|
| 271 |
+
CREATE TABLE IF NOT EXISTS dsa_antibody_strength (
|
| 272 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 273 |
+
report_id INTEGER NOT NULL,
|
| 274 |
+
antigen TEXT NOT NULL,
|
| 275 |
+
allele TEXT,
|
| 276 |
+
strength TEXT,
|
| 277 |
+
max_mfi REAL,
|
| 278 |
+
mean_mfi REAL,
|
| 279 |
+
no_of_beads INTEGER,
|
| 280 |
+
bead_ids TEXT,
|
| 281 |
+
FOREIGN KEY (report_id) REFERENCES dsa_reports(id) ON DELETE CASCADE
|
| 282 |
+
);
|
| 283 |
+
|
| 284 |
+
CREATE INDEX IF NOT EXISTS idx_dsa_reports_patient ON dsa_reports(patient_id);
|
| 285 |
+
CREATE INDEX IF NOT EXISTS idx_dsa_reports_date ON dsa_reports(report_date);
|
| 286 |
+
CREATE INDEX IF NOT EXISTS idx_dsa_ab_report ON dsa_antibody_strength(report_id);
|
| 287 |
''')
|
| 288 |
# migrate patients table
|
| 289 |
pat_cols = [r[1] for r in conn.execute('PRAGMA table_info(patients)').fetchall()]
|
|
|
|
| 639 |
return dates, antigens_out, pra_by_date, labels_by_rid
|
| 640 |
|
| 641 |
|
| 642 |
+
# ============================================================
|
| 643 |
+
# DSA helpers (Single-Antigen / Donor-Specific Antibody)
|
| 644 |
+
# ============================================================
|
| 645 |
+
|
| 646 |
+
def find_active_duplicate_dsa(patient_id, report_date, dsa_class):
|
| 647 |
+
conn = get_conn()
|
| 648 |
+
row = conn.execute(
|
| 649 |
+
'''SELECT id FROM dsa_reports
|
| 650 |
+
WHERE patient_id=? AND report_date=? AND dsa_class=?
|
| 651 |
+
AND COALESCE(is_deleted,0)=0''',
|
| 652 |
+
(patient_id, report_date, dsa_class)
|
| 653 |
+
).fetchone()
|
| 654 |
+
conn.close()
|
| 655 |
+
return row['id'] if row else None
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
def save_dsa_report(patient_id, report_date, dsa_class, pct_sa, overall,
|
| 659 |
+
specificity, comment, sero_mfi_list, status='draft',
|
| 660 |
+
submitted_by='', upload_file='', mode='overwrite'):
|
| 661 |
+
backup_db()
|
| 662 |
+
conn = get_conn()
|
| 663 |
+
existing = None
|
| 664 |
+
if mode == 'overwrite':
|
| 665 |
+
existing = conn.execute(
|
| 666 |
+
'''SELECT id FROM dsa_reports
|
| 667 |
+
WHERE patient_id=? AND report_date=? AND dsa_class=?
|
| 668 |
+
AND COALESCE(is_deleted,0)=0''',
|
| 669 |
+
(patient_id, report_date, dsa_class)
|
| 670 |
+
).fetchone()
|
| 671 |
+
|
| 672 |
+
if existing:
|
| 673 |
+
report_id = existing['id']
|
| 674 |
+
conn.execute('''UPDATE dsa_reports SET pct_sa=?, overall=?, specificity=?, comment=?,
|
| 675 |
+
status=?, submitted_by=?, upload_file=?,
|
| 676 |
+
updated_at=datetime('now','localtime') WHERE id=?''',
|
| 677 |
+
(pct_sa, overall, specificity, comment, status, submitted_by,
|
| 678 |
+
upload_file or '', report_id))
|
| 679 |
+
conn.execute('DELETE FROM dsa_antibody_strength WHERE report_id=?', (report_id,))
|
| 680 |
+
else:
|
| 681 |
+
cur = conn.execute(
|
| 682 |
+
'''INSERT INTO dsa_reports (patient_id, report_date, dsa_class, pct_sa, overall,
|
| 683 |
+
specificity, comment, status, submitted_by, upload_file)
|
| 684 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)''',
|
| 685 |
+
(patient_id, report_date, dsa_class, pct_sa, overall, specificity, comment,
|
| 686 |
+
status, submitted_by, upload_file or ''))
|
| 687 |
+
report_id = cur.lastrowid
|
| 688 |
+
|
| 689 |
+
for m in sero_mfi_list:
|
| 690 |
+
conn.execute(
|
| 691 |
+
'''INSERT INTO dsa_antibody_strength
|
| 692 |
+
(report_id, antigen, allele, strength, max_mfi, mean_mfi, no_of_beads, bead_ids)
|
| 693 |
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)''',
|
| 694 |
+
(report_id, m.get('sero', ''), m.get('alleles', ''),
|
| 695 |
+
m.get('strength', ''), m.get('max_mfi', 0), m.get('mean_mfi', 0),
|
| 696 |
+
m.get('count', 0), m.get('beads', '')))
|
| 697 |
+
|
| 698 |
+
conn.commit()
|
| 699 |
+
conn.close()
|
| 700 |
+
schedule_auto_push()
|
| 701 |
+
return report_id
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
def get_all_dsa_reports(limit=200):
|
| 705 |
+
conn = get_conn()
|
| 706 |
+
rows = conn.execute('''
|
| 707 |
+
SELECT r.*, p.patient_name, p.chart_no, p.donor_hla
|
| 708 |
+
FROM dsa_reports r JOIN patients p ON p.id = r.patient_id
|
| 709 |
+
WHERE COALESCE(r.is_deleted, 0) = 0
|
| 710 |
+
ORDER BY p.patient_name, p.chart_no, r.report_date DESC, r.dsa_class
|
| 711 |
+
LIMIT ?
|
| 712 |
+
''', (limit,)).fetchall()
|
| 713 |
+
conn.close()
|
| 714 |
+
return [dict(r) for r in rows]
|
| 715 |
+
|
| 716 |
+
|
| 717 |
+
def get_all_dsa_patients():
|
| 718 |
+
conn = get_conn()
|
| 719 |
+
rows = conn.execute('''
|
| 720 |
+
SELECT p.id, p.patient_name, p.chart_no,
|
| 721 |
+
COUNT(r.id) as report_count,
|
| 722 |
+
MAX(r.report_date) as latest_date,
|
| 723 |
+
GROUP_CONCAT(DISTINCT r.dsa_class) as classes
|
| 724 |
+
FROM patients p
|
| 725 |
+
LEFT JOIN dsa_reports r ON r.patient_id = p.id AND COALESCE(r.is_deleted, 0) = 0
|
| 726 |
+
GROUP BY p.id
|
| 727 |
+
HAVING report_count > 0
|
| 728 |
+
ORDER BY latest_date DESC
|
| 729 |
+
''').fetchall()
|
| 730 |
+
conn.close()
|
| 731 |
+
return [dict(r) for r in rows]
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
def get_dsa_patient_reports(chart_no):
|
| 735 |
+
conn = get_conn()
|
| 736 |
+
patient = conn.execute('SELECT * FROM patients WHERE chart_no=?', (chart_no,)).fetchone()
|
| 737 |
+
if not patient:
|
| 738 |
+
conn.close()
|
| 739 |
+
return None, []
|
| 740 |
+
reports = conn.execute('''
|
| 741 |
+
SELECT * FROM dsa_reports WHERE patient_id=? AND COALESCE(is_deleted,0)=0
|
| 742 |
+
ORDER BY report_date DESC, dsa_class
|
| 743 |
+
''', (patient['id'],)).fetchall()
|
| 744 |
+
out = []
|
| 745 |
+
for r in reports:
|
| 746 |
+
ab = conn.execute('SELECT * FROM dsa_antibody_strength WHERE report_id=? ORDER BY id',
|
| 747 |
+
(r['id'],)).fetchall()
|
| 748 |
+
out.append({**dict(r), 'antibodies': [dict(a) for a in ab]})
|
| 749 |
+
conn.close()
|
| 750 |
+
return dict(patient), out
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
def get_dsa_mfi_comparison(chart_no, dsa_class=None):
|
| 754 |
+
"""Same shape as PRA's get_mfi_comparison but reads dsa_reports."""
|
| 755 |
+
conn = get_conn()
|
| 756 |
+
patient = conn.execute('SELECT id FROM patients WHERE chart_no=?', (chart_no,)).fetchone()
|
| 757 |
+
if not patient:
|
| 758 |
+
conn.close()
|
| 759 |
+
return [], [], {}, {}
|
| 760 |
+
|
| 761 |
+
q = '''SELECT id, report_date, pct_sa, specificity FROM dsa_reports
|
| 762 |
+
WHERE patient_id = ? AND COALESCE(is_deleted, 0) = 0'''
|
| 763 |
+
params = [patient['id']]
|
| 764 |
+
if dsa_class:
|
| 765 |
+
q += ' AND dsa_class = ?'
|
| 766 |
+
params.append(dsa_class)
|
| 767 |
+
q += ' ORDER BY report_date, id'
|
| 768 |
+
reports = conn.execute(q, params).fetchall()
|
| 769 |
+
|
| 770 |
+
from collections import defaultdict
|
| 771 |
+
date_count = defaultdict(int)
|
| 772 |
+
for r in reports:
|
| 773 |
+
date_count[r['report_date']] += 1
|
| 774 |
+
date_seen = defaultdict(int)
|
| 775 |
+
labels_by_rid = {}
|
| 776 |
+
dates = []
|
| 777 |
+
pct_by_date = {}
|
| 778 |
+
for r in reports:
|
| 779 |
+
d = r['report_date']
|
| 780 |
+
if date_count[d] == 1:
|
| 781 |
+
label = d
|
| 782 |
+
else:
|
| 783 |
+
suffix = chr(ord('A') + date_seen[d])
|
| 784 |
+
label = f'{d} ({suffix})'
|
| 785 |
+
date_seen[d] += 1
|
| 786 |
+
labels_by_rid[r['id']] = label
|
| 787 |
+
dates.append(label)
|
| 788 |
+
pct_by_date[label] = r['pct_sa']
|
| 789 |
+
|
| 790 |
+
if not reports:
|
| 791 |
+
conn.close()
|
| 792 |
+
return [], [], {}, {}
|
| 793 |
+
|
| 794 |
+
rep_ids = [r['id'] for r in reports]
|
| 795 |
+
placeholders = ','.join('?' * len(rep_ids))
|
| 796 |
+
ab_rows = conn.execute(f'''
|
| 797 |
+
SELECT report_id, antigen, allele, max_mfi, mean_mfi, no_of_beads
|
| 798 |
+
FROM dsa_antibody_strength
|
| 799 |
+
WHERE report_id IN ({placeholders})
|
| 800 |
+
ORDER BY antigen
|
| 801 |
+
''', rep_ids).fetchall()
|
| 802 |
+
conn.close()
|
| 803 |
+
|
| 804 |
+
import re as _re
|
| 805 |
+
parsed = {}
|
| 806 |
+
for r in reports:
|
| 807 |
+
spec = r['specificity'] or ''
|
| 808 |
+
sa_map = {}
|
| 809 |
+
bare = set()
|
| 810 |
+
for mt in _re.finditer(r'([A-Za-z][A-Za-z0-9]*)\(([^)]*)\)', spec):
|
| 811 |
+
s = mt.group(1).strip()
|
| 812 |
+
for a in mt.group(2).split():
|
| 813 |
+
a = a.strip()
|
| 814 |
+
if a:
|
| 815 |
+
sa_map.setdefault(s, set()).add(a)
|
| 816 |
+
no_parens = _re.sub(r'\([^)]*\)', ' ', spec)
|
| 817 |
+
for tok in no_parens.split():
|
| 818 |
+
tok = tok.strip()
|
| 819 |
+
if '*' in tok and ':' in tok:
|
| 820 |
+
bare.add(tok)
|
| 821 |
+
parsed[r['id']] = {'sero_alleles': sa_map, 'bare': bare}
|
| 822 |
+
|
| 823 |
+
allele_to_sero = {}
|
| 824 |
+
for p in parsed.values():
|
| 825 |
+
for s, alleles in p['sero_alleles'].items():
|
| 826 |
+
for a in alleles:
|
| 827 |
+
allele_to_sero[a] = s
|
| 828 |
+
|
| 829 |
+
ab_map = {}
|
| 830 |
+
for ab in ab_rows:
|
| 831 |
+
ab_map[(ab['report_id'], ab['antigen'])] = dict(ab)
|
| 832 |
+
|
| 833 |
+
all_alleles = set()
|
| 834 |
+
for p in parsed.values():
|
| 835 |
+
for alleles in p['sero_alleles'].values():
|
| 836 |
+
all_alleles.update(alleles)
|
| 837 |
+
all_alleles.update(p['bare'])
|
| 838 |
+
|
| 839 |
+
def sk(a):
|
| 840 |
+
order = {'A': 0, 'B': 1, 'C': 2, 'Cw': 2,
|
| 841 |
+
'DR': 3, 'DRB1': 3, 'DRB3': 3, 'DRB4': 3, 'DRB5': 3,
|
| 842 |
+
'DQ': 4, 'DQB1': 4, 'DQA1': 5,
|
| 843 |
+
'DP': 6, 'DPB1': 6, 'DPA1': 7}
|
| 844 |
+
loc = a.split('*')[0] if '*' in a else (_re.match(r'[A-Za-z]+', a) or _re.match(r'.', a)).group()
|
| 845 |
+
nums = _re.findall(r'\d+', a)
|
| 846 |
+
n1 = int(nums[0]) if nums else 0
|
| 847 |
+
n2 = int(nums[1]) if len(nums) > 1 else 0
|
| 848 |
+
return (order.get(loc, 99), n1, n2, a)
|
| 849 |
+
|
| 850 |
+
out = []
|
| 851 |
+
for allele in sorted(all_alleles, key=sk):
|
| 852 |
+
sero = allele_to_sero.get(allele)
|
| 853 |
+
mfi_by_date = {}
|
| 854 |
+
for r in reports:
|
| 855 |
+
rid = r['id']
|
| 856 |
+
lbl = labels_by_rid[rid]
|
| 857 |
+
p = parsed[rid]
|
| 858 |
+
present = allele in p['sero_alleles'].get(sero, set()) if sero else allele in p['bare']
|
| 859 |
+
if not present:
|
| 860 |
+
continue
|
| 861 |
+
ab = ab_map.get((rid, sero)) if sero else None
|
| 862 |
+
if not ab:
|
| 863 |
+
ab = ab_map.get((rid, allele))
|
| 864 |
+
if ab:
|
| 865 |
+
mfi_by_date[lbl] = {
|
| 866 |
+
'max_mfi': ab['max_mfi'],
|
| 867 |
+
'mean_mfi': ab['mean_mfi'],
|
| 868 |
+
'no_of_beads': ab['no_of_beads'],
|
| 869 |
+
}
|
| 870 |
+
if not mfi_by_date:
|
| 871 |
+
continue
|
| 872 |
+
out.append({'antigen': sero if sero else allele, 'allele': allele,
|
| 873 |
+
'mfi_by_date': mfi_by_date})
|
| 874 |
+
return dates, out, pct_by_date, labels_by_rid
|
| 875 |
+
|
| 876 |
+
|
| 877 |
+
def delete_dsa_report(report_id):
|
| 878 |
+
backup_db()
|
| 879 |
+
conn = get_conn()
|
| 880 |
+
row = conn.execute('SELECT upload_file FROM dsa_reports WHERE id=?', (report_id,)).fetchone()
|
| 881 |
+
upload_file = row['upload_file'] if row and row['upload_file'] else ''
|
| 882 |
+
conn.execute("""UPDATE dsa_reports SET is_deleted=1, deleted_at=datetime('now','localtime'),
|
| 883 |
+
updated_at=datetime('now','localtime') WHERE id=?""", (report_id,))
|
| 884 |
+
conn.commit()
|
| 885 |
+
if upload_file:
|
| 886 |
+
still_used = conn.execute(
|
| 887 |
+
'SELECT COUNT(*) FROM dsa_reports WHERE upload_file=? AND id!=? AND COALESCE(is_deleted,0)=0',
|
| 888 |
+
(upload_file, report_id)
|
| 889 |
+
).fetchone()[0]
|
| 890 |
+
# Also check PRA reports
|
| 891 |
+
still_used += conn.execute(
|
| 892 |
+
'SELECT COUNT(*) FROM reports WHERE upload_file=? AND COALESCE(is_deleted,0)=0',
|
| 893 |
+
(upload_file,)
|
| 894 |
+
).fetchone()[0]
|
| 895 |
+
conn.close()
|
| 896 |
+
if still_used == 0:
|
| 897 |
+
delete_upload(upload_file)
|
| 898 |
+
else:
|
| 899 |
+
conn.close()
|
| 900 |
+
schedule_auto_push()
|
| 901 |
+
|
| 902 |
+
|
| 903 |
def delete_report(report_id):
|
| 904 |
"""軟刪除報告(標記 is_deleted=1,不真的刪)。
|
| 905 |
若 upload_file 不再被任何 active report 引用,同步刪除 uploads/ 下的檔案。"""
|