File size: 6,819 Bytes
41d16b3
 
 
 
 
 
214c544
ac7ea7c
66006d5
41d16b3
4595db8
70d9e6c
669f7f8
ac7ea7c
214c544
ac7ea7c
41d16b3
 
 
 
99e1f27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41d16b3
 
 
 
 
214c544
41d16b3
 
bec7397
 
 
 
 
 
 
 
 
41d16b3
bec7397
 
 
 
41d16b3
 
 
99e1f27
41d16b3
bec7397
41d16b3
214c544
 
 
 
 
 
 
 
 
 
 
41d16b3
 
ac7ea7c
214c544
 
 
99e1f27
 
 
 
 
 
bec7397
 
41d16b3
 
 
8354653
214c544
 
 
 
 
 
 
8354653
214c544
8354653
214c544
 
8354653
 
214c544
8354653
41d16b3
 
 
 
 
214c544
41d16b3
66006d5
70d9e6c
669f7f8
ac7ea7c
 
4595db8
214c544
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
import logging

from fastapi import FastAPI
from sqlalchemy import text as sa_text

from ..database import engine
from .config import IS_DDA_MODE, ensure_library_dirs, get_storage_root, is_hf_hosted
from .admin_routes import router as admin_router
from .jobs_routes import router as jobs_router
from .library_routes import router as library_router
from .local_routes import router as local_router
from .reports_routes import router as reports_router
from .review_routes import router as review_router
from .training_routes import router as training_router
from .tree.routes import router as tree_router
from .dda_auth import seed_dda_admin

logger = logging.getLogger(__name__)


def _migrate_detection_jobs_nullable() -> None:
    """SQLite legacy schema required image asset FKs; tree library jobs use paths only."""
    try:
        with engine.connect() as conn:
            rows = conn.execute(sa_text("PRAGMA table_info(dda_detection_jobs)")).fetchall()
            if not rows:
                return
            by_name = {r[1]: r for r in rows}
            base_col = by_name.get("base_image_id")
            if not base_col or base_col[3] == 0:
                return
            logger.info("Migrating dda_detection_jobs to allow NULL image IDs for path-based jobs")
            conn.execute(sa_text("PRAGMA foreign_keys=OFF"))
            conn.execute(sa_text("""
                CREATE TABLE dda_detection_jobs_new (
                    id INTEGER NOT NULL PRIMARY KEY,
                    status VARCHAR(32),
                    base_image_id INTEGER,
                    comparison_image_id INTEGER,
                    method VARCHAR(64),
                    params_json TEXT,
                    run_id INTEGER,
                    error_message TEXT,
                    notify_email VARCHAR(255),
                    created_by INTEGER,
                    started_at DATETIME,
                    completed_at DATETIME,
                    created_at DATETIME,
                    FOREIGN KEY(base_image_id) REFERENCES dda_image_assets (id),
                    FOREIGN KEY(comparison_image_id) REFERENCES dda_image_assets (id),
                    FOREIGN KEY(run_id) REFERENCES detection_runs (id),
                    FOREIGN KEY(created_by) REFERENCES users (id)
                )
            """))
            conn.execute(sa_text("""
                INSERT INTO dda_detection_jobs_new (
                    id, status, base_image_id, comparison_image_id, method, params_json,
                    run_id, error_message, notify_email, created_by,
                    started_at, completed_at, created_at
                )
                SELECT
                    id, status, base_image_id, comparison_image_id, method, params_json,
                    run_id, error_message, notify_email, created_by,
                    started_at, completed_at, created_at
                FROM dda_detection_jobs
            """))
            conn.execute(sa_text("DROP TABLE dda_detection_jobs"))
            conn.execute(sa_text("ALTER TABLE dda_detection_jobs_new RENAME TO dda_detection_jobs"))
            conn.execute(sa_text("PRAGMA foreign_keys=ON"))
            conn.commit()
    except Exception as exc:
        logger.warning("Detection jobs nullable migration skipped: %s", exc)


def init_dda_database():
    """Run DDA-specific startup tasks (dirs, seed, migrations)."""
    if not IS_DDA_MODE:
        return
    ensure_library_dirs()
    get_storage_root().mkdir(parents=True, exist_ok=True)
    try:
        with engine.connect() as conn:
            for stmt in (
                "ALTER TABLE users ADD COLUMN role VARCHAR(32) DEFAULT 'analyst'",
                "ALTER TABLE detection_runs ADD COLUMN after_full_path VARCHAR(512) DEFAULT ''",
            ):
                try:
                    conn.execute(sa_text(stmt))
                    conn.commit()
                except Exception:
                    conn.rollback()
            try:
                conn.execute(sa_text(
                    "CREATE UNIQUE INDEX IF NOT EXISTS ix_dda_region_reviews_run_region "
                    "ON dda_region_reviews (run_id, region_id)"
                ))
                conn.commit()
            except Exception:
                conn.rollback()
            _migrate_detection_jobs_nullable()
    except Exception as exc:
        logger.warning("DDA schema migration skipped: %s", exc)

    from ..database import Base, SessionLocal
    from .tree.models import AuditLog, ImageLibrary, TreeNode  # noqa: F401
    try:
        Base.metadata.create_all(bind=engine, tables=[
            TreeNode.__table__,
            ImageLibrary.__table__,
            AuditLog.__table__,
        ])
    except Exception as exc:
        logger.warning("Tree table create skipped: %s", exc)

    db = SessionLocal()
    try:
        seed_dda_admin(db)
        from .tree.migration import run_tree_migration
        mig = run_tree_migration(db)
        logger.info("Tree migration: %s", mig)
        try:
            from .tree.sync_service import sync_from_filesystem
            sync_stats = sync_from_filesystem(db)
            logger.info("Filesystem sync at startup: %s", sync_stats)
        except Exception as exc:
            logger.warning("Filesystem sync at startup failed: %s", exc)
        from .job_runner import reconcile_stale_jobs
        reconcile_stale_jobs(db)
    finally:
        db.close()

    try:
        from .tree.image_service import list_all_images
        from ..database import SessionLocal as SL
        sdb = SL()
        try:
            total = len(list_all_images(sdb))
        finally:
            sdb.close()
        logger.info(
            "DDA tree library ready (hosted=%s): %d images, storage=%s",
            is_hf_hosted(),
            total,
            get_storage_root(),
        )
    except Exception as exc:
        logger.warning("Tree library scan at startup failed: %s", exc)


def setup_dda(app: FastAPI) -> None:
    if not IS_DDA_MODE:
        logger.info("APP_MODE=legacy — DDA routes disabled")
        return
    app.include_router(tree_router, prefix="/api/dda", tags=["dda-tree"])
    app.include_router(library_router, prefix="/api/dda", tags=["dda"])
    app.include_router(jobs_router, prefix="/api/dda", tags=["dda-jobs"])
    app.include_router(reports_router, prefix="/api/dda", tags=["dda-reports"])
    app.include_router(review_router, prefix="/api/dda", tags=["dda-review"])
    app.include_router(training_router, prefix="/api/dda", tags=["dda-training"])
    app.include_router(admin_router, prefix="/api/dda", tags=["dda-admin"])
    app.include_router(local_router, prefix="/api/dda", tags=["dda-local"])
    logger.info("APP_MODE=dda — DDA routes enabled (tree, library, jobs, reports, review, training, admin, local)")