File size: 19,968 Bytes
d70361b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
"""Periodic automatic detection for identified same-area pairs.

Manual Change Detection is unchanged (user clicks Run). Automatic mode also
keeps Run-now; this module queues the same identified Before/After pairs on a
cadence (default 10 days) and saves reports without anyone opening the UI.

The FastAPI process must stay running for the schedule to fire.
"""
from __future__ import annotations

import hashlib
import json
import logging
import os
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Optional

from sqlalchemy.orm import Session

from ..database import SessionLocal
from ..models import User
from .models import AutoDetectSettings, AutoPairSchedule, DetectionJob

logger = logging.getLogger(__name__)

AUTO_SOURCE = "auto_schedule"

_scheduler_lock = threading.Lock()
_tick_lock = threading.Lock()
_scheduler_started = False


def _utcnow() -> datetime:
    return datetime.now(timezone.utc)


def _aware(dt: Optional[datetime]) -> Optional[datetime]:
    if dt is None:
        return None
    if dt.tzinfo is None:
        return dt.replace(tzinfo=timezone.utc)
    return dt


_IST = timezone(timedelta(hours=5, minutes=30))


def parse_run_at(value: str) -> str:
    raw = (value or "02:00").strip()
    try:
        parts = raw.split(":")
        hour = int(parts[0])
        minute = int(parts[1]) if len(parts) > 1 else 0
    except (TypeError, ValueError):
        hour, minute = 2, 0
    hour = max(0, min(23, hour))
    minute = max(0, min(59, minute))
    return f"{hour:02d}:{minute:02d}"


def clamp_interval_days(value: Any, default: int = 10) -> int:
    try:
        days = int(value)
    except (TypeError, ValueError):
        days = default
    return max(1, min(365, days))


def get_or_create_settings(db: Session) -> AutoDetectSettings:
    row = db.query(AutoDetectSettings).filter(AutoDetectSettings.id == 1).first()
    if row:
        return row
    row = AutoDetectSettings(
        id=1,
        running=False,
        interval_days=clamp_interval_days(os.environ.get("AUTO_DETECT_INTERVAL_DAYS", "10")),
        run_at=parse_run_at(os.environ.get("AUTO_DETECT_RUN_AT", "02:00")),
        next_run_at=None,
    )
    db.add(row)
    db.commit()
    db.refresh(row)
    return row


def scheduler_is_armed() -> bool:
    from .config import IS_DDA_MODE
    if not IS_DDA_MODE:
        return False
    raw = os.environ.get("AUTO_DETECT_ENABLED", "1").strip().lower()
    if raw in ("0", "false", "no", "off"):
        return False
    db = SessionLocal()
    try:
        return bool(get_or_create_settings(db).running)
    except Exception:
        return False
    finally:
        db.close()


def auto_detect_enabled() -> bool:
    """Feature exists in DDA mode unless explicitly disabled in env.

    The background *queue* only runs when the operator clicks Start
    (``scheduler_is_armed``). This flag only means the Automatic tab controls
    are available.
    """
    from .config import IS_DDA_MODE
    if not IS_DDA_MODE:
        return False
    raw = os.environ.get("AUTO_DETECT_ENABLED", "1").strip().lower()
    return raw not in ("0", "false", "no", "off")


def get_auto_detect_interval_days() -> int:
    db = SessionLocal()
    try:
        return clamp_interval_days(get_or_create_settings(db).interval_days)
    except Exception:
        return clamp_interval_days(os.environ.get("AUTO_DETECT_INTERVAL_DAYS", "10"))
    finally:
        db.close()


def next_run_at_from_clock(run_at: str, now: Optional[datetime] = None) -> datetime:
    """Next future IST clock time for HH:MM. Never returns 'now' (no launch burst)."""
    ist_now = (now or datetime.now(_IST)).astimezone(_IST)
    hhmm = parse_run_at(run_at)
    hour, minute = (int(p) for p in hhmm.split(":"))
    candidate = ist_now.replace(hour=hour, minute=minute, second=0, microsecond=0)
    if candidate <= ist_now + timedelta(seconds=45):
        candidate += timedelta(days=1)
    return candidate.astimezone(timezone.utc)


def cancel_queued_auto_jobs(db: Session, reason: str) -> int:
    cancelled = 0
    pending = (
        db.query(DetectionJob)
        .filter(DetectionJob.status == "queued")
        .all()
    )
    for job in pending:
        if not is_auto_schedule_job(job):
            continue
        job.status = "failed"
        job.error_message = reason[:2000]
        job.completed_at = _utcnow()
        cancelled += 1
    if cancelled:
        db.commit()
        logger.info("Cancelled %d queued auto-detect job(s)", cancelled)
    return cancelled


