coderuday21 commited on
Commit
19f4fba
·
1 Parent(s): a068757

Add email notification feature with Notify checkbox, SMTP backend, and HTML template

Browse files
app/main.py CHANGED
@@ -27,6 +27,7 @@ from .auth import (
27
  from .database import Base, engine, get_db, DATA_DIR
28
  from .models import User, DetectionRun
29
  from .detection_engine import run_detection
 
30
 
31
  Base.metadata.create_all(bind=engine, checkfirst=True)
32
 
@@ -175,6 +176,7 @@ async def detect(
175
  village: str = Form(""),
176
  enable_registration: bool = Form(True),
177
  enable_normalization: bool = Form(True),
 
178
  access_token: Optional[str] = Form(None),
179
  db: Session = Depends(get_db),
180
  ):
@@ -252,6 +254,22 @@ async def detect(
252
  Image.fromarray(result_image).save(buf, format="PNG")
253
  buf.seek(0)
254
  overlay_b64 = base64.b64encode(buf.read()).decode("utf-8")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  return {
256
  "id": run.id,
257
  "title": run.title,
@@ -267,6 +285,7 @@ async def detect(
267
  "regions": regions_serializable,
268
  "overlayBase64Png": overlay_b64,
269
  "overlayUrl": f"/api/overlay/{relative_overlay}",
 
270
  "createdAt": run.created_at.isoformat(),
271
  }
272
 
 
27
  from .database import Base, engine, get_db, DATA_DIR
28
  from .models import User, DetectionRun
29
  from .detection_engine import run_detection
30
+ from .notifier import send_notification
31
 
32
  Base.metadata.create_all(bind=engine, checkfirst=True)
33
 
 
176
  village: str = Form(""),
177
  enable_registration: bool = Form(True),
178
  enable_normalization: bool = Form(True),
179
+ notify_email: Optional[str] = Form(None),
180
  access_token: Optional[str] = Form(None),
181
  db: Session = Depends(get_db),
182
  ):
 
254
  Image.fromarray(result_image).save(buf, format="PNG")
255
  buf.seek(0)
256
  overlay_b64 = base64.b64encode(buf.read()).decode("utf-8")
257
+
258
+ # Send email notification if requested
259
+ notification_sent = False
260
+ if notify_email and notify_email.strip():
261
+ notification_sent = send_notification(
262
+ recipient=notify_email.strip(),
263
+ title=title,
264
+ method=method,
265
+ zone=zone,
266
+ village=village,
267
+ change_pct=change_pct,
268
+ changed_px=changed_px,
269
+ total_px=total_px,
270
+ regions=regions_serializable,
271
+ )
272
+
273
  return {
274
  "id": run.id,
275
  "title": run.title,
 
285
  "regions": regions_serializable,
286
  "overlayBase64Png": overlay_b64,
287
  "overlayUrl": f"/api/overlay/{relative_overlay}",
288
+ "notificationSent": notification_sent,
289
  "createdAt": run.created_at.isoformat(),
290
  }
291
 
app/notifier.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Email notification module.
3
+ Sends HTML-formatted detection reports via SMTP SSL.
4
+ Credentials are read from environment variables — never hardcoded.
5
+ """
6
+ import logging
7
+ import os
8
+ import smtplib
9
+ import ssl
10
+ from datetime import datetime, timezone
11
+ from email.mime.multipart import MIMEMultipart
12
+ from email.mime.text import MIMEText
13
+ from pathlib import Path
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ SMTP_HOST = os.environ.get("SMTP_HOST", "smtp.gmail.com")
18
+ SMTP_PORT = int(os.environ.get("SMTP_PORT", "465"))
19
+ SMTP_USER = os.environ.get("SMTP_USER", "vedangofficeserver@gmail.com")
20
+ SMTP_PASS = os.environ.get("SMTP_PASS", "")
21
+
22
+ TEMPLATE_PATH = Path(__file__).resolve().parent.parent / "templates" / "ChangeDetection.html"
23
+
24
+
25
+ def _load_template() -> str:
26
+ """Read the HTML email template from disk."""
27
+ if not TEMPLATE_PATH.exists():
28
+ logger.error("Email template not found at %s", TEMPLATE_PATH)
29
+ return "<p>Change Detection report — template file missing.</p>"
30
+ return TEMPLATE_PATH.read_text(encoding="utf-8")
31
+
32
+
33
+ def _build_region_rows(regions: list) -> str:
34
+ """Render region rows as HTML <tr> elements for the email template."""
35
+ if not regions:
36
+ return ""
37
+ rows = []
38
+ for r in regions[:20]:
39
+ bg = "#f9f9fb" if r.get("id", 0) % 2 == 0 else "#ffffff"
40
+ sub = r.get("subType") or "—"
41
+ conf = f'{r.get("confidence", 0) * 100:.0f}%'
42
+ area = f'{r.get("area", 0):,}'
43
+ rows.append(
44
+ f'<tr style="background:{bg};">'
45
+ f'<td style="padding:6px 10px;">{r.get("id","")}</td>'
46
+ f'<td style="padding:6px 10px;">{r.get("objectType","")}</td>'
47
+ f'<td style="padding:6px 10px;">{sub}</td>'
48
+ f'<td style="padding:6px 10px;">{conf}</td>'
49
+ f'<td style="padding:6px 10px; text-align:right;">{area}</td>'
50
+ f"</tr>"
51
+ )
52
+ return "\n".join(rows)
53
+
54
+
55
+ def build_email_body(
56
+ title: str,
57
+ method: str,
58
+ zone: str,
59
+ village: str,
60
+ change_pct: float,
61
+ changed_px: int,
62
+ total_px: int,
63
+ regions: list,
64
+ ) -> str:
65
+ """Populate the HTML template with detection results."""
66
+ html = _load_template()
67
+ location = ", ".join(filter(None, [village, zone])) or "—"
68
+ region_rows = _build_region_rows(regions)
69
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
70
+
71
+ replacements = {
72
+ "{{title}}": title or "Untitled run",
73
+ "{{method}}": method or "—",
74
+ "{{location}}": location,
75
+ "{{change_pct}}": f"{change_pct:.2f}",
76
+ "{{changed_px}}": f"{changed_px:,}",
77
+ "{{total_px}}": f"{total_px:,}",
78
+ "{{regions_count}}": str(len(regions)),
79
+ "{{region_rows}}": region_rows,
80
+ "{{timestamp}}": now,
81
+ }
82
+ for key, val in replacements.items():
83
+ html = html.replace(key, val)
84
+
85
+ # Handle conditional regions block
86
+ if regions:
87
+ html = html.replace("{{#regions}}", "").replace("{{/regions}}", "")
88
+ else:
89
+ import re
90
+ html = re.sub(r"\{\{#regions\}\}.*?\{\{/regions\}\}", "", html, flags=re.DOTALL)
91
+
92
+ return html
93
+
94
+
95
+ def send_notification(
96
+ recipient: str,
97
+ title: str,
98
+ method: str,
99
+ zone: str,
100
+ village: str,
101
+ change_pct: float,
102
+ changed_px: int,
103
+ total_px: int,
104
+ regions: list,
105
+ ) -> bool:
106
+ """
107
+ Send detection report email to the recipient.
108
+ Returns True on success, False on failure (never raises).
109
+ """
110
+ if not SMTP_PASS:
111
+ logger.warning("SMTP_PASS not set — skipping email notification")
112
+ return False
113
+
114
+ html_body = build_email_body(
115
+ title, method, zone, village, change_pct, changed_px, total_px, regions
116
+ )
117
+
118
+ msg = MIMEMultipart("alternative")
119
+ msg["Subject"] = f"Change Detection Report — {title or 'Untitled run'}"
120
+ msg["From"] = SMTP_USER
121
+ msg["To"] = recipient
122
+ msg.attach(MIMEText(html_body, "html", "utf-8"))
123
+
124
+ try:
125
+ context = ssl.create_default_context()
126
+ with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, context=context) as server:
127
+ server.login(SMTP_USER, SMTP_PASS)
128
+ server.sendmail(SMTP_USER, recipient, msg.as_string())
129
+ logger.info("Notification email sent to %s", recipient)
130
+ return True
131
+ except Exception as e:
132
+ logger.error("Failed to send notification email: %s", e)
133
+ return False
static/css/style.css CHANGED
@@ -436,6 +436,40 @@ input:focus, select:focus, textarea:focus {
436
  .location-row { grid-template-columns: 1fr; }
437
  }
438
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
  /* ---- Upload zones ---- */
440
  .upload-grid {
441
  display: grid;
 
436
  .location-row { grid-template-columns: 1fr; }
437
  }
438
 
439
+ /* ---- Notify row ---- */
440
+ .notify-row {
441
+ display: flex;
442
+ align-items: flex-end;
443
+ gap: 1rem;
444
+ margin-bottom: 1.25rem;
445
+ flex-wrap: wrap;
446
+ }
447
+ .notify-email-group {
448
+ flex: 1;
449
+ min-width: 200px;
450
+ transition: opacity 0.2s ease, transform 0.2s ease;
451
+ }
452
+ .notify-email-group.visible {
453
+ opacity: 1;
454
+ transform: translateY(0);
455
+ }
456
+ .notify-email-group label {
457
+ display: flex;
458
+ align-items: center;
459
+ gap: 0.35rem;
460
+ }
461
+ .notify-email-group label svg {
462
+ color: var(--grad-start);
463
+ flex-shrink: 0;
464
+ }
465
+ .notify-email-group input {
466
+ width: 100%;
467
+ }
468
+ @media (max-width: 640px) {
469
+ .notify-row { flex-direction: column; align-items: stretch; }
470
+ .notify-email-group { min-width: 100%; }
471
+ }
472
+
473
  /* ---- Upload zones ---- */
474
  .upload-grid {
475
  display: grid;
static/js/app.js CHANGED
@@ -273,6 +273,24 @@ const DELHI_ZONES = {
273
  });
274
  })();
275
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
276
  // ---- Run detection ----
277
  document.getElementById('form-detect')?.addEventListener('submit', async (e) => {
278
  e.preventDefault();
@@ -299,6 +317,21 @@ document.getElementById('form-detect')?.addEventListener('submit', async (e) =>
299
  form.append('village', document.getElementById('detect-village').value || '');
300
  form.append('enable_registration', document.getElementById('detect-registration').checked);
301
  form.append('enable_normalization', document.getElementById('detect-normalization').checked);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
302
  if (token) form.append('access_token', token);
303
 
304
  try {
@@ -310,7 +343,10 @@ document.getElementById('form-detect')?.addEventListener('submit', async (e) =>
310
  }
311
  const data = await api('POST', '/api/detect', { body: form });
312
  showResult(data);
313
- showSuccess('dashboard-success', 'Detection complete!');
 
 
 
314
  loadHistory();
315
  } catch (err) {
316
  showError('dashboard-error', err.message);
 
273
  });
274
  })();
275
 
276
+ // ---- Notify checkbox toggle ----
277
+ (function initNotifyToggle() {
278
+ const cb = document.getElementById('detect-notify');
279
+ const group = document.getElementById('notify-email-group');
280
+ if (!cb || !group) return;
281
+
282
+ cb.addEventListener('change', () => {
283
+ if (cb.checked) {
284
+ group.classList.remove('hidden');
285
+ group.classList.add('visible');
286
+ } else {
287
+ group.classList.remove('visible');
288
+ group.classList.add('hidden');
289
+ document.getElementById('notify-email').value = '';
290
+ }
291
+ });
292
+ })();
293
+
294
  // ---- Run detection ----
295
  document.getElementById('form-detect')?.addEventListener('submit', async (e) => {
296
  e.preventDefault();
 
317
  form.append('village', document.getElementById('detect-village').value || '');
318
  form.append('enable_registration', document.getElementById('detect-registration').checked);
319
  form.append('enable_normalization', document.getElementById('detect-normalization').checked);
320
+
321
+ // Notify: validate and attach email if checkbox is checked
322
+ const notifyCb = document.getElementById('detect-notify');
323
+ const notifyInput = document.getElementById('notify-email');
324
+ if (notifyCb?.checked) {
325
+ const email = (notifyInput?.value || '').trim();
326
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
327
+ showError('dashboard-error', 'Please enter a valid email address for notification.');
328
+ btn.disabled = false;
329
+ loading.classList.add('hidden');
330
+ return;
331
+ }
332
+ form.append('notify_email', email);
333
+ }
334
+
335
  if (token) form.append('access_token', token);
336
 
337
  try {
 
343
  }
344
  const data = await api('POST', '/api/detect', { body: form });
345
  showResult(data);
346
+ const notifyMsg = data.notificationSent
347
+ ? ' Notification email sent.'
348
+ : '';
349
+ showSuccess('dashboard-success', 'Detection complete!' + notifyMsg);
350
  loadHistory();
351
  } catch (err) {
352
  showError('dashboard-error', err.message);
templates/ChangeDetection.html ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Change Detection Report</title>
7
+ </head>
8
+ <body style="margin:0; padding:0; background:#f4f4f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;">
9
+ <table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f7; padding:40px 0;">
10
+ <tr>
11
+ <td align="center">
12
+ <table width="600" cellpadding="0" cellspacing="0" style="background:#ffffff; border-radius:12px; overflow:hidden; box-shadow:0 4px 24px rgba(0,0,0,0.08);">
13
+
14
+ <!-- Header -->
15
+ <tr>
16
+ <td style="background:linear-gradient(135deg,#2e33c5,#cf2040); padding:32px 40px; text-align:center;">
17
+ <h1 style="margin:0; color:#ffffff; font-size:22px; font-weight:700; letter-spacing:-0.02em;">
18
+ AI Change Detection
19
+ </h1>
20
+ <p style="margin:6px 0 0; color:rgba(255,255,255,0.8); font-size:13px;">
21
+ Detection Run Complete — Report Summary
22
+ </p>
23
+ </td>
24
+ </tr>
25
+
26
+ <!-- Body -->
27
+ <tr>
28
+ <td style="padding:32px 40px;">
29
+ <p style="margin:0 0 18px; color:#333; font-size:15px; line-height:1.6;">
30
+ Hello,
31
+ </p>
32
+ <p style="margin:0 0 18px; color:#333; font-size:15px; line-height:1.6;">
33
+ A change detection run has been completed. Here are the results:
34
+ </p>
35
+
36
+ <!-- Stats table -->
37
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px; border:1px solid #e8e8ec; border-radius:8px; overflow:hidden;">
38
+ <tr style="background:#f9f9fb;">
39
+ <td style="padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8ec;">Run Title</td>
40
+ <td style="padding:10px 16px; font-size:14px; color:#222; font-weight:600; border-bottom:1px solid #e8e8ec;">{{title}}</td>
41
+ </tr>
42
+ <tr>
43
+ <td style="padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8ec;">Method</td>
44
+ <td style="padding:10px 16px; font-size:14px; color:#222; border-bottom:1px solid #e8e8ec;">{{method}}</td>
45
+ </tr>
46
+ <tr style="background:#f9f9fb;">
47
+ <td style="padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8ec;">Location</td>
48
+ <td style="padding:10px 16px; font-size:14px; color:#222; border-bottom:1px solid #e8e8ec;">{{location}}</td>
49
+ </tr>
50
+ <tr>
51
+ <td style="padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8ec;">Change Percentage</td>
52
+ <td style="padding:10px 16px; font-size:14px; color:#cf2040; font-weight:700; border-bottom:1px solid #e8e8ec;">{{change_pct}}%</td>
53
+ </tr>
54
+ <tr style="background:#f9f9fb;">
55
+ <td style="padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8ec;">Changed Pixels</td>
56
+ <td style="padding:10px 16px; font-size:14px; color:#222; border-bottom:1px solid #e8e8ec;">{{changed_px}}</td>
57
+ </tr>
58
+ <tr>
59
+ <td style="padding:10px 16px; font-size:13px; color:#666; border-bottom:1px solid #e8e8ec;">Total Pixels</td>
60
+ <td style="padding:10px 16px; font-size:14px; color:#222; border-bottom:1px solid #e8e8ec;">{{total_px}}</td>
61
+ </tr>
62
+ <tr style="background:#f9f9fb;">
63
+ <td style="padding:10px 16px; font-size:13px; color:#666;">Regions Detected</td>
64
+ <td style="padding:10px 16px; font-size:14px; color:#222;">{{regions_count}}</td>
65
+ </tr>
66
+ </table>
67
+
68
+ <!-- Region details (if any) -->
69
+ {{#regions}}
70
+ <h3 style="margin:0 0 12px; font-size:14px; color:#2e33c5; font-weight:600;">Detected Regions</h3>
71
+ <table width="100%" cellpadding="0" cellspacing="0" style="margin:0 0 24px; border:1px solid #e8e8ec; border-radius:8px; overflow:hidden; font-size:12px;">
72
+ <tr style="background:#2e33c5; color:#fff;">
73
+ <th style="padding:8px 10px; text-align:left;">#</th>
74
+ <th style="padding:8px 10px; text-align:left;">Type</th>
75
+ <th style="padding:8px 10px; text-align:left;">Sub-Type</th>
76
+ <th style="padding:8px 10px; text-align:left;">Confidence</th>
77
+ <th style="padding:8px 10px; text-align:right;">Area</th>
78
+ </tr>
79
+ {{region_rows}}
80
+ </table>
81
+ {{/regions}}
82
+
83
+ <p style="margin:24px 0 0; color:#333; font-size:15px; line-height:1.6;">
84
+ You can view the full overlay image and detailed results by logging in to the application.
85
+ </p>
86
+ </td>
87
+ </tr>
88
+
89
+ <!-- Footer -->
90
+ <tr>
91
+ <td style="background:#f9f9fb; padding:20px 40px; border-top:1px solid #e8e8ec; text-align:center;">
92
+ <p style="margin:0; color:#999; font-size:12px;">
93
+ This is an automated notification from AI Change Detection.
94
+ </p>
95
+ <p style="margin:4px 0 0; color:#bbb; font-size:11px;">
96
+ {{timestamp}}
97
+ </p>
98
+ </td>
99
+ </tr>
100
+
101
+ </table>
102
+ </td>
103
+ </tr>
104
+ </table>
105
+ </body>
106
+ </html>
templates/index.html CHANGED
@@ -219,6 +219,18 @@
219
  <label><input type="checkbox" id="detect-normalization" checked /> Radiometric Normalization</label>
220
  </div>
221
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
222
  <div class="run-btn-wrap">
223
  <button type="submit" class="btn btn-primary btn-lg" id="btn-run">
224
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
@@ -300,6 +312,6 @@
300
  </div>
301
  </div>
302
 
303
- <script src="/static/js/app.js?v=14"></script>
304
  </body>
305
  </html>
 
219
  <label><input type="checkbox" id="detect-normalization" checked /> Radiometric Normalization</label>
220
  </div>
221
  </div>
222
+ <div class="notify-row">
223
+ <div class="form-group checkbox-group">
224
+ <label><input type="checkbox" id="detect-notify" /> Notify via Email</label>
225
+ </div>
226
+ <div class="form-group notify-email-group hidden" id="notify-email-group">
227
+ <label for="notify-email">
228
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
229
+ Recipient Email
230
+ </label>
231
+ <input type="email" id="notify-email" placeholder="recipient@example.com" />
232
+ </div>
233
+ </div>
234
  <div class="run-btn-wrap">
235
  <button type="submit" class="btn btn-primary btn-lg" id="btn-run">
236
  <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>
 
312
  </div>
313
  </div>
314
 
315
+ <script src="/static/js/app.js?v=15"></script>
316
  </body>
317
  </html>