AD232025 commited on
Commit
81752b4
·
verified ·
1 Parent(s): 21a8272

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +748 -753
app.py CHANGED
@@ -1,753 +1,748 @@
1
- import os
2
- import re
3
- import csv
4
- import secrets
5
- import unicodedata
6
- from datetime import datetime, timedelta
7
- from io import BytesIO
8
-
9
- from flask import (
10
- Flask,
11
- render_template,
12
- request,
13
- redirect,
14
- url_for,
15
- send_file,
16
- flash,
17
- session,
18
- abort,
19
- )
20
- from flask_sqlalchemy import SQLAlchemy
21
- from flask_login import (
22
- LoginManager,
23
- UserMixin,
24
- login_user,
25
- login_required,
26
- current_user,
27
- logout_user,
28
- )
29
- from werkzeug.security import generate_password_hash, check_password_hash
30
- from reportlab.pdfgen import canvas
31
- from reportlab.lib.pagesizes import letter
32
-
33
- from sendgrid import SendGridAPIClient
34
- from sendgrid.helpers.mail import Mail
35
-
36
- # your HF-based classifier
37
- from model import classify_tone_rich
38
-
39
-
40
- # =========================================================
41
- # APP CONFIG
42
- # =========================================================
43
-
44
- app = Flask(__name__)
45
-
46
- app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-this-in-prod")
47
- app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///data.db"
48
- app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
49
-
50
- # cookie security
51
- app.config["SESSION_COOKIE_HTTPONLY"] = True
52
- app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
53
-
54
- db = SQLAlchemy(app)
55
- login_manager = LoginManager(app)
56
- login_manager.login_view = "login"
57
-
58
- # Email (SendGrid)
59
- SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY")
60
- SENDER_EMAIL = os.getenv("SENDER_EMAIL", "no-reply@example.com")
61
-
62
- # simple email regex
63
- EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
64
-
65
- os.makedirs("exports", exist_ok=True)
66
-
67
-
68
- # =========================================================
69
- # HELPER FUNCTIONS – SANITIZATION, CSRF, PASSWORDS
70
- # =========================================================
71
-
72
- def normalize_text(value: str) -> str:
73
- if not value:
74
- return ""
75
- value = unicodedata.normalize("NFKC", value)
76
- value = value.replace("\u200b", "").replace("\u200c", "").replace("\u200d", "")
77
- return value.strip()
78
-
79
-
80
- def sanitize_string(value: str, max_len: int = 255) -> str:
81
- value = normalize_text(value)
82
- if len(value) > max_len:
83
- value = value[:max_len]
84
- return value
85
-
86
-
87
- def sanitize_long_text(value: str, max_len: int = 4000) -> str:
88
- value = normalize_text(value)
89
- if len(value) > max_len:
90
- value = value[:max_len]
91
- return value
92
-
93
-
94
- def is_valid_email(email: str) -> bool:
95
- return bool(email and EMAIL_RE.match(email))
96
-
97
-
98
- def is_strong_password(pw: str) -> bool:
99
- if not pw or len(pw) < 8:
100
- return False
101
- has_letter = any(c.isalpha() for c in pw)
102
- has_digit = any(c.isdigit() for c in pw)
103
- return has_letter and has_digit
104
-
105
-
106
- def generate_code() -> str:
107
- """6-digit numeric code used for verify + reset."""
108
- return f"{secrets.randbelow(1000000):06d}"
109
-
110
-
111
- def generate_csrf_token() -> str:
112
- token = session.get("csrf_token")
113
- if not token:
114
- token = secrets.token_hex(16)
115
- session["csrf_token"] = token
116
- return token
117
-
118
-
119
- @app.before_request
120
- def csrf_protect():
121
- # ensure CSRF token exists
122
- generate_csrf_token()
123
-
124
- if request.method == "POST":
125
- form_token = request.form.get("csrf_token", "")
126
- sess_token = session.get("csrf_token", "")
127
- if not form_token or form_token != sess_token:
128
- abort(400, description="Invalid CSRF token")
129
-
130
-
131
- @app.context_processor
132
- def inject_csrf():
133
- return {"csrf_token": session.get("csrf_token", "")}
134
-
135
-
136
- # =========================================================
137
- # MODELS
138
- # =========================================================
139
-
140
- class User(UserMixin, db.Model):
141
- id = db.Column(db.Integer, primary_key=True)
142
- email = db.Column(db.String(255), unique=True, nullable=False)
143
- password_hash = db.Column(db.String(255), nullable=False)
144
- created_at = db.Column(db.DateTime, default=datetime.utcnow)
145
-
146
- # login security
147
- failed_logins = db.Column(db.Integer, default=0)
148
- lock_until = db.Column(db.DateTime, nullable=True)
149
-
150
- # email verification
151
- is_verified = db.Column(db.Boolean, default=False)
152
- verification_code = db.Column(db.String(6), nullable=True)
153
- verification_expires = db.Column(db.DateTime, nullable=True)
154
-
155
- # password reset
156
- reset_code = db.Column(db.String(6), nullable=True)
157
- reset_expires = db.Column(db.DateTime, nullable=True)
158
-
159
- # activity (for possible retention rules)
160
- last_active_at = db.Column(db.DateTime, nullable=True)
161
-
162
-
163
- class Entry(db.Model):
164
- id = db.Column(db.Integer, primary_key=True)
165
- created_at = db.Column(db.DateTime, default=datetime.utcnow)
166
- text = db.Column(db.Text, nullable=False)
167
- label = db.Column(db.String(32))
168
- confidence = db.Column(db.Float)
169
- severity = db.Column(db.Integer)
170
- threat_score = db.Column(db.Integer)
171
- politeness_score = db.Column(db.Integer)
172
- friendly_score = db.Column(db.Integer)
173
- has_threat = db.Column(db.Boolean, default=False)
174
- has_profanity = db.Column(db.Boolean, default=False)
175
- has_sarcasm = db.Column(db.Boolean, default=False)
176
- user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
177
-
178
- user = db.relationship("User", backref="entries")
179
-
180
-
181
- @login_manager.user_loader
182
- def load_user(user_id):
183
- try:
184
- return User.query.get(int(user_id))
185
- except Exception:
186
- return None
187
-
188
-
189
- # =========================================================
190
- # EMAIL HELPERS
191
- # =========================================================
192
-
193
- def send_email(to_email: str, subject: str, html: str):
194
- if not SENDGRID_API_KEY:
195
- print("[WARN] SENDGRID_API_KEY not set. Skipping email send.")
196
- print(f"Subject: {subject}\nTo: {to_email}\n{html}")
197
- return
198
-
199
- message = Mail(
200
- from_email=SENDER_EMAIL,
201
- to_emails=to_email,
202
- subject=subject,
203
- html_content=html,
204
- )
205
- try:
206
- sg = SendGridAPIClient(SENDGRID_API_KEY)
207
- sg.send(message)
208
- print(f"[INFO] Sent email to {to_email}: {subject}")
209
- except Exception as e:
210
- print(f"[ERROR] Failed to send email to {to_email}: {e}")
211
-
212
-
213
- def send_verification_email(to_email: str, code: str):
214
- html = f"""
215
- <p>Thanks for signing up for the AI Email Tone Classifier.</p>
216
- <p>Your verification code is: <strong>{code}</strong></p>
217
- <p>This code will expire in 15 minutes.</p>
218
- """
219
- send_email(to_email, "Verify your email", html)
220
-
221
-
222
- def send_password_reset_email(to_email: str, code: str):
223
- html = f"""
224
- <p>You requested to reset your password for the AI Email Tone Classifier.</p>
225
- <p>Your password reset code is: <strong>{code}</strong></p>
226
- <p>This code will expire in 15 minutes.</p>
227
- <p>If you did not request this, you can ignore this email.</p>
228
- """
229
- send_email(to_email, "Password reset code", html)
230
-
231
-
232
- # =========================================================
233
- # AUTH ROUTES: REGISTER / LOGIN / LOGOUT / VERIFY
234
- # =========================================================
235
-
236
- @app.route("/register", methods=["GET", "POST"])
237
- def register():
238
- if current_user.is_authenticated:
239
- return redirect(url_for("index"))
240
-
241
- if request.method == "POST":
242
- email = sanitize_string(request.form.get("email", ""), 255).lower()
243
- password = normalize_text(request.form.get("password", ""))
244
- consent = request.form.get("consent_privacy") == "on"
245
-
246
- if not email or not password:
247
- flash("Email and password are required.", "error")
248
- return redirect(url_for("register"))
249
-
250
- if not is_valid_email(email):
251
- flash("Please enter a valid email address.", "error")
252
- return redirect(url_for("register"))
253
-
254
- if not is_strong_password(password):
255
- flash("Password must be at least 8 characters and contain letters and numbers.", "error")
256
- return redirect(url_for("register"))
257
-
258
- if not consent:
259
- flash("You must agree to the Privacy Policy to create an account.", "error")
260
- return redirect(url_for("register"))
261
-
262
- existing = User.query.filter_by(email=email).first()
263
- if existing:
264
- flash("An account with that email already exists.", "error")
265
- return redirect(url_for("register"))
266
-
267
- user = User(
268
- email=email,
269
- password_hash=generate_password_hash(password),
270
- last_active_at=datetime.utcnow(),
271
- )
272
-
273
- code = generate_code()
274
- user.verification_code = code
275
- user.verification_expires = datetime.utcnow() + timedelta(minutes=15)
276
- user.is_verified = False
277
-
278
- db.session.add(user)
279
- db.session.commit()
280
-
281
- send_verification_email(email, code)
282
- session["pending_email"] = email
283
-
284
- flash("Account created. Check your email for the verification code.", "success")
285
- return redirect(url_for("verify"))
286
-
287
- return render_template("login.html", mode="register", title="Register")
288
-
289
-
290
- @app.route("/login", methods=["GET", "POST"])
291
- def login():
292
- if current_user.is_authenticated:
293
- return redirect(url_for("index"))
294
-
295
- if request.method == "POST":
296
- email = sanitize_string(request.form.get("email", ""), 255).lower()
297
- password = normalize_text(request.form.get("password", ""))
298
-
299
- if not email or not password:
300
- flash("Email and password are required.", "error")
301
- return redirect(url_for("login"))
302
-
303
- user = User.query.filter_by(email=email).first()
304
- if not user:
305
- flash("Invalid email or password.", "error")
306
- return redirect(url_for("login"))
307
-
308
- now = datetime.utcnow()
309
-
310
- # lockout check
311
- if user.lock_until and user.lock_until > now:
312
- remaining = int((user.lock_until - now).total_seconds() // 60) + 1
313
- flash(f"Account locked due to too many failed attempts. Try again in ~{remaining} minutes.", "error")
314
- return redirect(url_for("login"))
315
-
316
- if not check_password_hash(user.password_hash, password):
317
- user.failed_logins = (user.failed_logins or 0) + 1
318
- if user.failed_logins >= 5:
319
- user.lock_until = now + timedelta(minutes=10)
320
- user.failed_logins = 0
321
- db.session.commit()
322
- flash("Invalid email or password.", "error")
323
- return redirect(url_for("login"))
324
-
325
- # reset counters
326
- user.failed_logins = 0
327
- user.lock_until = None
328
- user.last_active_at = now
329
- db.session.commit()
330
-
331
- if not user.is_verified:
332
- session["pending_email"] = user.email
333
- flash("Please verify your email before logging in.", "error")
334
- return redirect(url_for("verify"))
335
-
336
- login_user(user)
337
- flash("Logged in successfully.", "success")
338
- return redirect(url_for("index"))
339
-
340
- return render_template("login.html", mode="login", title="Login")
341
-
342
-
343
- @app.route("/logout")
344
- @login_required
345
- def logout():
346
- logout_user()
347
- flash("You have been logged out.", "success")
348
- return redirect(url_for("login"))
349
-
350
-
351
- @app.route("/verify", methods=["GET", "POST"])
352
- def verify():
353
- email = sanitize_string(
354
- request.args.get("email", "") or session.get("pending_email", ""), 255
355
- ).lower()
356
-
357
- if not email:
358
- flash("No email specified for verification. Please register or log in again.", "error")
359
- return redirect(url_for("register"))
360
-
361
- user = User.query.filter_by(email=email).first()
362
- if not user:
363
- flash("Account not found. Please register again.", "error")
364
- return redirect(url_for("register"))
365
-
366
- if user.is_verified:
367
- flash("Your email is already verified. You can log in.", "success")
368
- return redirect(url_for("login"))
369
-
370
- if request.method == "POST":
371
- action = request.form.get("action", "verify")
372
-
373
- if action == "resend":
374
- code = generate_code()
375
- user.verification_code = code
376
- user.verification_expires = datetime.utcnow() + timedelta(minutes=15)
377
- db.session.commit()
378
- send_verification_email(user.email, code)
379
- flash("A new verification code has been sent.", "success")
380
- return redirect(url_for("verify", email=user.email))
381
-
382
- code_input = sanitize_string(request.form.get("code", ""), 6)
383
-
384
- if not code_input:
385
- flash("Please enter the verification code.", "error")
386
- return redirect(url_for("verify", email=user.email))
387
-
388
- if not user.verification_code or not user.verification_expires:
389
- flash("No active verification code. Please resend.", "error")
390
- return redirect(url_for("verify", email=user.email))
391
-
392
- if datetime.utcnow() > user.verification_expires:
393
- flash("Verification code expired. Please request a new one.", "error")
394
- return redirect(url_for("verify", email=user.email))
395
-
396
- if code_input != user.verification_code:
397
- flash("Invalid verification code.", "error")
398
- return redirect(url_for("verify", email=user.email))
399
-
400
- user.is_verified = True
401
- user.verification_code = None
402
- user.verification_expires = None
403
- user.last_active_at = datetime.utcnow()
404
- db.session.commit()
405
-
406
- flash("Email verified successfully. You can now log in.", "success")
407
- return redirect(url_for("login"))
408
-
409
- session["pending_email"] = email
410
- return render_template("verify.html", email=email, title="Verify Email")
411
-
412
-
413
- # =========================================================
414
- # FORGOT PASSWORD + RESET
415
- # =========================================================
416
-
417
- @app.route("/forgot", methods=["GET", "POST"])
418
- def forgot_password():
419
- if request.method == "POST":
420
- email = sanitize_string(request.form.get("email", ""), 255).lower()
421
-
422
- if not is_valid_email(email):
423
- flash("If that email exists, a reset code has been sent. Check your email, then enter the code below.", "success")
424
- return redirect(url_for("reset_password"))
425
-
426
- user = User.query.filter_by(email=email).first()
427
- if user:
428
- code = generate_code()
429
- user.reset_code = code
430
- user.reset_expires = datetime.utcnow() + timedelta(minutes=15)
431
- db.session.commit()
432
- send_password_reset_email(user.email, code)
433
-
434
- flash("If that email exists, a reset code has been sent. Check your email, then enter the code below.", "success")
435
- return redirect(url_for("reset_password"))
436
-
437
- return render_template("forgot.html", title="Forgot Password")
438
-
439
-
440
-
441
- @app.route("/reset", methods=["GET", "POST"])
442
- def reset_password():
443
- if request.method == "POST":
444
- email = sanitize_string(request.form.get("email", ""), 255).lower()
445
- code_input = sanitize_string(request.form.get("code", ""), 6)
446
- new_pw = normalize_text(request.form.get("password", ""))
447
- confirm_pw = normalize_text(request.form.get("confirm_password", ""))
448
-
449
- if not email or not code_input or not new_pw or not confirm_pw:
450
- flash("All fields are required.", "error")
451
- return redirect(url_for("reset_password"))
452
-
453
- if new_pw != confirm_pw:
454
- flash("Passwords do not match.", "error")
455
- return redirect(url_for("reset_password"))
456
-
457
- if not is_strong_password(new_pw):
458
- flash("Password must be at least 8 characters and contain letters and numbers.", "error")
459
- return redirect(url_for("reset_password"))
460
-
461
- user = User.query.filter_by(email=email).first()
462
- if (
463
- not user
464
- or not user.reset_code
465
- or not user.reset_expires
466
- or datetime.utcnow() > user.reset_expires
467
- or code_input != user.reset_code
468
- ):
469
- flash("Invalid or expired reset code.", "error")
470
- return redirect(url_for("reset_password"))
471
-
472
- user.password_hash = generate_password_hash(new_pw)
473
- user.reset_code = None
474
- user.reset_expires = None
475
- db.session.commit()
476
-
477
- flash("Password reset successfully. You can now log in.", "success")
478
- return redirect(url_for("login"))
479
-
480
- return render_template("reset_password.html", title="Reset Password")
481
-
482
-
483
- # =========================================================
484
- # MAIN CLASSIFIER
485
- # =========================================================
486
-
487
- @app.route("/", methods=["GET", "POST"])
488
- @login_required
489
- def index():
490
- if not current_user.is_verified:
491
- flash("Please verify your email to use the classifier.", "error")
492
- return redirect(url_for("verify", email=current_user.email))
493
-
494
- result = None
495
- text = ""
496
-
497
- if request.method == "POST":
498
- text = sanitize_long_text(request.form.get("email_text", ""))
499
- if text:
500
- result = classify_tone_rich(text)
501
-
502
- entry = Entry(
503
- text=text,
504
- label=result["label"],
505
- confidence=float(result["confidence"]),
506
- severity=int(result["severity"]),
507
- threat_score=int(result["threat_score"]),
508
- politeness_score=int(result["politeness_score"]),
509
- friendly_score=int(result["friendly_score"]),
510
- has_threat=bool(result["has_threat"]),
511
- has_profanity=bool(result["has_profanity"]),
512
- has_sarcasm=bool(result["has_sarcasm"]),
513
- user_id=current_user.id,
514
- )
515
- db.session.add(entry)
516
- db.session.commit()
517
-
518
- return render_template(
519
- "index.html",
520
- title="Analyze Email",
521
- email_text=text,
522
- result=result,
523
- )
524
-
525
-
526
- # =========================================================
527
- # HISTORY + EXPORTS
528
- # =========================================================
529
-
530
- @app.route("/history")
531
- @login_required
532
- def history_view():
533
- if not current_user.is_verified:
534
- flash("Please verify your email to view history.", "error")
535
- return redirect(url_for("verify", email=current_user.email))
536
-
537
- q = sanitize_string(request.args.get("q", ""), 255).lower()
538
- filter_label = sanitize_string(request.args.get("label", ""), 32).lower()
539
-
540
- query = Entry.query.filter_by(user_id=current_user.id)
541
-
542
- if q:
543
- query = query.filter(Entry.text.ilike(f"%{q}%"))
544
-
545
- if filter_label:
546
- query = query.filter(Entry.label.ilike(filter_label))
547
-
548
- entries = query.order_by(Entry.created_at.desc()).all()
549
-
550
- return render_template(
551
- "history.html",
552
- title="History",
553
- history=entries,
554
- search=q,
555
- active_filter=filter_label,
556
- )
557
-
558
-
559
- @app.route("/export/csv")
560
- @login_required
561
- def export_csv():
562
- if not current_user.is_verified:
563
- flash("Please verify your email to export data.", "error")
564
- return redirect(url_for("verify", email=current_user.email))
565
-
566
- filepath = os.path.join("exports", f"history_{current_user.id}.csv")
567
- entries = Entry.query.filter_by(user_id=current_user.id).order_by(Entry.created_at.asc())
568
-
569
- with open(filepath, "w", newline="", encoding="utf-8") as f:
570
- writer = csv.writer(f)
571
- writer.writerow(
572
- [
573
- "Time UTC",
574
- "Label",
575
- "Confidence",
576
- "Severity",
577
- "ThreatScore",
578
- "PolitenessScore",
579
- "FriendlyScore",
580
- "HasThreat",
581
- "HasProfanity",
582
- "HasSarcasm",
583
- "Text",
584
- ]
585
- )
586
- for e in entries:
587
- writer.writerow(
588
- [
589
- e.created_at.isoformat(),
590
- e.label,
591
- f"{e.confidence:.1f}",
592
- e.severity,
593
- e.threat_score,
594
- e.politeness_score,
595
- e.friendly_score,
596
- int(e.has_threat),
597
- int(e.has_profanity),
598
- int(e.has_sarcasm),
599
- e.text,
600
- ]
601
- )
602
-
603
- return send_file(filepath, as_attachment=True)
604
-
605
-
606
- @app.route("/export/pdf")
607
- @login_required
608
- def export_pdf():
609
- if not current_user.is_verified:
610
- flash("Please verify your email to export data.", "error")
611
- return redirect(url_for("verify", email=current_user.email))
612
-
613
- buffer = BytesIO()
614
- c = canvas.Canvas(buffer, pagesize=letter)
615
- width, height = letter
616
-
617
- c.setFillColorRGB(0.12, 0.15, 0.20)
618
- c.rect(0, height - 60, width, 60, fill=1)
619
- c.setFillColorRGB(1, 1, 1)
620
- c.setFont("Helvetica-Bold", 18)
621
- c.drawString(40, height - 35, "Tone Classifier – History Report")
622
-
623
- entries = (
624
- Entry.query.filter_by(user_id=current_user.id)
625
- .order_by(Entry.created_at.desc())
626
- .all()
627
- )
628
-
629
- y = height - 80
630
- for e in entries:
631
- if y < 90:
632
- c.showPage()
633
- y = height - 60
634
-
635
- c.setFont("Helvetica-Bold", 10)
636
- c.setFillColorRGB(0, 0, 0)
637
- c.drawString(
638
- 40,
639
- y,
640
- f"{e.created_at.isoformat()} | {e.label} | Severity {e.severity}",
641
- )
642
- y -= 12
643
-
644
- meta = f"Threat:{e.threat_score} Polite:{e.politeness_score} Friendly:{e.friendly_score}"
645
- c.setFont("Helvetica", 9)
646
- c.drawString(40, y, meta)
647
- y -= 12
648
-
649
- text = e.text
650
- while len(text) > 90:
651
- idx = text.rfind(" ", 0, 90)
652
- if idx == -1:
653
- idx = 90
654
- c.drawString(50, y, text[:idx])
655
- text = text[idx:].strip()
656
- y -= 11
657
-
658
- c.drawString(50, y, text)
659
- y -= 20
660
-
661
- c.showPage()
662
- c.save()
663
-
664
- buffer.seek(0)
665
- filepath = os.path.join("exports", f"history_{current_user.id}.pdf")
666
- with open(filepath, "wb") as f:
667
- f.write(buffer.getvalue())
668
-
669
- return send_file(filepath, as_attachment=True)
670
-
671
-
672
- @app.route("/history/clear", methods=["POST"])
673
- @login_required
674
- def clear_history():
675
- if not current_user.is_verified:
676
- flash("Please verify your email to clear history.", "error")
677
- return redirect(url_for("verify", email=current_user.email))
678
-
679
- Entry.query.filter_by(user_id=current_user.id).delete()
680
- db.session.commit()
681
- flash("History cleared.", "success")
682
- return redirect(url_for("history_view"))
683
-
684
-
685
- # =========================================================
686
- # DELETE ACCOUNT + GDPR PAGES
687
- # =========================================================
688
-
689
- @app.route("/account/delete", methods=["GET", "POST"])
690
- @login_required
691
- def delete_account():
692
- if request.method == "POST":
693
- password = normalize_text(request.form.get("password", ""))
694
-
695
- if not check_password_hash(current_user.password_hash, password):
696
- flash("Incorrect password. Account not deleted.", "error")
697
- return redirect(url_for("delete_account"))
698
-
699
- try:
700
- uid = current_user.id
701
- Entry.query.filter_by(user_id=uid).delete()
702
- user = User.query.get(uid)
703
- logout_user()
704
- db.session.delete(user)
705
- db.session.commit()
706
- flash("Your account and all data have been deleted.", "success")
707
- except Exception as e:
708
- db.session.rollback()
709
- flash("Error deleting account. Please try again.", "error")
710
- print(f"[ERROR] delete_account failed: {e}")
711
- return redirect(url_for("delete_account"))
712
-
713
- return redirect(url_for("register"))
714
-
715
- return render_template("delete_account.html", title="Delete Account")
716
-
717
-
718
- @app.route("/privacy")
719
- def privacy():
720
- # pass datetime for template display
721
- from datetime import datetime as dt
722
- return render_template("privacy.html", title="Privacy Policy", datetime=dt)
723
-
724
-
725
- @app.route("/do-not-sell")
726
- def do_not_sell():
727
- return render_template("do_not_sell.html", title="Do Not Sell My Info")
728
-
729
-
730
- # =========================================================
731
- # INIT DB & RUN (LOCAL)
732
- # =========================================================
733
-
734
- with app.app_context():
735
- db.create_all()
736
-
737
- if __name__ == "__main__":
738
- # local dev
739
- app.run(debug=True, host="0.0.0.0", port=7860)
740
-
741
-
742
-
743
-
744
-
745
-
746
-
747
-
748
-
749
-
750
-
751
-
752
-
753
-
 
1
+ import os
2
+ import re
3
+ import csv
4
+ import secrets
5
+ import unicodedata
6
+ from datetime import datetime, timedelta
7
+ from io import BytesIO
8
+
9
+ from flask import (
10
+ Flask,
11
+ render_template,
12
+ request,
13
+ redirect,
14
+ url_for,
15
+ send_file,
16
+ flash,
17
+ session,
18
+ abort,
19
+ )
20
+ from flask_sqlalchemy import SQLAlchemy
21
+ from flask_login import (
22
+ LoginManager,
23
+ UserMixin,
24
+ login_user,
25
+ login_required,
26
+ current_user,
27
+ logout_user,
28
+ )
29
+ from werkzeug.security import generate_password_hash, check_password_hash
30
+ from reportlab.pdfgen import canvas
31
+ from reportlab.lib.pagesizes import letter
32
+
33
+ from sendgrid import SendGridAPIClient
34
+ from sendgrid.helpers.mail import Mail
35
+
36
+ # your HF-based classifier
37
+ from model import classify_tone_rich
38
+
39
+
40
+ # =========================================================
41
+ # APP CONFIG
42
+ # =========================================================
43
+
44
+ app = Flask(__name__)
45
+
46
+ app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-this-in-prod")
47
+ app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///data.db"
48
+ app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
49
+
50
+ # Detect if running on Hugging Face Spaces (they set SPACE_ID)
51
+ IS_HF = os.getenv("SPACE_ID") is not None
52
+
53
+ # cookie security
54
+ app.config["SESSION_COOKIE_HTTPONLY"] = True
55
+
56
+ if IS_HF:
57
+ # Needed because app runs inside an iframe on huggingface.co
58
+ app.config["SESSION_COOKIE_SAMESITE"] = "None"
59
+ app.config["SESSION_COOKIE_SECURE"] = True
60
+ else:
61
+ # Local dev / normal hosting
62
+ app.config["SESSION_COOKIE_SAMESITE"] = "Lax"
63
+ app.config["SESSION_COOKIE_SECURE"] = False
64
+
65
+ db = SQLAlchemy(app)
66
+ login_manager = LoginManager(app)
67
+ login_manager.login_view = "login"
68
+
69
+ # Email (SendGrid)
70
+ SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY")
71
+ SENDER_EMAIL = os.getenv("SENDER_EMAIL", "no-reply@example.com")
72
+
73
+ # simple email regex
74
+ EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
75
+
76
+ os.makedirs("exports", exist_ok=True)
77
+
78
+
79
+ # =========================================================
80
+ # HELPER FUNCTIONS SANITIZATION, CSRF, PASSWORDS
81
+ # =========================================================
82
+
83
+ def normalize_text(value: str) -> str:
84
+ if not value:
85
+ return ""
86
+ value = unicodedata.normalize("NFKC", value)
87
+ value = value.replace("\u200b", "").replace("\u200c", "").replace("\u200d", "")
88
+ return value.strip()
89
+
90
+
91
+ def sanitize_string(value: str, max_len: int = 255) -> str:
92
+ value = normalize_text(value)
93
+ if len(value) > max_len:
94
+ value = value[:max_len]
95
+ return value
96
+
97
+
98
+ def sanitize_long_text(value: str, max_len: int = 4000) -> str:
99
+ value = normalize_text(value)
100
+ if len(value) > max_len:
101
+ value = value[:max_len]
102
+ return value
103
+
104
+
105
+ def is_valid_email(email: str) -> bool:
106
+ return bool(email and EMAIL_RE.match(email))
107
+
108
+
109
+ def is_strong_password(pw: str) -> bool:
110
+ if not pw or len(pw) < 8:
111
+ return False
112
+ has_letter = any(c.isalpha() for c in pw)
113
+ has_digit = any(c.isdigit() for c in pw)
114
+ return has_letter and has_digit
115
+
116
+
117
+ def generate_code() -> str:
118
+ """6-digit numeric code used for verify + reset."""
119
+ return f"{secrets.randbelow(1000000):06d}"
120
+
121
+
122
+ def generate_csrf_token() -> str:
123
+ token = session.get("csrf_token")
124
+ if not token:
125
+ token = secrets.token_hex(16)
126
+ session["csrf_token"] = token
127
+ return token
128
+
129
+
130
+ @app.before_request
131
+ def csrf_protect():
132
+ # ensure CSRF token exists
133
+ generate_csrf_token()
134
+
135
+ if request.method == "POST":
136
+ form_token = request.form.get("csrf_token", "")
137
+ sess_token = session.get("csrf_token", "")
138
+ if not form_token or form_token != sess_token:
139
+ abort(400, description="Invalid CSRF token")
140
+
141
+
142
+ @app.context_processor
143
+ def inject_csrf():
144
+ return {"csrf_token": session.get("csrf_token", "")}
145
+
146
+
147
+ # =========================================================
148
+ # MODELS
149
+ # =========================================================
150
+
151
+ class User(UserMixin, db.Model):
152
+ id = db.Column(db.Integer, primary_key=True)
153
+ email = db.Column(db.String(255), unique=True, nullable=False)
154
+ password_hash = db.Column(db.String(255), nullable=False)
155
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
156
+
157
+ # login security
158
+ failed_logins = db.Column(db.Integer, default=0)
159
+ lock_until = db.Column(db.DateTime, nullable=True)
160
+
161
+ # email verification
162
+ is_verified = db.Column(db.Boolean, default=False)
163
+ verification_code = db.Column(db.String(6), nullable=True)
164
+ verification_expires = db.Column(db.DateTime, nullable=True)
165
+
166
+ # password reset
167
+ reset_code = db.Column(db.String(6), nullable=True)
168
+ reset_expires = db.Column(db.DateTime, nullable=True)
169
+
170
+ # activity (for possible retention rules)
171
+ last_active_at = db.Column(db.DateTime, nullable=True)
172
+
173
+
174
+ class Entry(db.Model):
175
+ id = db.Column(db.Integer, primary_key=True)
176
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
177
+ text = db.Column(db.Text, nullable=False)
178
+ label = db.Column(db.String(32))
179
+ confidence = db.Column(db.Float)
180
+ severity = db.Column(db.Integer)
181
+ threat_score = db.Column(db.Integer)
182
+ politeness_score = db.Column(db.Integer)
183
+ friendly_score = db.Column(db.Integer)
184
+ has_threat = db.Column(db.Boolean, default=False)
185
+ has_profanity = db.Column(db.Boolean, default=False)
186
+ has_sarcasm = db.Column(db.Boolean, default=False)
187
+ user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False)
188
+
189
+ user = db.relationship("User", backref="entries")
190
+
191
+
192
+ @login_manager.user_loader
193
+ def load_user(user_id):
194
+ try:
195
+ return User.query.get(int(user_id))
196
+ except Exception:
197
+ return None
198
+
199
+
200
+ # =========================================================
201
+ # EMAIL HELPERS
202
+ # =========================================================
203
+
204
+ def send_email(to_email: str, subject: str, html: str):
205
+ if not SENDGRID_API_KEY:
206
+ print("[WARN] SENDGRID_API_KEY not set. Skipping email send.")
207
+ print(f"Subject: {subject}\nTo: {to_email}\n{html}")
208
+ return
209
+
210
+ message = Mail(
211
+ from_email=SENDER_EMAIL,
212
+ to_emails=to_email,
213
+ subject=subject,
214
+ html_content=html,
215
+ )
216
+ try:
217
+ sg = SendGridAPIClient(SENDGRID_API_KEY)
218
+ sg.send(message)
219
+ print(f"[INFO] Sent email to {to_email}: {subject}")
220
+ except Exception as e:
221
+ print(f"[ERROR] Failed to send email to {to_email}: {e}")
222
+
223
+
224
+ def send_verification_email(to_email: str, code: str):
225
+ html = f"""
226
+ <p>Thanks for signing up for the AI Email Tone Classifier.</p>
227
+ <p>Your verification code is: <strong>{code}</strong></p>
228
+ <p>This code will expire in 15 minutes.</p>
229
+ """
230
+ send_email(to_email, "Verify your email", html)
231
+
232
+
233
+ def send_password_reset_email(to_email: str, code: str):
234
+ html = f"""
235
+ <p>You requested to reset your password for the AI Email Tone Classifier.</p>
236
+ <p>Your password reset code is: <strong>{code}</strong></p>
237
+ <p>This code will expire in 15 minutes.</p>
238
+ <p>If you did not request this, you can ignore this email.</p>
239
+ """
240
+ send_email(to_email, "Password reset code", html)
241
+
242
+
243
+ # =========================================================
244
+ # AUTH ROUTES: REGISTER / LOGIN / LOGOUT / VERIFY
245
+ # =========================================================
246
+
247
+ @app.route("/register", methods=["GET", "POST"])
248
+ def register():
249
+ if current_user.is_authenticated:
250
+ return redirect(url_for("index"))
251
+
252
+ if request.method == "POST":
253
+ email = sanitize_string(request.form.get("email", ""), 255).lower()
254
+ password = normalize_text(request.form.get("password", ""))
255
+ consent = request.form.get("consent_privacy") == "on"
256
+
257
+ if not email or not password:
258
+ flash("Email and password are required.", "error")
259
+ return redirect(url_for("register"))
260
+
261
+ if not is_valid_email(email):
262
+ flash("Please enter a valid email address.", "error")
263
+ return redirect(url_for("register"))
264
+
265
+ if not is_strong_password(password):
266
+ flash("Password must be at least 8 characters and contain letters and numbers.", "error")
267
+ return redirect(url_for("register"))
268
+
269
+ if not consent:
270
+ flash("You must agree to the Privacy Policy to create an account.", "error")
271
+ return redirect(url_for("register"))
272
+
273
+ existing = User.query.filter_by(email=email).first()
274
+ if existing:
275
+ flash("An account with that email already exists.", "error")
276
+ return redirect(url_for("register"))
277
+
278
+ user = User(
279
+ email=email,
280
+ password_hash=generate_password_hash(password),
281
+ last_active_at=datetime.utcnow(),
282
+ )
283
+
284
+ code = generate_code()
285
+ user.verification_code = code
286
+ user.verification_expires = datetime.utcnow() + timedelta(minutes=15)
287
+ user.is_verified = False
288
+
289
+ db.session.add(user)
290
+ db.session.commit()
291
+
292
+ send_verification_email(email, code)
293
+ session["pending_email"] = email
294
+
295
+ flash("Account created. Check your email for the verification code.", "success")
296
+ return redirect(url_for("verify"))
297
+
298
+ return render_template("login.html", mode="register", title="Register")
299
+
300
+
301
+ @app.route("/login", methods=["GET", "POST"])
302
+ def login():
303
+ if current_user.is_authenticated:
304
+ return redirect(url_for("index"))
305
+
306
+ if request.method == "POST":
307
+ email = sanitize_string(request.form.get("email", ""), 255).lower()
308
+ password = normalize_text(request.form.get("password", ""))
309
+
310
+ if not email or not password:
311
+ flash("Email and password are required.", "error")
312
+ return redirect(url_for("login"))
313
+
314
+ user = User.query.filter_by(email=email).first()
315
+ if not user:
316
+ flash("Invalid email or password.", "error")
317
+ return redirect(url_for("login"))
318
+
319
+ now = datetime.utcnow()
320
+
321
+ # lockout check
322
+ if user.lock_until and user.lock_until > now:
323
+ remaining = int((user.lock_until - now).total_seconds() // 60) + 1
324
+ flash(f"Account locked due to too many failed attempts. Try again in ~{remaining} minutes.", "error")
325
+ return redirect(url_for("login"))
326
+
327
+ if not check_password_hash(user.password_hash, password):
328
+ user.failed_logins = (user.failed_logins or 0) + 1
329
+ if user.failed_logins >= 5:
330
+ user.lock_until = now + timedelta(minutes=10)
331
+ user.failed_logins = 0
332
+ db.session.commit()
333
+ flash("Invalid email or password.", "error")
334
+ return redirect(url_for("login"))
335
+
336
+ # reset counters
337
+ user.failed_logins = 0
338
+ user.lock_until = None
339
+ user.last_active_at = now
340
+ db.session.commit()
341
+
342
+ if not user.is_verified:
343
+ session["pending_email"] = user.email
344
+ flash("Please verify your email before logging in.", "error")
345
+ return redirect(url_for("verify"))
346
+
347
+ login_user(user)
348
+ flash("Logged in successfully.", "success")
349
+ return redirect(url_for("index"))
350
+
351
+ return render_template("login.html", mode="login", title="Login")
352
+
353
+
354
+ @app.route("/logout")
355
+ @login_required
356
+ def logout():
357
+ logout_user()
358
+ flash("You have been logged out.", "success")
359
+ return redirect(url_for("login"))
360
+
361
+
362
+ @app.route("/verify", methods=["GET", "POST"])
363
+ def verify():
364
+ email = sanitize_string(
365
+ request.args.get("email", "") or session.get("pending_email", ""), 255
366
+ ).lower()
367
+
368
+ if not email:
369
+ flash("No email specified for verification. Please register or log in again.", "error")
370
+ return redirect(url_for("register"))
371
+
372
+ user = User.query.filter_by(email=email).first()
373
+ if not user:
374
+ flash("Account not found. Please register again.", "error")
375
+ return redirect(url_for("register"))
376
+
377
+ if user.is_verified:
378
+ flash("Your email is already verified. You can log in.", "success")
379
+ return redirect(url_for("login"))
380
+
381
+ if request.method == "POST":
382
+ action = request.form.get("action", "verify")
383
+
384
+ if action == "resend":
385
+ code = generate_code()
386
+ user.verification_code = code
387
+ user.verification_expires = datetime.utcnow() + timedelta(minutes=15)
388
+ db.session.commit()
389
+ send_verification_email(user.email, code)
390
+ flash("A new verification code has been sent.", "success")
391
+ return redirect(url_for("verify", email=user.email))
392
+
393
+ code_input = sanitize_string(request.form.get("code", ""), 6)
394
+
395
+ if not code_input:
396
+ flash("Please enter the verification code.", "error")
397
+ return redirect(url_for("verify", email=user.email))
398
+
399
+ if not user.verification_code or not user.verification_expires:
400
+ flash("No active verification code. Please resend.", "error")
401
+ return redirect(url_for("verify", email=user.email))
402
+
403
+ if datetime.utcnow() > user.verification_expires:
404
+ flash("Verification code expired. Please request a new one.", "error")
405
+ return redirect(url_for("verify", email=user.email))
406
+
407
+ if code_input != user.verification_code:
408
+ flash("Invalid verification code.", "error")
409
+ return redirect(url_for("verify", email=user.email))
410
+
411
+ user.is_verified = True
412
+ user.verification_code = None
413
+ user.verification_expires = None
414
+ user.last_active_at = datetime.utcnow()
415
+ db.session.commit()
416
+
417
+ flash("Email verified successfully. You can now log in.", "success")
418
+ return redirect(url_for("login"))
419
+
420
+ session["pending_email"] = email
421
+ return render_template("verify.html", email=email, title="Verify Email")
422
+
423
+
424
+ # =========================================================
425
+ # FORGOT PASSWORD + RESET
426
+ # =========================================================
427
+
428
+ @app.route("/forgot", methods=["GET", "POST"])
429
+ def forgot_password():
430
+ if request.method == "POST":
431
+ email = sanitize_string(request.form.get("email", ""), 255).lower()
432
+
433
+ if not is_valid_email(email):
434
+ flash("If that email exists, a reset code has been sent. Check your email, then enter the code below.", "success")
435
+ return redirect(url_for("reset_password"))
436
+
437
+ user = User.query.filter_by(email=email).first()
438
+ if user:
439
+ code = generate_code()
440
+ user.reset_code = code
441
+ user.reset_expires = datetime.utcnow() + timedelta(minutes=15)
442
+ db.session.commit()
443
+ send_password_reset_email(user.email, code)
444
+
445
+ flash("If that email exists, a reset code has been sent. Check your email, then enter the code below.", "success")
446
+ return redirect(url_for("reset_password"))
447
+
448
+ return render_template("forgot.html", title="Forgot Password")
449
+
450
+
451
+ @app.route("/reset", methods=["GET", "POST"])
452
+ def reset_password():
453
+ if request.method == "POST":
454
+ email = sanitize_string(request.form.get("email", ""), 255).lower()
455
+ code_input = sanitize_string(request.form.get("code", ""), 6)
456
+ new_pw = normalize_text(request.form.get("password", ""))
457
+ confirm_pw = normalize_text(request.form.get("confirm_password", ""))
458
+
459
+ if not email or not code_input or not new_pw or not confirm_pw:
460
+ flash("All fields are required.", "error")
461
+ return redirect(url_for("reset_password"))
462
+
463
+ if new_pw != confirm_pw:
464
+ flash("Passwords do not match.", "error")
465
+ return redirect(url_for("reset_password"))
466
+
467
+ if not is_strong_password(new_pw):
468
+ flash("Password must be at least 8 characters and contain letters and numbers.", "error")
469
+ return redirect(url_for("reset_password"))
470
+
471
+ user = User.query.filter_by(email=email).first()
472
+ if (
473
+ not user
474
+ or not user.reset_code
475
+ or not user.reset_expires
476
+ or datetime.utcnow() > user.reset_expires
477
+ or code_input != user.reset_code
478
+ ):
479
+ flash("Invalid or expired reset code.", "error")
480
+ return redirect(url_for("reset_password"))
481
+
482
+ user.password_hash = generate_password_hash(new_pw)
483
+ user.reset_code = None
484
+ user.reset_expires = None
485
+ db.session.commit()
486
+
487
+ flash("Password reset successfully. You can now log in.", "success")
488
+ return redirect(url_for("login"))
489
+
490
+ return render_template("reset_password.html", title="Reset Password")
491
+
492
+
493
+ # =========================================================
494
+ # MAIN CLASSIFIER
495
+ # =========================================================
496
+
497
+ @app.route("/", methods=["GET", "POST"])
498
+ @login_required
499
+ def index():
500
+ if not current_user.is_verified:
501
+ flash("Please verify your email to use the classifier.", "error")
502
+ return redirect(url_for("verify", email=current_user.email))
503
+
504
+ result = None # dict from classify_tone_rich
505
+ text = ""
506
+
507
+ if request.method == "POST":
508
+ text = sanitize_long_text(request.form.get("email_text", ""))
509
+ if text:
510
+ result = classify_tone_rich(text)
511
+
512
+ entry = Entry(
513
+ text=text,
514
+ label=result["label"],
515
+ confidence=float(result["confidence"]),
516
+ severity=int(result["severity"]),
517
+ threat_score=int(result["threat_score"]),
518
+ politeness_score=int(result["politeness_score"]),
519
+ friendly_score=int(result["friendly_score"]),
520
+ has_threat=bool(result["has_threat"]),
521
+ has_profanity=bool(result["has_profanity"]),
522
+ has_sarcasm=bool(result["has_sarcasm"]),
523
+ user_id=current_user.id,
524
+ )
525
+ db.session.add(entry)
526
+ db.session.commit()
527
+
528
+ return render_template(
529
+ "index.html",
530
+ title="Analyze Email",
531
+ email_text=text,
532
+ result=result,
533
+ )
534
+
535
+
536
+ # =========================================================
537
+ # HISTORY + EXPORTS
538
+ # =========================================================
539
+
540
+ @app.route("/history")
541
+ @login_required
542
+ def history_view():
543
+ if not current_user.is_verified:
544
+ flash("Please verify your email to view history.", "error")
545
+ return redirect(url_for("verify", email=current_user.email))
546
+
547
+ q = sanitize_string(request.args.get("q", ""), 255).lower()
548
+ filter_label = sanitize_string(request.args.get("label", ""), 32).lower()
549
+
550
+ query = Entry.query.filter_by(user_id=current_user.id)
551
+
552
+ if q:
553
+ query = query.filter(Entry.text.ilike(f"%{q}%"))
554
+
555
+ if filter_label:
556
+ query = query.filter(Entry.label.ilike(filter_label))
557
+
558
+ entries = query.order_by(Entry.created_at.desc()).all()
559
+
560
+ return render_template(
561
+ "history.html",
562
+ title="History",
563
+ history=entries,
564
+ search=q,
565
+ active_filter=filter_label,
566
+ )
567
+
568
+
569
+ @app.route("/export/csv")
570
+ @login_required
571
+ def export_csv():
572
+ if not current_user.is_verified:
573
+ flash("Please verify your email to export data.", "error")
574
+ return redirect(url_for("verify", email=current_user.email))
575
+
576
+ filepath = os.path.join("exports", f"history_{current_user.id}.csv")
577
+ entries = Entry.query.filter_by(user_id=current_user.id).order_by(Entry.created_at.asc())
578
+
579
+ with open(filepath, "w", newline="", encoding="utf-8") as f:
580
+ writer = csv.writer(f)
581
+ writer.writerow(
582
+ [
583
+ "Time UTC",
584
+ "Label",
585
+ "Confidence",
586
+ "Severity",
587
+ "ThreatScore",
588
+ "PolitenessScore",
589
+ "FriendlyScore",
590
+ "HasThreat",
591
+ "HasProfanity",
592
+ "HasSarcasm",
593
+ "Text",
594
+ ]
595
+ )
596
+ for e in entries:
597
+ writer.writerow(
598
+ [
599
+ e.created_at.isoformat(),
600
+ e.label,
601
+ f"{e.confidence:.1f}",
602
+ e.severity,
603
+ e.threat_score,
604
+ e.politeness_score,
605
+ e.friendly_score,
606
+ int(e.has_threat),
607
+ int(e.has_profanity),
608
+ int(e.has_sarcasm),
609
+ e.text,
610
+ ]
611
+ )
612
+
613
+ return send_file(filepath, as_attachment=True)
614
+
615
+
616
+ @app.route("/export/pdf")
617
+ @login_required
618
+ def export_pdf():
619
+ if not current_user.is_verified:
620
+ flash("Please verify your email to export data.", "error")
621
+ return redirect(url_for("verify", email=current_user.email))
622
+
623
+ buffer = BytesIO()
624
+ c = canvas.Canvas(buffer, pagesize=letter)
625
+ width, height = letter
626
+
627
+ c.setFillColorRGB(0.12, 0.15, 0.20)
628
+ c.rect(0, height - 60, width, 60, fill=1)
629
+ c.setFillColorRGB(1, 1, 1)
630
+ c.setFont("Helvetica-Bold", 18)
631
+ c.drawString(40, height - 35, "Tone Classifier – History Report")
632
+
633
+ entries = (
634
+ Entry.query.filter_by(user_id=current_user.id)
635
+ .order_by(Entry.created_at.desc())
636
+ .all()
637
+ )
638
+
639
+ y = height - 80
640
+ for e in entries:
641
+ if y < 90:
642
+ c.showPage()
643
+ y = height - 60
644
+
645
+ c.setFont("Helvetica-Bold", 10)
646
+ c.setFillColorRGB(0, 0, 0)
647
+ c.drawString(
648
+ 40,
649
+ y,
650
+ f"{e.created_at.isoformat()} | {e.label} | Severity {e.severity}",
651
+ )
652
+ y -= 12
653
+
654
+ meta = f"Threat:{e.threat_score} Polite:{e.politeness_score} Friendly:{e.friendly_score}"
655
+ c.setFont("Helvetica", 9)
656
+ c.drawString(40, y, meta)
657
+ y -= 12
658
+
659
+ text = e.text
660
+ while len(text) > 90:
661
+ idx = text.rfind(" ", 0, 90)
662
+ if idx == -1:
663
+ idx = 90
664
+ c.drawString(50, y, text[:idx])
665
+ text = text[idx:].strip()
666
+ y -= 11
667
+
668
+ c.drawString(50, y, text)
669
+ y -= 20
670
+
671
+ c.showPage()
672
+ c.save()
673
+
674
+ buffer.seek(0)
675
+ filepath = os.path.join("exports", f"history_{current_user.id}.pdf")
676
+ with open(filepath, "wb") as f:
677
+ f.write(buffer.getvalue())
678
+
679
+ return send_file(filepath, as_attachment=True)
680
+
681
+
682
+ @app.route("/history/clear", methods=["POST"])
683
+ @login_required
684
+ def clear_history():
685
+ if not current_user.is_verified:
686
+ flash("Please verify your email to clear history.", "error")
687
+ return redirect(url_for("verify", email=current_user.email))
688
+
689
+ Entry.query.filter_by(user_id=current_user.id).delete()
690
+ db.session.commit()
691
+ flash("History cleared.", "success")
692
+ return redirect(url_for("history_view"))
693
+
694
+
695
+ # =========================================================
696
+ # DELETE ACCOUNT + GDPR PAGES
697
+ # =========================================================
698
+
699
+ @app.route("/account/delete", methods=["GET", "POST"])
700
+ @login_required
701
+ def delete_account():
702
+ if request.method == "POST":
703
+ password = normalize_text(request.form.get("password", ""))
704
+
705
+ if not check_password_hash(current_user.password_hash, password):
706
+ flash("Incorrect password. Account not deleted.", "error")
707
+ return redirect(url_for("delete_account"))
708
+
709
+ try:
710
+ uid = current_user.id
711
+ Entry.query.filter_by(user_id=uid).delete()
712
+ user = User.query.get(uid)
713
+ logout_user()
714
+ db.session.delete(user)
715
+ db.session.commit()
716
+ flash("Your account and all data have been deleted.", "success")
717
+ except Exception as e:
718
+ db.session.rollback()
719
+ flash("Error deleting account. Please try again.", "error")
720
+ print(f"[ERROR] delete_account failed: {e}")
721
+ return redirect(url_for("delete_account"))
722
+
723
+ return redirect(url_for("register"))
724
+
725
+ return render_template("delete_account.html", title="Delete Account")
726
+
727
+
728
+ @app.route("/privacy")
729
+ def privacy():
730
+ from datetime import datetime as dt
731
+ return render_template("privacy.html", title="Privacy Policy", datetime=dt)
732
+
733
+
734
+ @app.route("/do-not-sell")
735
+ def do_not_sell():
736
+ return render_template("do_not_sell.html", title="Do Not Sell My Info")
737
+
738
+
739
+ # =========================================================
740
+ # INIT DB & RUN (LOCAL)
741
+ # =========================================================
742
+
743
+ with app.app_context():
744
+ db.create_all()
745
+
746
+ if __name__ == "__main__":
747
+ # local dev
748
+ app.run(debug=True, host="0.0.0.0", port=7860)