def pair_key_for(before_path: str, after_path: str) -> str:
    a = (before_path or "").replace("\\", "/").strip().lower()
    b = (after_path or "").replace("\\", "/").strip().lower()
    return hashlib.sha256(f"{a}|{b}".encode("utf-8")).hexdigest()


def job_source(job: DetectionJob) -> str:
    try:
        return str(json.loads(job.params_json or "{}").get("source") or "")
    except json.JSONDecodeError:
        return ""


def is_auto_schedule_job(job: Optional[DetectionJob]) -> bool:
    return bool(job) and job_source(job) == AUTO_SOURCE


def is_auto_schedule_run(db: Session, run_id: int) -> bool:
    if not run_id:
        return False
    job = (
        db.query(DetectionJob)
        .filter(DetectionJob.run_id == run_id)
        .order_by(DetectionJob.id.desc())
        .first()
    )
    return is_auto_schedule_job(job)


def user_can_access_run(db: Session, run_id: int, user_id: int) -> bool:
    from ..models import DetectionRun
    run = db.query(DetectionRun).filter(DetectionRun.id == run_id).first()
    if not run:
        return False
    if run.user_id == user_id:
        return True
    return is_auto_schedule_run(db, run_id)


def _norm_path(path: str) -> str:
    return (path or "").replace("\\", "/").strip()


def _paths_match(job: DetectionJob, before: str, after: str) -> bool:
    try:
        params = json.loads(job.params_json or "{}")
    except json.JSONDecodeError:
        return False
    return (
        _norm_path(params.get("base_path", "")) == _norm_path(before)
        and _norm_path(params.get("comparison_path", "")) == _norm_path(after)
    )


def _schedule_owner_id(db: Session) -> int:
    admin = (
        db.query(User)
        .filter(User.role == "admin")
        .order_by(User.id.asc())
        .first()
    )
    if admin:
        return admin.id
    user = (
        db.query(User)
        .filter(User.email.notlike("__guest__%"))
        .order_by(User.id.asc())
        .first()
    )
    if user:
        return user.id
    from ..auth import get_or_create_guest_user
    return get_or_create_guest_user(db).id


def _load_identified_pairs(db: Session) -> list[dict[str, Any]]:
    from .tree.area_groups import load_cached_area_groups

    payload = load_cached_area_groups(db)
    pairs = []
    for group in payload.get("groups") or []:
        before = group.get("beforePath") or (group.get("suggestedBefore") or {}).get("path")
        after = group.get("afterPath") or (group.get("suggestedAfter") or {}).get("path")
        if not before or not after or _norm_path(before) == _norm_path(after):
            continue
        pairs.append({
            "group_id": group.get("id") or "",
            "label": group.get("label") or Path(before).name,
            "before_path": _norm_path(before),
            "after_path": _norm_path(after),
            "before_date": group.get("beforeDate"),
            "after_date": group.get("afterDate"),
        })
    return pairs


def _sync_schedules(db: Session, pairs: list[dict[str, Any]]) -> None:
    interval = get_auto_detect_interval_days()
    live_keys = set()
    now = _utcnow()
    for pair in pairs:
        key = pair_key_for(pair["before_path"], pair["after_path"])
        live_keys.add(key)
        row = db.query(AutoPairSchedule).filter(AutoPairSchedule.pair_key == key).first()
        if row:
            row.group_label = pair["label"]
            row.before_path = pair["before_path"]
            row.after_path = pair["after_path"]
            row.interval_days = interval
            row.enabled = True
            row.updated_at = now
        else:
            db.add(AutoPairSchedule(
                pair_key=key,
                group_label=pair["label"],
                before_path=pair["before_path"],
                after_path=pair["after_path"],
                interval_days=interval,
                enabled=True,
            ))
    stale = (
        db.query(AutoPairSchedule)
        .filter(AutoPairSchedule.enabled.is_(True))
        .all()
    )
    for row in stale:
        if row.pair_key not in live_keys:
            row.enabled = False
            row.updated_at = now
    db.commit()


def _pending_job_for_paths(db: Session, before: str, after: str) -> Optional[DetectionJob]:
    pending = (
        db.query(DetectionJob)
        .filter(DetectionJob.status.in_(("queued", "running")))
        .order_by(DetectionJob.id.desc())
        .limit(80)
        .all()
    )
    for job in pending:
        if _paths_match(job, before, after):
            return job
    return None


def _latest_success_for_paths(db: Session, before: str, after: str) -> Optional[datetime]:
    done = (
        db.query(DetectionJob)
        .filter(DetectionJob.status == "completed", DetectionJob.completed_at.isnot(None))
        .order_by(DetectionJob.completed_at.desc())
        .limit(80)
        .all()
    )
    for job in done:
        if _paths_match(job, before, after):
            return _aware(job.completed_at)
    return None


def _is_due(row: AutoPairSchedule, last_success: Optional[datetime], now: datetime) -> bool:
    interval = timedelta(days=max(1, int(row.interval_days or get_auto_detect_interval_days())))
    last = _aware(row.last_completed_at) or last_success
    if last and now < last + interval:
        return False
    last_enq = _aware(row.last_enqueued_at)
    if row.last_error and last_enq and now < last_enq + timedelta(hours=12):
        return False
    if last_enq and last and now < last_enq + timedelta(minutes=30):
        return False
    return True


def _enqueue_pair(db: Session, row: AutoPairSchedule) -> Optional[DetectionJob]:
    from .job_runner import create_local_folder_job
    from .local_routes import safe_resolve

    try:
        safe_resolve(row.before_path)
        safe_resolve(row.after_path)
    except Exception as exc:
        row.last_error = f"Library path missing: {exc}"[:2000]
        db.commit()
        logger.warning("Auto-detect skip %s: %s", row.group_label, exc)
        return None

    title = f"[Auto] {row.group_label}: {Path(row.before_path).name} vs {Path(row.after_path).name}"
    job = create_local_folder_job(
        db,
        base_path=row.before_path,
        comparison_path=row.after_path,
        title=title,
        created_by=_schedule_owner_id(db),
        extra_params={
            "source": AUTO_SOURCE,
            "pair_key": row.pair_key,
            "group_label": row.group_label,
        },
    )
    row.last_enqueued_at = _utcnow()
    row.last_job_id = job.id
    row.last_error = ""
    db.commit()
    logger.info(
        "Auto-detect queued job %d for %s (%s → %s)",
        job.id, row.group_label, Path(row.before_path).name, Path(row.after_path).name,
    )
    return job


def tick_auto_detect() -> int:
    """Sync identified pairs and enqueue those that are due. Returns jobs queued."""
    if not scheduler_is_armed():
        return 0
    if not _tick_lock.acquire(blocking=False):
        return 0
    queued = 0
    db = SessionLocal()
    try:
        pairs = _load_identified_pairs(db)
        _sync_schedules(db, pairs)
        now = _utcnow()
        rows = (
            db.query(AutoPairSchedule)
            .filter(AutoPairSchedule.enabled.is_(True))
            .order_by(AutoPairSchedule.id.asc())
            .all()
        )
        for row in rows:
            if _pending_job_for_paths(db, row.before_path, row.after_path):
                continue
            last_success = _latest_success_for_paths(db, row.before_path, row.after_path)
            if last_success and (
                row.last_completed_at is None or _aware(row.last_completed_at) < last_success
            ):
                row.last_completed_at = last_success
                db.commit()
            if not _is_due(row, last_success, now):
                continue
            if _enqueue_pair(db, row):
                queued += 1
        if queued:
            logger.info("Auto-detect tick queued %d pair(s)", queued)
    except Exception:
        logger.exception("Auto-detect tick failed")
        db.rollback()
        queued = 0
    finally:
        db.close()
        _tick_lock.release()
    if queued:
        from .job_runner import start_next_queued_job
        start_next_queued_job()
    return queued


def mark_schedule_from_job(job_id: int, *, status: str, run_id: Optional[int], error: str = "") -> None:
    db = SessionLocal()
    try:
        job = db.query(DetectionJob).filter(DetectionJob.id == job_id).first()
        if not is_auto_schedule_job(job):
            # Manual success for the same paths still postpones the next auto run.
            if status == "completed" and job:
                try:
                    params = json.loads(job.params_json or "{}")
                except json.JSONDecodeError:
                    return
                key = pair_key_for(params.get("base_path", ""), params.get("comparison_path", ""))
                row = db.query(AutoPairSchedule).filter(AutoPairSchedule.pair_key == key).first()
                if row:
                    row.last_completed_at = _utcnow()
                    row.last_job_id = job_id
                    if run_id:
                        row.last_run_id = run_id
                    row.last_error = ""
                    db.commit()
            return
        try:
            params = json.loads(job.params_json or "{}")
        except json.JSONDecodeError:
            params = {}
        key = params.get("pair_key") or pair_key_for(
            params.get("base_path", ""), params.get("comparison_path", ""))
        row = db.query(AutoPairSchedule).filter(AutoPairSchedule.pair_key == key).first()
        if not row:
            return
        row.last_job_id = job_id
        if status == "completed":
            row.last_completed_at = _utcnow()
            row.last_run_id = run_id
            row.last_error = ""
        else:
            row.last_error = (error or "")[:2000]
        db.commit()
    except Exception:
        logger.exception("Could not update auto-detect schedule for job %s", job_id)
        db.rollback()
    finally:
        db.close()


def refresh_schedule_rows() -> None:
    """Create/update schedule rows from identified pairs without enqueueing jobs."""
    db = SessionLocal()
    try:
        pairs = _load_identified_pairs(db)
        _sync_schedules(db, pairs)
    except Exception:
        logger.exception("Auto-detect schedule sync failed")
        db.rollback()
    finally:
        db.close()


def schedule_status_payload(db: Session) -> dict:
    settings = get_or_create_settings(db)
    interval = clamp_interval_days(settings.interval_days)
    feature_on = auto_detect_enabled()
    armed = bool(settings.running) and feature_on
    next_run = _aware(settings.next_run_at)
    rows = db.query(AutoPairSchedule).order_by(AutoPairSchedule.group_label.asc()).all()
    out = []
    for row in rows:
        last = _aware(row.last_completed_at)
        job = db.query(DetectionJob).filter(DetectionJob.id == row.last_job_id).first() if row.last_job_id else None
        out.append({
            "pairKey": row.pair_key,
            "label": row.group_label,
            "beforePath": row.before_path,
            "afterPath": row.after_path,
            "enabled": bool(row.enabled) and armed,
            "intervalDays": interval,
            "lastCompletedAt": last.isoformat() if last else None,
            "nextDueAt": next_run.isoformat() if next_run and armed else None,
            "dueNow": False,
            "lastJobId": row.last_job_id,
            "lastRunId": row.last_run_id,
            "lastJobStatus": job.status if job else None,
            "lastError": row.last_error or "",
        })
    return {
        "enabled": feature_on,
        "running": armed,
        "intervalDays": interval,
        "runAt": parse_run_at(settings.run_at),
        "nextRunAt": next_run.isoformat() if next_run and armed else None,
        "pairs": out,
    }


def start_schedule(*, interval_days: int, run_at: str) -> dict:
    if not auto_detect_enabled():
        raise ValueError("Automatic detection is disabled on the server.")
    db = SessionLocal()
    try:
        refresh_schedule_rows()
        settings = get_or_create_settings(db)
        settings.interval_days = clamp_interval_days(interval_days)
        settings.run_at = parse_run_at(run_at)
        settings.running = True
        settings.next_run_at = next_run_at_from_clock(settings.run_at)
        settings.updated_at = _utcnow()
        db.commit()
        payload = schedule_status_payload(db)
        logger.info(
            "Auto-detect started: interval=%d day(s) at %s IST; first queue %s (not now)",
            settings.interval_days, settings.run_at, settings.next_run_at,
        )
        return payload
    finally:
        db.close()


def stop_schedule() -> dict:
    db = SessionLocal()
    try:
        settings = get_or_create_settings(db)
        settings.running = False
        settings.next_run_at = None
        settings.updated_at = _utcnow()
        db.commit()
        cancel_queued_auto_jobs(
            db,
            "Cancelled because automatic schedule was stopped.",
        )
        logger.info("Auto-detect stopped; queued auto jobs cancelled")
        return schedule_status_payload(db)
    finally:
        db.close()


def _maybe_fire_scheduled_tick() -> None:
    if not scheduler_is_armed():
        return
    db = SessionLocal()
    try:
        settings = get_or_create_settings(db)
        due_at = _aware(settings.next_run_at)
        if not due_at or _utcnow() < due_at:
            return
        slot = due_at
        settings.next_run_at = slot + timedelta(days=clamp_interval_days(settings.interval_days))
        db.commit()
    except Exception:
        logger.exception("Auto-detect schedule clock failed")
        db.rollback()
        return
    finally:
        db.close()
    tick_auto_detect()


def _scheduler_loop() -> None:
    while True:
        threading.Event().wait(20)
        try:
            _maybe_fire_scheduled_tick()
        except Exception:
            logger.exception("Auto-detect waiter failed")


def reset_schedule_on_process_start() -> None:
    """Disarm the queue and cancel leftover auto jobs. Safe to call twice."""
    db = SessionLocal()
    try:
        settings = get_or_create_settings(db)
        settings.running = False
        settings.next_run_at = None
        db.commit()
        cancel_queued_auto_jobs(
            db,
            "Cancelled on server start. Automatic detection does not run until you click Start.",
        )
    except Exception:
        logger.exception("Could not reset auto-detect on launch")
        db.rollback()
    finally:
        db.close()


def start_auto_detect_scheduler() -> None:
    """Start the idle waiter. Never enqueues jobs on launch."""
    global _scheduler_started
    reset_schedule_on_process_start()

    if not auto_detect_enabled():
        logger.info("Automatic pair detection feature disabled")
        return
    with _scheduler_lock:
        if _scheduler_started:
            return
        _scheduler_started = True
    logger.info(
        "Automatic detection is idle. Set interval/time on the Automatic tab and click Start."
    )
    threading.Thread(target=_scheduler_loop, daemon=True, name="dda-auto-detect").start()