Lawino commited on
Commit
5d1e6fb
Β·
verified Β·
1 Parent(s): 372784b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +340 -466
app.py CHANGED
@@ -1,6 +1,6 @@
1
  """
2
  Campus Recovery Network - Main Application
3
- Complete working version for Hugging Face Spaces
4
  """
5
 
6
  import os
@@ -12,15 +12,16 @@ import time
12
  import re
13
  import csv
14
  import io
 
15
  from datetime import datetime, timedelta
16
  from functools import wraps
17
- from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, g, send_file, make_response, session
18
  from flask_bcrypt import Bcrypt
19
  from flask_cors import CORS
20
  from werkzeug.utils import secure_filename
21
 
22
  # ── Configuration ─────────────────────────────────────────────────────────────
23
- SECRET_KEY = os.environ.get('SECRET_KEY', 'campus_recovery_secret_key_2024_must_be_long_and_secure')
24
  UPLOAD_FOLDER = 'static/uploads'
25
  MAX_CONTENT_LENGTH = 16 * 1024 * 1024
26
 
@@ -31,73 +32,41 @@ ADMIN_EMAIL = os.environ.get('ADMIN_EMAIL', 'admin@campusrecovery.com')
31
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
32
  DATABASE = 'campus_recovery.db'
33
 
34
- # ── Optional AI libs (graceful fallback) ─────────────────────────────────────
35
  try:
36
  import numpy as np
37
- NUMPY_OK = True
38
  except ImportError:
39
- NUMPY_OK = False
40
 
41
  try:
42
  import cv2
43
- OPENCV_OK = True
44
  except ImportError:
45
- OPENCV_OK = False
46
 
47
  try:
48
  from PIL import Image
49
- PIL_OK = True
50
  except ImportError:
51
- PIL_OK = False
52
 
53
  try:
54
  from ultralytics import YOLO
55
- YOLO_OK = True
56
  yolo_model = YOLO('yolov8n.pt')
57
- print("βœ“ YOLOv8 loaded")
58
- except Exception as e:
59
- YOLO_OK = False
60
  yolo_model = None
61
- print(f"⚠ YOLOv8 not available: {e}")
62
 
63
  # ── App setup ───────────────────────────────────────────────────────────────
64
  app = Flask(__name__)
65
  app.secret_key = SECRET_KEY
66
  app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
67
  app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH
68
- app.config['SESSION_PERMANENT'] = True
69
- app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=30)
70
- app.config['SESSION_COOKIE_SECURE'] = False # Set to True if using HTTPS
71
- app.config['SESSION_COOKIE_HTTPONLY'] = True
72
- app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
73
 
74
  bcrypt = Bcrypt(app)
75
  CORS(app, supports_credentials=True)
76
 
77
  ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
78
 
79
- # ── Login Required Decorator ─────────────────────────────────────────────────
80
- def login_required(f):
81
- @wraps(f)
82
- def decorated_function(*args, **kwargs):
83
- if 'user_id' not in session:
84
- flash('Please log in to continue.', 'warning')
85
- return redirect(url_for('login'))
86
-
87
- # Verify user still exists and is not banned
88
- user = query_db("SELECT id, is_banned FROM users WHERE id=?", [session['user_id']], one=True)
89
- if not user:
90
- session.clear()
91
- flash('Session expired. Please log in again.', 'warning')
92
- return redirect(url_for('login'))
93
- if user['is_banned']:
94
- session.clear()
95
- flash('Your account has been suspended.', 'danger')
96
- return redirect(url_for('login'))
97
-
98
- return f(*args, **kwargs)
99
- return decorated_function
100
-
101
  # ── Database helpers ─────────────────────────────────────────────────────────
102
  def get_db():
103
  db = getattr(g, '_database', None)
@@ -128,7 +97,70 @@ def query_db(query, args=(), one=False, commit=False):
128
  db.rollback()
129
  raise e
130
 
131
- # ── Database Schema ───────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
132
  def init_db():
133
  with app.app_context():
134
  db = get_db()
@@ -141,7 +173,6 @@ def init_db():
141
  role TEXT DEFAULT 'user',
142
  is_banned INTEGER DEFAULT 0,
143
  ban_reason TEXT,
144
- avatar TEXT,
145
  email_notifications INTEGER DEFAULT 1,
146
  match_alerts INTEGER DEFAULT 1,
147
  claim_updates INTEGER DEFAULT 1,
@@ -150,6 +181,15 @@ def init_db():
150
  last_login TEXT
151
  );
152
 
 
 
 
 
 
 
 
 
 
153
  CREATE TABLE IF NOT EXISTS items (
154
  id INTEGER PRIMARY KEY AUTOINCREMENT,
155
  user_id INTEGER NOT NULL,
@@ -163,8 +203,6 @@ def init_db():
163
  brand TEXT,
164
  image_path TEXT,
165
  status TEXT DEFAULT 'active' CHECK(status IN ('active','resolved','claimed')),
166
- image_features TEXT,
167
- ai_embedding TEXT,
168
  source TEXT DEFAULT 'web',
169
  ai_item_name TEXT,
170
  ai_brand_model TEXT,
@@ -180,7 +218,7 @@ def init_db():
180
  lost_item_id INTEGER NOT NULL,
181
  found_item_id INTEGER NOT NULL,
182
  score REAL DEFAULT 0,
183
- status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','rejected')),
184
  created_at TEXT DEFAULT (datetime('now')),
185
  FOREIGN KEY (lost_item_id) REFERENCES items(id) ON DELETE CASCADE,
186
  FOREIGN KEY (found_item_id) REFERENCES items(id) ON DELETE CASCADE
@@ -192,7 +230,7 @@ def init_db():
192
  claimant_id INTEGER NOT NULL,
193
  proof_text TEXT NOT NULL,
194
  proof_image TEXT,
195
- status TEXT DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected')),
196
  admin_note TEXT,
197
  created_at TEXT DEFAULT (datetime('now')),
198
  resolved_at TEXT,
@@ -223,25 +261,6 @@ def init_db():
223
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
224
  );
225
 
226
- CREATE TABLE IF NOT EXISTS chat_history (
227
- id INTEGER PRIMARY KEY AUTOINCREMENT,
228
- user_id INTEGER NOT NULL,
229
- role TEXT NOT NULL,
230
- content TEXT NOT NULL,
231
- created_at TEXT DEFAULT (datetime('now')),
232
- FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
233
- );
234
-
235
- CREATE TABLE IF NOT EXISTS admin_actions (
236
- id INTEGER PRIMARY KEY AUTOINCREMENT,
237
- admin_id INTEGER,
238
- action TEXT NOT NULL,
239
- target_type TEXT,
240
- target_id INTEGER,
241
- details TEXT,
242
- created_at TEXT DEFAULT (datetime('now'))
243
- );
244
-
245
  CREATE TABLE IF NOT EXISTS ratings (
246
  id INTEGER PRIMARY KEY AUTOINCREMENT,
247
  user_id INTEGER NOT NULL,
@@ -250,32 +269,28 @@ def init_db():
250
  created_at TEXT DEFAULT (datetime('now')),
251
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
252
  );
253
-
254
- CREATE TABLE IF NOT EXISTS system_settings (
255
- key TEXT PRIMARY KEY,
256
- value TEXT,
257
- updated_at TEXT DEFAULT (datetime('now'))
258
- );
259
  """)
260
  db.commit()
261
 
262
- # Add missing columns for existing databases
263
- columns_to_add = [
264
- ('items', 'ai_item_name', 'TEXT'),
265
- ('items', 'ai_brand_model', 'TEXT'),
266
- ('items', 'ai_dominant_color', 'TEXT'),
267
- ('items', 'ai_confidence_scores', 'TEXT'),
268
- ('notifications', 'metadata', 'TEXT'),
269
- ]
270
-
271
- for table, column, col_type in columns_to_add:
272
- try:
273
- db.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
274
- print(f"βœ“ Added column {column} to {table}")
275
- except sqlite3.OperationalError:
276
- pass
 
 
277
 
278
- # Create admin user if not exists
279
  existing_admin = db.execute("SELECT id FROM users WHERE username=?", (ADMIN_USERNAME,)).fetchone()
280
  if not existing_admin:
281
  pw_hash = bcrypt.generate_password_hash(ADMIN_PASSWORD_RAW).decode('utf-8')
@@ -299,127 +314,31 @@ def save_upload(file):
299
  return filename
300
  return None
301
 
302
- def add_notification(user_id, title, message, notif_type='info', link=None, metadata=None):
303
  try:
304
  query_db(
305
- "INSERT INTO notifications (user_id, title, message, type, link, metadata) VALUES (?,?,?,?,?,?)",
306
- [user_id, title, message, notif_type, link, json.dumps(metadata) if metadata else None],
307
  commit=True
308
  )
309
  except Exception as e:
310
  print(f"Error adding notification: {e}")
311
 
312
- # ── Color Detection ──────────────────────────────────────────────────────────
313
- COLOR_NAMES = {
314
- 'Red': ([0, 100, 100], [10, 255, 255]),
315
- 'Orange': ([10, 100, 100], [25, 255, 255]),
316
- 'Yellow': ([25, 100, 100], [35, 255, 255]),
317
- 'Green': ([35, 100, 100], [85, 255, 255]),
318
- 'Blue': ([85, 100, 100], [130, 255, 255]),
319
- 'Purple': ([130, 100, 100], [160, 255, 255]),
320
- 'Pink': ([160, 100, 100], [180, 255, 255]),
321
- 'Black': ([0, 0, 0], [180, 255, 50]),
322
- 'White': ([0, 0, 200], [180, 30, 255]),
323
- 'Gray': ([0, 0, 50], [180, 30, 200]),
324
- 'Brown': ([0, 50, 50], [30, 255, 150]),
325
- }
326
-
327
- def detect_color_with_opencv(image_path):
328
- if not OPENCV_OK or not NUMPY_OK:
329
- return None
330
- try:
331
- img = cv2.imread(image_path)
332
- if img is None:
333
- return None
334
- hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
335
- hsv = cv2.resize(hsv, (200, 200))
336
- pixels = hsv.reshape(-1, 3)
337
-
338
- from sklearn.cluster import KMeans
339
- kmeans = KMeans(n_clusters=3, random_state=42, n_init=10)
340
- kmeans.fit(pixels)
341
- centers = kmeans.cluster_centers_
342
-
343
- for color_hsv in centers:
344
- h, s, v = color_hsv
345
- for color_name, (lower, upper) in COLOR_NAMES.items():
346
- lower_h, lower_s, lower_v = lower
347
- upper_h, upper_s, upper_v = upper
348
- if color_name == 'Red':
349
- if (lower_h <= h <= upper_h) or (h >= 150 and h <= 180):
350
- if lower_s <= s <= upper_s and lower_v <= v <= upper_v:
351
- return color_name.lower()
352
- else:
353
- if (lower_h <= h <= upper_h and
354
- lower_s <= s <= upper_s and
355
- lower_v <= v <= upper_v):
356
- return color_name.lower()
357
- return None
358
- except Exception as e:
359
- print(f"Color detection error: {e}")
360
- return None
361
-
362
- def detect_item_with_yolo(image_path):
363
- if not YOLO_OK or yolo_model is None:
364
- return None
365
- try:
366
- results = yolo_model(image_path)
367
- for result in results:
368
- boxes = result.boxes
369
- if boxes and len(boxes) > 0:
370
- cls = int(boxes.cls[0])
371
- conf = float(boxes.conf[0])
372
- label = yolo_model.names[cls]
373
- if conf > 0.25:
374
- friendly_names = {
375
- 'cell phone': 'Smartphone', 'laptop': 'Laptop',
376
- 'backpack': 'Backpack', 'handbag': 'Handbag',
377
- 'book': 'Book', 'bottle': 'Water Bottle',
378
- 'watch': 'Watch',
379
- }
380
- item_name = friendly_names.get(label, label.replace('_', ' ').title())
381
- return {'item': item_name, 'confidence': conf}
382
- return None
383
- except Exception as e:
384
- print(f"YOLO detection error: {e}")
385
- return None
386
-
387
  def auto_detect_category(title):
388
  title_lower = title.lower().strip()
389
- category_map = {
390
- 'Electronics & Gadgets': ['laptop', 'computer', 'tablet', 'phone', 'iphone', 'samsung', 'charger', 'headphone', 'camera', 'watch'],
391
- 'Personal Items & Accessories': ['wallet', 'purse', 'bag', 'backpack', 'keys', 'glasses', 'sunglasses', 'umbrella'],
392
- 'Clothing & Wearables': ['shirt', 'jacket', 'hoodie', 'sweater', 'pants', 'jeans', 'shoe', 'hat'],
393
- 'Academic Materials': ['book', 'textbook', 'notebook', 'pen', 'pencil', 'calculator'],
394
- 'Documents & IDs': ['id', 'identification', 'passport', 'license', 'student id', 'card'],
395
- 'Food & Kitchen': ['bottle', 'lunchbox', 'cup', 'mug', 'water bottle'],
396
  }
397
- for category, keywords in category_map.items():
398
  for keyword in keywords:
399
  if keyword in title_lower:
400
  return category
401
- return 'Miscellaneous'
402
-
403
- def analyze_image_for_autofill(image_path):
404
- suggestions = {'item_name': None, 'category': None, 'color': None, 'brand': None, 'confidence_scores': {}}
405
- print(f"πŸ” Analyzing image: {image_path}")
406
-
407
- yolo_result = detect_item_with_yolo(image_path)
408
- if yolo_result:
409
- suggestions['item_name'] = yolo_result['item']
410
- suggestions['confidence_scores']['yolo'] = yolo_result['confidence']
411
- print(f"βœ… YOLO detected: {yolo_result['item']}")
412
-
413
- color_result = detect_color_with_opencv(image_path)
414
- if color_result:
415
- suggestions['color'] = color_result.title()
416
- suggestions['confidence_scores']['color'] = 0.85
417
- print(f"βœ… OpenCV detected color: {color_result}")
418
-
419
- if suggestions['item_name']:
420
- suggestions['category'] = auto_detect_category(suggestions['item_name'])
421
-
422
- return suggestions
423
 
424
  def compute_match_score(item_a, item_b):
425
  score = 0
@@ -453,8 +372,8 @@ def run_matching_engine():
453
  if score >= 40:
454
  db.execute("INSERT INTO matches (lost_item_id, found_item_id, score) VALUES (?,?,?)", (lost['id'], found['id'], score))
455
  db.commit()
456
- add_notification(lost['user_id'], f"Potential match found! ({score}%)", f"We found a possible match for your '{lost['title']}'.", 'match', f"/item/{found['id']}")
457
- add_notification(found['user_id'], f"Potential match found! ({score}%)", f"Your found item '{found['title']}' matches a lost report!", 'match', f"/item/{lost['id']}")
458
  db.close()
459
  except Exception as e:
460
  print(f"Matching engine error: {e}")
@@ -471,34 +390,32 @@ def index():
471
 
472
  @app.route('/login', methods=['GET', 'POST'])
473
  def login():
474
- if 'user_id' in session:
 
 
475
  return redirect(url_for('dashboard'))
476
 
477
  if request.method == 'POST':
478
  identifier = request.form.get('identifier', '').strip()
479
  password = request.form.get('password', '')
480
 
481
- if not identifier or not password:
482
- flash('Please enter both username/email and password.', 'danger')
483
- return render_template('login.html')
484
-
485
  user = query_db("SELECT * FROM users WHERE username=? OR email=?", [identifier, identifier], one=True)
486
 
487
  if user and bcrypt.check_password_hash(user['password_hash'], password):
488
  if user['is_banned']:
489
- flash('Your account has been suspended.', 'danger')
490
  return render_template('login.html')
491
 
492
- session.clear()
493
- session['user_id'] = user['id']
494
- session['username'] = user['username']
495
- session['role'] = user['role']
496
- session.permanent = True
497
-
498
  query_db("UPDATE users SET last_login=datetime('now') WHERE id=?", [user['id']], commit=True)
499
 
500
  flash(f'Welcome back, {user["username"]}!', 'success')
501
- return redirect(url_for('dashboard'))
 
 
 
 
502
  else:
503
  flash('Invalid credentials.', 'danger')
504
 
@@ -506,9 +423,6 @@ def login():
506
 
507
  @app.route('/register', methods=['GET', 'POST'])
508
  def register():
509
- if 'user_id' in session:
510
- return redirect(url_for('dashboard'))
511
-
512
  if request.method == 'POST':
513
  username = request.form.get('username', '').strip()
514
  email = request.form.get('email', '').strip()
@@ -516,7 +430,7 @@ def register():
516
  confirm = request.form.get('confirm_password', '')
517
 
518
  if not username or not email or not password:
519
- flash('All fields are required.', 'danger')
520
  return render_template('register.html')
521
 
522
  if password != confirm:
@@ -535,32 +449,46 @@ def register():
535
  pw_hash = bcrypt.generate_password_hash(password).decode('utf-8')
536
  user_id = query_db("INSERT INTO users (username, email, password_hash) VALUES (?,?,?)", [username, email, pw_hash], commit=True)
537
 
538
- session.clear()
539
- session['user_id'] = user_id
540
- session['username'] = username
541
- session['role'] = 'user'
542
- session.permanent = True
543
 
544
- add_notification(user_id, 'Welcome to Campus Recovery Network!', 'Your account has been created. Start by reporting a lost or found item.', 'success')
545
- flash('Registration successful! Welcome!', 'success')
546
- return redirect(url_for('dashboard'))
 
 
 
547
 
548
  return render_template('register.html')
549
 
550
  @app.route('/logout')
551
  def logout():
552
- session.clear()
553
- flash('You have been logged out.', 'info')
554
- return redirect(url_for('index'))
 
 
 
 
555
 
556
  @app.route('/dashboard')
557
- @login_required
558
  def dashboard():
559
- uid = session['user_id']
 
 
 
 
 
 
 
 
 
 
 
 
560
  my_items = query_db("SELECT * FROM items WHERE user_id=? ORDER BY created_at DESC LIMIT 10", [uid])
561
  my_matches = query_db("""
562
- SELECT m.*, li.title as lost_title, fi.title as found_title,
563
- li.id as lid, fi.id as fid
564
  FROM matches m
565
  JOIN items li ON m.lost_item_id = li.id
566
  JOIN items fi ON m.found_item_id = fi.id
@@ -574,13 +502,23 @@ def dashboard():
574
  'found': query_db("SELECT COUNT(*) as c FROM items WHERE user_id=? AND item_type='found'", [uid], one=True)['c'] or 0,
575
  'resolved': query_db("SELECT COUNT(*) as c FROM items WHERE user_id=? AND status='resolved'", [uid], one=True)['c'] or 0,
576
  }
577
- return render_template('dashboard.html', my_items=my_items, my_matches=my_matches, recent_found=recent_found, stats=stats, username=session.get('username'))
578
 
579
  @app.route('/report', methods=['GET', 'POST'])
580
- @login_required
581
  def report_item():
 
 
 
 
 
 
 
 
 
 
 
582
  if request.method == 'POST':
583
- uid = session['user_id']
584
  item_type = request.form.get('item_type', 'lost')
585
  title = request.form.get('title', '').strip()
586
  description = request.form.get('description', '').strip()
@@ -591,87 +529,76 @@ def report_item():
591
  brand = request.form.get('brand', '').strip()
592
 
593
  if not title or not location:
594
- flash('Title and location are required.', 'danger')
595
  return redirect(url_for('report_item'))
596
 
597
  image_path = None
598
- ai_item_name = None
599
- ai_brand_model = None
600
- ai_dominant_color = None
601
- ai_confidence_scores = {}
602
-
603
  if 'image' in request.files and request.files['image'].filename:
604
  file = request.files['image']
605
  if allowed_file(file.filename):
606
  image_path = save_upload(file)
607
- if image_path:
608
- full_path = os.path.join(app.config['UPLOAD_FOLDER'], image_path)
609
- suggestions = analyze_image_for_autofill(full_path)
610
- if suggestions:
611
- ai_item_name = suggestions.get('item_name')
612
- ai_brand_model = suggestions.get('brand')
613
- ai_dominant_color = suggestions.get('color')
614
- ai_confidence_scores = suggestions.get('confidence_scores', {})
615
- if not category and suggestions.get('category'):
616
- category = suggestions.get('category')
617
- if not color and suggestions.get('color'):
618
- color = suggestions.get('color')
619
 
620
  if not category:
621
  category = auto_detect_category(title)
622
 
623
  item_id = query_db(
624
  """INSERT INTO items (user_id, item_type, title, description, category, location,
625
- date_occurred, color, brand, image_path, ai_item_name, ai_brand_model,
626
- ai_dominant_color, ai_confidence_scores, source)
627
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
628
  [uid, item_type, title, description, category, location,
629
- date_occurred, color, brand, image_path, ai_item_name,
630
- ai_brand_model, ai_dominant_color, json.dumps(ai_confidence_scores), 'web'], commit=True
631
  )
632
 
633
- add_notification(uid, f"Item Reported: {title}", f"Your {item_type} item report has been submitted!", 'success', f'/item/{item_id}')
634
- flash(f'Your {item_type} item has been reported successfully!', 'success')
635
  return redirect(url_for('item_detail', item_id=item_id))
636
 
637
  return render_template('report.html')
638
 
639
  @app.route('/item/<int:item_id>')
640
- @login_required
641
  def item_detail(item_id):
 
 
 
 
 
 
 
642
  item = query_db("SELECT i.*, u.username FROM items i JOIN users u ON i.user_id=u.id WHERE i.id=?", [item_id], one=True)
643
  if not item:
644
  flash('Item not found.', 'danger')
645
  return redirect(url_for('search'))
646
 
647
- uid = session['user_id']
648
- is_owner = (item['user_id'] == uid)
649
 
650
  if item['item_type'] == 'lost':
651
  matches = query_db("""
652
- SELECT m.*, fi.title, fi.image_path, fi.location, fi.color, fi.brand, fi.id as matched_id, u.username as finder_username
653
- FROM matches m JOIN items fi ON m.found_item_id=fi.id JOIN users u ON fi.user_id=u.id
654
  WHERE m.lost_item_id=? ORDER BY m.score DESC
655
  """, [item_id])
656
  else:
657
  matches = query_db("""
658
- SELECT m.*, li.title, li.image_path, li.location, li.color, li.brand, li.id as matched_id, u.username as owner_username
659
- FROM matches m JOIN items li ON m.lost_item_id=li.id JOIN users u ON li.user_id=u.id
660
  WHERE m.found_item_id=? ORDER BY m.score DESC
661
  """, [item_id])
662
 
663
  claims = query_db("SELECT c.*, u.username FROM claims c JOIN users u ON c.claimant_id=u.id WHERE c.item_id=? ORDER BY c.created_at DESC", [item_id])
664
- user_claim = query_db("SELECT * FROM claims WHERE item_id=? AND claimant_id=?", [item_id, uid], one=True)
665
  return render_template('item_detail.html', item=item, is_owner=is_owner, matches=matches, claims=claims, user_claim=user_claim)
666
 
667
  @app.route('/search')
668
- @login_required
669
  def search():
 
 
 
 
 
670
  q = request.args.get('q', '').strip()
671
  item_type = request.args.get('type', '')
672
  category = request.args.get('category', '')
673
  location_filter = request.args.get('location', '')
674
- sort = request.args.get('sort', 'newest')
675
 
676
  conditions = ["i.status='active'"]
677
  params = []
@@ -685,247 +612,201 @@ def search():
685
  if location_filter:
686
  conditions.append("i.location LIKE ?"); params.append(f'%{location_filter}%')
687
 
688
- order = {'newest': 'i.created_at DESC', 'oldest': 'i.created_at ASC', 'title': 'i.title ASC'}.get(sort, 'i.created_at DESC')
689
  where = ' AND '.join(conditions)
690
- items = query_db(f"SELECT i.*, u.username FROM items i JOIN users u ON i.user_id=u.id WHERE {where} ORDER BY {order}", params)
691
  categories = query_db("SELECT DISTINCT category FROM items WHERE category IS NOT NULL AND category != ''")
692
- return render_template('search.html', items=items, query=q, item_type=item_type, category=category, location_filter=location_filter, sort=sort, categories=[r['category'] for r in categories])
693
 
694
  @app.route('/claim/<int:item_id>', methods=['POST'])
695
- @login_required
696
  def submit_claim(item_id):
697
- uid = session['user_id']
 
 
 
 
 
 
698
  item = query_db("SELECT * FROM items WHERE id=? AND item_type='found' AND status='active'", [item_id], one=True)
699
  if not item:
700
- flash('Item not found or not claimable.', 'danger')
701
  return redirect(url_for('item_detail', item_id=item_id))
702
- if item['user_id'] == uid:
703
- flash("You cannot claim your own item.", 'danger')
 
704
  return redirect(url_for('item_detail', item_id=item_id))
705
 
706
- existing = query_db("SELECT id FROM claims WHERE item_id=? AND claimant_id=?", [item_id, uid], one=True)
707
  if existing:
708
- flash('You already submitted a claim for this item.', 'warning')
709
  return redirect(url_for('item_detail', item_id=item_id))
710
 
711
  proof_text = request.form.get('proof_text', '').strip()
712
  proof_image = save_upload(request.files['proof_image']) if 'proof_image' in request.files else None
 
713
  if not proof_text:
714
- flash('Please provide proof of ownership.', 'danger')
715
  return redirect(url_for('item_detail', item_id=item_id))
716
 
717
- claim_id = query_db("INSERT INTO claims (item_id, claimant_id, proof_text, proof_image) VALUES (?,?,?,?)", [item_id, uid, proof_text, proof_image], commit=True)
718
- add_notification(item['user_id'], 'Someone claimed your item!', f"A user submitted a claim for '{item['title']}'.", 'warning', f'/item/{item_id}')
719
- add_notification(uid, 'Claim Submitted', f"Your claim for '{item['title']}' is under review.", 'info')
720
- flash('Your claim has been submitted!', 'success')
721
  return redirect(url_for('claim_chat', claim_id=claim_id))
722
 
723
  @app.route('/claim/<int:claim_id>/chat', methods=['GET', 'POST'])
724
- @login_required
725
  def claim_chat(claim_id):
726
- uid = session['user_id']
727
- claim = query_db("""SELECT c.*, i.title as item_title, i.user_id as finder_id, u.username as claimant_name, u2.username as finder_name
728
- FROM claims c JOIN items i ON c.item_id=i.id JOIN users u ON c.claimant_id=u.id JOIN users u2 ON i.user_id=u2.id WHERE c.id=?""", [claim_id], one=True)
 
 
 
 
 
 
 
 
 
 
 
 
729
  if not claim:
730
  flash('Claim not found.', 'danger')
731
  return redirect(url_for('dashboard'))
732
 
733
- if uid != claim['claimant_id'] and uid != claim['finder_id']:
734
  flash('Unauthorized.', 'danger')
735
  return redirect(url_for('dashboard'))
736
 
737
  if request.method == 'POST':
738
  msg = request.form.get('message', '').strip()
739
  if msg:
740
- query_db("INSERT INTO claim_messages (claim_id, sender_id, message) VALUES (?,?,?)", [claim_id, uid, msg], commit=True)
741
 
742
  messages = query_db("SELECT cm.*, u.username FROM claim_messages cm JOIN users u ON cm.sender_id=u.id WHERE cm.claim_id=? ORDER BY cm.created_at ASC", [claim_id])
743
  return render_template('claim_chat.html', claim=claim, messages=messages)
744
 
745
  @app.route('/notifications')
746
- @login_required
747
  def notifications():
748
- uid = session['user_id']
749
- notifs = query_db("SELECT * FROM notifications WHERE user_id=? ORDER BY created_at DESC", [uid])
750
- query_db("UPDATE notifications SET is_read=1 WHERE user_id=?", [uid], commit=True)
 
 
 
 
 
 
751
  return render_template('notifications.html', notifications=notifs)
752
 
753
  @app.route('/profile', methods=['GET', 'POST'])
754
- @login_required
755
  def profile():
756
- uid = session['user_id']
757
- user = query_db("SELECT * FROM users WHERE id=?", [uid], one=True)
 
 
 
 
 
 
 
758
  if request.method == 'POST':
759
  action = request.form.get('action')
760
  if action == 'update_prefs':
761
  query_db("UPDATE users SET email_notifications=?, match_alerts=?, claim_updates=? WHERE id=?",
762
- [1 if request.form.get('email_notifications') else 0, 1 if request.form.get('match_alerts') else 0, 1 if request.form.get('claim_updates') else 0, uid], commit=True)
 
 
763
  flash('Preferences updated!', 'success')
764
  elif action == 'change_password':
765
  current = request.form.get('current_password')
766
  new_pw = request.form.get('new_password')
767
  confirm = request.form.get('confirm_password')
768
  if not bcrypt.check_password_hash(user['password_hash'], current):
769
- flash('Current password is incorrect.', 'danger')
770
  elif new_pw != confirm:
771
  flash('Passwords do not match.', 'danger')
772
  elif len(new_pw) < 6:
773
- flash('Password must be at least 6 characters.', 'danger')
774
  else:
775
- query_db("UPDATE users SET password_hash=? WHERE id=?", [bcrypt.generate_password_hash(new_pw).decode('utf-8'), uid], commit=True)
776
- flash('Password changed successfully!', 'success')
777
  return redirect(url_for('profile'))
778
 
779
- my_items = query_db("SELECT * FROM items WHERE user_id=? ORDER BY created_at DESC", [uid])
780
- my_claims = query_db("SELECT c.*, i.title as item_title FROM claims c JOIN items i ON c.item_id=i.id WHERE c.claimant_id=? ORDER BY c.created_at DESC", [uid])
781
  return render_template('profile.html', user=user, my_items=my_items, my_claims=my_claims)
782
 
783
- # ── API Routes ────────────────────────────────────────────────────────────────
784
- @app.route('/api/analyze-image', methods=['POST'])
785
- @login_required
786
- def analyze_image_api():
787
- if 'image' not in request.files:
788
- return jsonify({'error': 'No image'}), 400
789
- f = request.files['image']
790
- if not allowed_file(f.filename):
791
- return jsonify({'error': 'Invalid file type'}), 400
792
-
793
- ext = f.filename.rsplit('.', 1)[1].lower()
794
- temp_filename = f"temp_{uuid.uuid4().hex}.{ext}"
795
- temp_path = os.path.join(app.config['UPLOAD_FOLDER'], temp_filename)
796
- f.save(temp_path)
797
-
798
- try:
799
- suggestions = analyze_image_for_autofill(temp_path)
800
- os.remove(temp_path)
801
-
802
- if suggestions.get('item_name') or suggestions.get('color'):
803
- return jsonify({'success': True, 'suggestions': suggestions})
804
- else:
805
- return jsonify({'success': False, 'message': 'Could not detect specific details.'})
806
- except Exception as e:
807
- try:
808
- os.remove(temp_path)
809
- except:
810
- pass
811
- return jsonify({'error': str(e)}), 500
812
-
813
- @app.route('/api/generate-description', methods=['POST'])
814
- @login_required
815
- def generate_description_api():
816
- data = request.get_json() or {}
817
- title = data.get('title', '')
818
- color = data.get('color', '')
819
- brand = data.get('brand', '')
820
- location = data.get('location', '')
821
- date_str = data.get('date', '')
822
- item_type = data.get('item_type', 'lost')
823
-
824
- if not title:
825
- return jsonify({'success': False, 'error': 'Title is required'}), 400
826
-
827
- action = "lost" if item_type == "lost" else "found"
828
- formatted_date = ''
829
- if date_str:
830
- try:
831
- date_obj = datetime.strptime(date_str, '%Y-%m-%d')
832
- formatted_date = date_obj.strftime('%B %d, %Y')
833
- except:
834
- formatted_date = date_str
835
-
836
- description_parts = []
837
- if brand and color:
838
- description_parts.append(f"This {color} {brand} {title}")
839
- elif brand:
840
- description_parts.append(f"This {brand} {title}")
841
- elif color:
842
- description_parts.append(f"This {color} {title}")
843
- else:
844
- description_parts.append(f"This {title}")
845
-
846
- if location and formatted_date:
847
- description_parts.append(f"was {action} at {location} on {formatted_date}.")
848
- elif location:
849
- description_parts.append(f"was {action} at {location}.")
850
- else:
851
- description_parts.append(f"was {action}.")
852
-
853
- description_parts.append(" Please contact if you have any information about this item.")
854
- full_description = " ".join(description_parts)
855
-
856
- return jsonify({'success': True, 'description': full_description})
857
-
858
- @app.route('/api/auto-category', methods=['POST'])
859
- @login_required
860
- def auto_category():
861
- data = request.get_json() or {}
862
- title = data.get('title', '')
863
- if title:
864
- return jsonify({'category': auto_detect_category(title)})
865
- return jsonify({'category': 'Miscellaneous'})
866
-
867
  @app.route('/api/notifications/count')
868
- @login_required
869
  def notif_count():
870
- count = query_db("SELECT COUNT(*) as c FROM notifications WHERE user_id=? AND is_read=0", [session['user_id']], one=True)['c'] or 0
 
 
 
 
871
  return jsonify({'count': count})
872
 
873
  @app.route('/rate', methods=['POST'])
874
- @login_required
875
  def submit_rating():
 
 
 
 
 
 
876
  score = int(request.form.get('score', 0))
877
  feedback = request.form.get('feedback', '').strip()
878
  if 1 <= score <= 5:
879
- query_db("INSERT INTO ratings (user_id, score, feedback) VALUES (?,?,?)", [session['user_id'], score, feedback], commit=True)
880
  flash('Thank you for your feedback!', 'success')
881
  return redirect(url_for('dashboard'))
882
 
883
  # ── Admin Routes ──────────────────────────────────────────────────────────────
884
  @app.route('/nimda')
885
  def admin_dashboard():
886
- if 'user_id' not in session:
 
 
887
  return redirect(url_for('login'))
888
-
889
- user = query_db("SELECT role FROM users WHERE id=?", [session['user_id']], one=True)
890
  if not user or user['role'] != 'admin':
891
  flash('Admin access required.', 'danger')
892
  return redirect(url_for('dashboard'))
893
-
894
  return render_template('admin.html')
895
 
896
  @app.route('/nimda/api/users')
897
  def admin_api_users():
898
- if 'user_id' not in session:
 
 
899
  return jsonify({'error': 'Unauthorized'}), 401
900
-
901
- admin_user = query_db("SELECT role FROM users WHERE id=?", [session['user_id']], one=True)
902
- if not admin_user or admin_user['role'] != 'admin':
903
  return jsonify({'error': 'Unauthorized'}), 401
904
-
905
  users = query_db("SELECT id, username, email, role, is_banned, created_at FROM users ORDER BY created_at DESC")
906
  return jsonify([dict(u) for u in users])
907
 
908
  @app.route('/nimda/api/items')
909
  def admin_api_items():
910
- if 'user_id' not in session:
 
 
911
  return jsonify({'error': 'Unauthorized'}), 401
912
-
913
- admin_user = query_db("SELECT role FROM users WHERE id=?", [session['user_id']], one=True)
914
- if not admin_user or admin_user['role'] != 'admin':
915
  return jsonify({'error': 'Unauthorized'}), 401
916
-
917
  items = query_db("SELECT i.*, u.username FROM items i JOIN users u ON i.user_id=u.id ORDER BY i.created_at DESC")
918
  return jsonify([dict(it) for it in items])
919
 
920
  @app.route('/nimda/api/claims')
921
  def admin_api_claims():
922
- if 'user_id' not in session:
 
 
923
  return jsonify({'error': 'Unauthorized'}), 401
924
-
925
- admin_user = query_db("SELECT role FROM users WHERE id=?", [session['user_id']], one=True)
926
- if not admin_user or admin_user['role'] != 'admin':
927
  return jsonify({'error': 'Unauthorized'}), 401
928
-
929
  claims = query_db("""
930
  SELECT c.*, i.title as item_title, u.username as claimant_name, u2.username as finder_name
931
  FROM claims c
@@ -938,13 +819,13 @@ def admin_api_claims():
938
 
939
  @app.route('/nimda/api/stats')
940
  def admin_api_stats():
941
- if 'user_id' not in session:
 
 
942
  return jsonify({'error': 'Unauthorized'}), 401
943
-
944
- admin_user = query_db("SELECT role FROM users WHERE id=?", [session['user_id']], one=True)
945
- if not admin_user or admin_user['role'] != 'admin':
946
  return jsonify({'error': 'Unauthorized'}), 401
947
-
948
  stats = {
949
  'total_users': query_db("SELECT COUNT(*) as c FROM users WHERE role='user'", one=True)['c'] or 0,
950
  'total_items': query_db("SELECT COUNT(*) as c FROM items", one=True)['c'] or 0,
@@ -953,105 +834,98 @@ def admin_api_stats():
953
  'resolved': query_db("SELECT COUNT(*) as c FROM items WHERE status='resolved'", one=True)['c'] or 0,
954
  'pending_claims': query_db("SELECT COUNT(*) as c FROM claims WHERE status='pending'", one=True)['c'] or 0,
955
  'total_matches': query_db("SELECT COUNT(*) as c FROM matches", one=True)['c'] or 0,
956
- 'banned_users': query_db("SELECT COUNT(*) as c FROM users WHERE is_banned=1", one=True)['c'] or 0,
957
  }
958
- total = stats['total_items']
959
- stats['recovery_rate'] = round((stats['resolved'] / total * 100) if total > 0 else 0, 1)
960
-
961
  return jsonify({'stats': stats})
962
 
963
  @app.route('/nimda/api/users/<int:user_id>/ban', methods=['POST'])
964
  def admin_api_ban_user(user_id):
965
- if 'user_id' not in session:
 
 
966
  return jsonify({'error': 'Unauthorized'}), 401
967
-
968
- admin_user = query_db("SELECT role FROM users WHERE id=?", [session['user_id']], one=True)
969
- if not admin_user or admin_user['role'] != 'admin':
970
  return jsonify({'error': 'Unauthorized'}), 401
971
-
972
- reason = (request.get_json() or {}).get('reason', 'Violation of terms')
973
- query_db("UPDATE users SET is_banned=1, ban_reason=? WHERE id=?", [reason, user_id], commit=True)
974
  return jsonify({'success': True})
975
 
976
  @app.route('/nimda/api/users/<int:user_id>/unban', methods=['POST'])
977
  def admin_api_unban_user(user_id):
978
- if 'user_id' not in session:
 
 
979
  return jsonify({'error': 'Unauthorized'}), 401
980
-
981
- admin_user = query_db("SELECT role FROM users WHERE id=?", [session['user_id']], one=True)
982
- if not admin_user or admin_user['role'] != 'admin':
983
  return jsonify({'error': 'Unauthorized'}), 401
984
-
985
- query_db("UPDATE users SET is_banned=0, ban_reason=NULL WHERE id=?", [user_id], commit=True)
986
  return jsonify({'success': True})
987
 
988
  @app.route('/nimda/api/users/<int:user_id>/delete', methods=['POST'])
989
  def admin_api_delete_user(user_id):
990
- if 'user_id' not in session:
 
 
991
  return jsonify({'error': 'Unauthorized'}), 401
992
-
993
- admin_user = query_db("SELECT role FROM users WHERE id=?", [session['user_id']], one=True)
994
- if not admin_user or admin_user['role'] != 'admin':
995
  return jsonify({'error': 'Unauthorized'}), 401
996
-
997
  query_db("DELETE FROM users WHERE id=?", [user_id], commit=True)
998
  return jsonify({'success': True})
999
 
1000
  @app.route('/nimda/api/items/<int:item_id>/delete', methods=['POST'])
1001
  def admin_api_delete_item(item_id):
1002
- if 'user_id' not in session:
 
 
1003
  return jsonify({'error': 'Unauthorized'}), 401
1004
-
1005
- admin_user = query_db("SELECT role FROM users WHERE id=?", [session['user_id']], one=True)
1006
- if not admin_user or admin_user['role'] != 'admin':
1007
  return jsonify({'error': 'Unauthorized'}), 401
1008
-
1009
  query_db("DELETE FROM items WHERE id=?", [item_id], commit=True)
1010
  return jsonify({'success': True})
1011
 
1012
  @app.route('/nimda/api/claims/<int:claim_id>/approve', methods=['POST'])
1013
  def admin_api_approve_claim(claim_id):
1014
- if 'user_id' not in session:
 
 
1015
  return jsonify({'error': 'Unauthorized'}), 401
1016
-
1017
- admin_user = query_db("SELECT role FROM users WHERE id=?", [session['user_id']], one=True)
1018
- if not admin_user or admin_user['role'] != 'admin':
1019
  return jsonify({'error': 'Unauthorized'}), 401
1020
-
1021
  query_db("UPDATE claims SET status='approved', resolved_at=datetime('now') WHERE id=?", [claim_id], commit=True)
1022
  claim = query_db("SELECT * FROM claims WHERE id=?", [claim_id], one=True)
1023
  if claim:
1024
  query_db("UPDATE items SET status='resolved' WHERE id=?", [claim['item_id']], commit=True)
1025
- add_notification(claim['claimant_id'], 'Claim Approved!', 'Your claim has been approved!', 'success')
1026
  return jsonify({'success': True})
1027
 
1028
  @app.route('/nimda/api/claims/<int:claim_id>/reject', methods=['POST'])
1029
  def admin_api_reject_claim(claim_id):
1030
- if 'user_id' not in session:
 
 
1031
  return jsonify({'error': 'Unauthorized'}), 401
1032
-
1033
- admin_user = query_db("SELECT role FROM users WHERE id=?", [session['user_id']], one=True)
1034
- if not admin_user or admin_user['role'] != 'admin':
1035
  return jsonify({'error': 'Unauthorized'}), 401
1036
-
1037
- note = (request.get_json() or {}).get('note', '')
1038
- query_db("UPDATE claims SET status='rejected', admin_note=?, resolved_at=datetime('now') WHERE id=?", [note, claim_id], commit=True)
1039
- claim = query_db("SELECT * FROM claims WHERE id=?", [claim_id], one=True)
1040
- if claim:
1041
- add_notification(claim['claimant_id'], 'Claim Rejected', f'Your claim was rejected.', 'danger')
1042
  return jsonify({'success': True})
1043
 
1044
  # ── Context processor ────────────────────────────────────────────────────────
1045
  @app.context_processor
1046
  def inject_globals():
1047
  unread = 0
1048
- if 'user_id' in session:
1049
- try:
1050
- row = query_db("SELECT COUNT(*) as c FROM notifications WHERE user_id=? AND is_read=0", [session['user_id']], one=True)
1051
- unread = row['c'] if row else 0
1052
- except:
1053
- pass
1054
- return {'unread_count': unread, 'current_year': datetime.now().year, 'session': session}
 
 
 
1055
 
1056
  # ── Boot ─────────────────────────────────────────────────────────────────────
1057
  if __name__ == '__main__':
 
1
  """
2
  Campus Recovery Network - Main Application
3
+ Complete working version for Hugging Face Spaces with database sessions
4
  """
5
 
6
  import os
 
12
  import re
13
  import csv
14
  import io
15
+ import secrets
16
  from datetime import datetime, timedelta
17
  from functools import wraps
18
+ from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, g, send_file, make_response
19
  from flask_bcrypt import Bcrypt
20
  from flask_cors import CORS
21
  from werkzeug.utils import secure_filename
22
 
23
  # ── Configuration ─────────────────────────────────────────────────────────────
24
+ SECRET_KEY = os.environ.get('SECRET_KEY', 'campus_recovery_secret_key_2024')
25
  UPLOAD_FOLDER = 'static/uploads'
26
  MAX_CONTENT_LENGTH = 16 * 1024 * 1024
27
 
 
32
  os.makedirs(UPLOAD_FOLDER, exist_ok=True)
33
  DATABASE = 'campus_recovery.db'
34
 
35
+ # ── Optional AI libs ─────────────────────────────────────────────────────────
36
  try:
37
  import numpy as np
 
38
  except ImportError:
39
+ np = None
40
 
41
  try:
42
  import cv2
 
43
  except ImportError:
44
+ cv2 = None
45
 
46
  try:
47
  from PIL import Image
 
48
  except ImportError:
49
+ Image = None
50
 
51
  try:
52
  from ultralytics import YOLO
 
53
  yolo_model = YOLO('yolov8n.pt')
54
+ YOLO_OK = True
55
+ except Exception:
 
56
  yolo_model = None
57
+ YOLO_OK = False
58
 
59
  # ── App setup ───────────────────────────────────────────────────────────────
60
  app = Flask(__name__)
61
  app.secret_key = SECRET_KEY
62
  app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
63
  app.config['MAX_CONTENT_LENGTH'] = MAX_CONTENT_LENGTH
 
 
 
 
 
64
 
65
  bcrypt = Bcrypt(app)
66
  CORS(app, supports_credentials=True)
67
 
68
  ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  # ── Database helpers ─────────────────────────────────────────────────────────
71
  def get_db():
72
  db = getattr(g, '_database', None)
 
97
  db.rollback()
98
  raise e
99
 
100
+ # ── Session Management (Database-based) ──────────────────────────────────────
101
+ def create_session_token(user_id):
102
+ """Create a new session token and store in database"""
103
+ token = secrets.token_urlsafe(32)
104
+ expires_at = datetime.now() + timedelta(days=30)
105
+ query_db(
106
+ "INSERT INTO user_sessions (session_token, user_id, expires_at) VALUES (?, ?, ?)",
107
+ [token, user_id, expires_at.isoformat()],
108
+ commit=True
109
+ )
110
+ return token
111
+
112
+ def validate_session_token(token):
113
+ """Validate session token and return user_id if valid"""
114
+ if not token:
115
+ return None
116
+ session = query_db(
117
+ "SELECT user_id, expires_at FROM user_sessions WHERE session_token = ?",
118
+ [token],
119
+ one=True
120
+ )
121
+ if not session:
122
+ return None
123
+ expires_at = datetime.fromisoformat(session['expires_at'])
124
+ if expires_at < datetime.now():
125
+ query_db("DELETE FROM user_sessions WHERE session_token = ?", [token], commit=True)
126
+ return None
127
+ return session['user_id']
128
+
129
+ def delete_session_token(token):
130
+ """Delete a session token (logout)"""
131
+ if token:
132
+ query_db("DELETE FROM user_sessions WHERE session_token = ?", [token], commit=True)
133
+
134
+ # ── Login Required Decorator ─────────────────────────────────────────────────
135
+ def login_required(f):
136
+ @wraps(f)
137
+ def decorated_function(*args, **kwargs):
138
+ # First try to get token from cookie
139
+ token = request.cookies.get('session_token')
140
+ user_id = validate_session_token(token) if token else None
141
+
142
+ # If not found, try URL parameter (fallback for HF Spaces)
143
+ if not user_id:
144
+ token = request.args.get('token')
145
+ user_id = validate_session_token(token) if token else None
146
+
147
+ if not user_id:
148
+ flash('Please log in to continue.', 'warning')
149
+ return redirect(url_for('login'))
150
+
151
+ # Get user info
152
+ user = query_db("SELECT * FROM users WHERE id=? AND is_banned=0", [user_id], one=True)
153
+ if not user:
154
+ flash('Please log in again.', 'warning')
155
+ return redirect(url_for('login'))
156
+
157
+ g.user_id = user_id
158
+ g.username = user['username']
159
+ g.user = user
160
+ return f(*args, **kwargs)
161
+ return decorated_function
162
+
163
+ # ── Database Schema (with sessions table) ─────────────────────────────────────
164
  def init_db():
165
  with app.app_context():
166
  db = get_db()
 
173
  role TEXT DEFAULT 'user',
174
  is_banned INTEGER DEFAULT 0,
175
  ban_reason TEXT,
 
176
  email_notifications INTEGER DEFAULT 1,
177
  match_alerts INTEGER DEFAULT 1,
178
  claim_updates INTEGER DEFAULT 1,
 
181
  last_login TEXT
182
  );
183
 
184
+ CREATE TABLE IF NOT EXISTS user_sessions (
185
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
186
+ session_token TEXT UNIQUE NOT NULL,
187
+ user_id INTEGER NOT NULL,
188
+ created_at TEXT DEFAULT (datetime('now')),
189
+ expires_at TEXT NOT NULL,
190
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
191
+ );
192
+
193
  CREATE TABLE IF NOT EXISTS items (
194
  id INTEGER PRIMARY KEY AUTOINCREMENT,
195
  user_id INTEGER NOT NULL,
 
203
  brand TEXT,
204
  image_path TEXT,
205
  status TEXT DEFAULT 'active' CHECK(status IN ('active','resolved','claimed')),
 
 
206
  source TEXT DEFAULT 'web',
207
  ai_item_name TEXT,
208
  ai_brand_model TEXT,
 
218
  lost_item_id INTEGER NOT NULL,
219
  found_item_id INTEGER NOT NULL,
220
  score REAL DEFAULT 0,
221
+ status TEXT DEFAULT 'pending',
222
  created_at TEXT DEFAULT (datetime('now')),
223
  FOREIGN KEY (lost_item_id) REFERENCES items(id) ON DELETE CASCADE,
224
  FOREIGN KEY (found_item_id) REFERENCES items(id) ON DELETE CASCADE
 
230
  claimant_id INTEGER NOT NULL,
231
  proof_text TEXT NOT NULL,
232
  proof_image TEXT,
233
+ status TEXT DEFAULT 'pending',
234
  admin_note TEXT,
235
  created_at TEXT DEFAULT (datetime('now')),
236
  resolved_at TEXT,
 
261
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
262
  );
263
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  CREATE TABLE IF NOT EXISTS ratings (
265
  id INTEGER PRIMARY KEY AUTOINCREMENT,
266
  user_id INTEGER NOT NULL,
 
269
  created_at TEXT DEFAULT (datetime('now')),
270
  FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
271
  );
 
 
 
 
 
 
272
  """)
273
  db.commit()
274
 
275
+ # Add missing columns if needed
276
+ try:
277
+ db.execute("ALTER TABLE items ADD COLUMN ai_item_name TEXT")
278
+ except sqlite3.OperationalError:
279
+ pass
280
+ try:
281
+ db.execute("ALTER TABLE items ADD COLUMN ai_brand_model TEXT")
282
+ except sqlite3.OperationalError:
283
+ pass
284
+ try:
285
+ db.execute("ALTER TABLE items ADD COLUMN ai_dominant_color TEXT")
286
+ except sqlite3.OperationalError:
287
+ pass
288
+ try:
289
+ db.execute("ALTER TABLE items ADD COLUMN ai_confidence_scores TEXT")
290
+ except sqlite3.OperationalError:
291
+ pass
292
 
293
+ # Create admin user
294
  existing_admin = db.execute("SELECT id FROM users WHERE username=?", (ADMIN_USERNAME,)).fetchone()
295
  if not existing_admin:
296
  pw_hash = bcrypt.generate_password_hash(ADMIN_PASSWORD_RAW).decode('utf-8')
 
314
  return filename
315
  return None
316
 
317
+ def add_notification(user_id, title, message, notif_type='info', link=None):
318
  try:
319
  query_db(
320
+ "INSERT INTO notifications (user_id, title, message, type, link) VALUES (?,?,?,?,?)",
321
+ [user_id, title, message, notif_type, link],
322
  commit=True
323
  )
324
  except Exception as e:
325
  print(f"Error adding notification: {e}")
326
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
327
  def auto_detect_category(title):
328
  title_lower = title.lower().strip()
329
+ categories = {
330
+ 'Electronics': ['laptop', 'computer', 'phone', 'iphone', 'samsung', 'charger', 'headphone', 'camera', 'watch'],
331
+ 'Accessories': ['wallet', 'purse', 'bag', 'backpack', 'keys', 'glasses', 'sunglasses', 'umbrella'],
332
+ 'Clothing': ['shirt', 'jacket', 'hoodie', 'sweater', 'pants', 'jeans', 'shoe', 'hat'],
333
+ 'Academic': ['book', 'textbook', 'notebook', 'pen', 'pencil', 'calculator'],
334
+ 'Documents': ['id', 'identification', 'passport', 'license', 'card'],
335
+ 'Food & Drink': ['bottle', 'lunchbox', 'cup', 'mug', 'water bottle'],
336
  }
337
+ for category, keywords in categories.items():
338
  for keyword in keywords:
339
  if keyword in title_lower:
340
  return category
341
+ return 'Other'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
 
343
  def compute_match_score(item_a, item_b):
344
  score = 0
 
372
  if score >= 40:
373
  db.execute("INSERT INTO matches (lost_item_id, found_item_id, score) VALUES (?,?,?)", (lost['id'], found['id'], score))
374
  db.commit()
375
+ add_notification(lost['user_id'], f"Potential match! ({score}%)", f"We found a match for '{lost['title']}'", 'match', f"/item/{found['id']}")
376
+ add_notification(found['user_id'], f"Potential match! ({score}%)", f"Your found item matches '{lost['title']}'", 'match', f"/item/{lost['id']}")
377
  db.close()
378
  except Exception as e:
379
  print(f"Matching engine error: {e}")
 
390
 
391
  @app.route('/login', methods=['GET', 'POST'])
392
  def login():
393
+ # Check if already logged in via cookie
394
+ token = request.cookies.get('session_token')
395
+ if token and validate_session_token(token):
396
  return redirect(url_for('dashboard'))
397
 
398
  if request.method == 'POST':
399
  identifier = request.form.get('identifier', '').strip()
400
  password = request.form.get('password', '')
401
 
 
 
 
 
402
  user = query_db("SELECT * FROM users WHERE username=? OR email=?", [identifier, identifier], one=True)
403
 
404
  if user and bcrypt.check_password_hash(user['password_hash'], password):
405
  if user['is_banned']:
406
+ flash('Account suspended.', 'danger')
407
  return render_template('login.html')
408
 
409
+ # Create session token
410
+ token = create_session_token(user['id'])
 
 
 
 
411
  query_db("UPDATE users SET last_login=datetime('now') WHERE id=?", [user['id']], commit=True)
412
 
413
  flash(f'Welcome back, {user["username"]}!', 'success')
414
+
415
+ # Create response with cookie
416
+ resp = make_response(redirect(url_for('dashboard')))
417
+ resp.set_cookie('session_token', token, max_age=30*24*60*60, httponly=True, samesite='Lax', path='/')
418
+ return resp
419
  else:
420
  flash('Invalid credentials.', 'danger')
421
 
 
423
 
424
  @app.route('/register', methods=['GET', 'POST'])
425
  def register():
 
 
 
426
  if request.method == 'POST':
427
  username = request.form.get('username', '').strip()
428
  email = request.form.get('email', '').strip()
 
430
  confirm = request.form.get('confirm_password', '')
431
 
432
  if not username or not email or not password:
433
+ flash('All fields required.', 'danger')
434
  return render_template('register.html')
435
 
436
  if password != confirm:
 
449
  pw_hash = bcrypt.generate_password_hash(password).decode('utf-8')
450
  user_id = query_db("INSERT INTO users (username, email, password_hash) VALUES (?,?,?)", [username, email, pw_hash], commit=True)
451
 
452
+ # Create session token
453
+ token = create_session_token(user_id)
 
 
 
454
 
455
+ add_notification(user_id, 'Welcome!', 'Your account has been created.', 'success')
456
+ flash('Registration successful!', 'success')
457
+
458
+ resp = make_response(redirect(url_for('dashboard')))
459
+ resp.set_cookie('session_token', token, max_age=30*24*60*60, httponly=True, samesite='Lax', path='/')
460
+ return resp
461
 
462
  return render_template('register.html')
463
 
464
  @app.route('/logout')
465
  def logout():
466
+ token = request.cookies.get('session_token')
467
+ if token:
468
+ delete_session_token(token)
469
+ resp = make_response(redirect(url_for('index')))
470
+ resp.set_cookie('session_token', '', expires=0, path='/')
471
+ flash('Logged out.', 'info')
472
+ return resp
473
 
474
  @app.route('/dashboard')
 
475
  def dashboard():
476
+ token = request.cookies.get('session_token')
477
+ user_id = validate_session_token(token) if token else None
478
+
479
+ if not user_id:
480
+ flash('Please log in.', 'warning')
481
+ return redirect(url_for('login'))
482
+
483
+ user = query_db("SELECT * FROM users WHERE id=? AND is_banned=0", [user_id], one=True)
484
+ if not user:
485
+ flash('Please log in.', 'warning')
486
+ return redirect(url_for('login'))
487
+
488
+ uid = user['id']
489
  my_items = query_db("SELECT * FROM items WHERE user_id=? ORDER BY created_at DESC LIMIT 10", [uid])
490
  my_matches = query_db("""
491
+ SELECT m.*, li.title as lost_title, fi.title as found_title, li.id as lid, fi.id as fid
 
492
  FROM matches m
493
  JOIN items li ON m.lost_item_id = li.id
494
  JOIN items fi ON m.found_item_id = fi.id
 
502
  'found': query_db("SELECT COUNT(*) as c FROM items WHERE user_id=? AND item_type='found'", [uid], one=True)['c'] or 0,
503
  'resolved': query_db("SELECT COUNT(*) as c FROM items WHERE user_id=? AND status='resolved'", [uid], one=True)['c'] or 0,
504
  }
505
+ return render_template('dashboard.html', my_items=my_items, my_matches=my_matches, recent_found=recent_found, stats=stats, username=user['username'])
506
 
507
  @app.route('/report', methods=['GET', 'POST'])
 
508
  def report_item():
509
+ token = request.cookies.get('session_token')
510
+ user_id = validate_session_token(token) if token else None
511
+
512
+ if not user_id:
513
+ flash('Please log in.', 'warning')
514
+ return redirect(url_for('login'))
515
+
516
+ user = query_db("SELECT * FROM users WHERE id=? AND is_banned=0", [user_id], one=True)
517
+ if not user:
518
+ return redirect(url_for('login'))
519
+
520
  if request.method == 'POST':
521
+ uid = user['id']
522
  item_type = request.form.get('item_type', 'lost')
523
  title = request.form.get('title', '').strip()
524
  description = request.form.get('description', '').strip()
 
529
  brand = request.form.get('brand', '').strip()
530
 
531
  if not title or not location:
532
+ flash('Title and location required.', 'danger')
533
  return redirect(url_for('report_item'))
534
 
535
  image_path = None
 
 
 
 
 
536
  if 'image' in request.files and request.files['image'].filename:
537
  file = request.files['image']
538
  if allowed_file(file.filename):
539
  image_path = save_upload(file)
 
 
 
 
 
 
 
 
 
 
 
 
540
 
541
  if not category:
542
  category = auto_detect_category(title)
543
 
544
  item_id = query_db(
545
  """INSERT INTO items (user_id, item_type, title, description, category, location,
546
+ date_occurred, color, brand, image_path, source)
547
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
 
548
  [uid, item_type, title, description, category, location,
549
+ date_occurred, color, brand, image_path, 'web'], commit=True
 
550
  )
551
 
552
+ add_notification(uid, f"Item Reported: {title}", f"Your {item_type} item has been reported.", 'success', f'/item/{item_id}')
553
+ flash(f'Item reported successfully!', 'success')
554
  return redirect(url_for('item_detail', item_id=item_id))
555
 
556
  return render_template('report.html')
557
 
558
  @app.route('/item/<int:item_id>')
 
559
  def item_detail(item_id):
560
+ token = request.cookies.get('session_token')
561
+ user_id = validate_session_token(token) if token else None
562
+
563
+ if not user_id:
564
+ flash('Please log in.', 'warning')
565
+ return redirect(url_for('login'))
566
+
567
  item = query_db("SELECT i.*, u.username FROM items i JOIN users u ON i.user_id=u.id WHERE i.id=?", [item_id], one=True)
568
  if not item:
569
  flash('Item not found.', 'danger')
570
  return redirect(url_for('search'))
571
 
572
+ is_owner = (item['user_id'] == user_id)
 
573
 
574
  if item['item_type'] == 'lost':
575
  matches = query_db("""
576
+ SELECT m.*, fi.title, fi.image_path, fi.location, fi.color, fi.brand, fi.id as matched_id
577
+ FROM matches m JOIN items fi ON m.found_item_id=fi.id
578
  WHERE m.lost_item_id=? ORDER BY m.score DESC
579
  """, [item_id])
580
  else:
581
  matches = query_db("""
582
+ SELECT m.*, li.title, li.image_path, li.location, li.color, li.brand, li.id as matched_id
583
+ FROM matches m JOIN items li ON m.lost_item_id=li.id
584
  WHERE m.found_item_id=? ORDER BY m.score DESC
585
  """, [item_id])
586
 
587
  claims = query_db("SELECT c.*, u.username FROM claims c JOIN users u ON c.claimant_id=u.id WHERE c.item_id=? ORDER BY c.created_at DESC", [item_id])
588
+ user_claim = query_db("SELECT * FROM claims WHERE item_id=? AND claimant_id=?", [item_id, user_id], one=True)
589
  return render_template('item_detail.html', item=item, is_owner=is_owner, matches=matches, claims=claims, user_claim=user_claim)
590
 
591
  @app.route('/search')
 
592
  def search():
593
+ token = request.cookies.get('session_token')
594
+ if not token or not validate_session_token(token):
595
+ flash('Please log in.', 'warning')
596
+ return redirect(url_for('login'))
597
+
598
  q = request.args.get('q', '').strip()
599
  item_type = request.args.get('type', '')
600
  category = request.args.get('category', '')
601
  location_filter = request.args.get('location', '')
 
602
 
603
  conditions = ["i.status='active'"]
604
  params = []
 
612
  if location_filter:
613
  conditions.append("i.location LIKE ?"); params.append(f'%{location_filter}%')
614
 
 
615
  where = ' AND '.join(conditions)
616
+ items = query_db(f"SELECT i.*, u.username FROM items i JOIN users u ON i.user_id=u.id WHERE {where} ORDER BY i.created_at DESC", params)
617
  categories = query_db("SELECT DISTINCT category FROM items WHERE category IS NOT NULL AND category != ''")
618
+ return render_template('search.html', items=items, query=q, item_type=item_type, category=category, location_filter=location_filter, categories=[r['category'] for r in categories])
619
 
620
  @app.route('/claim/<int:item_id>', methods=['POST'])
 
621
  def submit_claim(item_id):
622
+ token = request.cookies.get('session_token')
623
+ user_id = validate_session_token(token) if token else None
624
+
625
+ if not user_id:
626
+ flash('Please log in.', 'warning')
627
+ return redirect(url_for('login'))
628
+
629
  item = query_db("SELECT * FROM items WHERE id=? AND item_type='found' AND status='active'", [item_id], one=True)
630
  if not item:
631
+ flash('Item not claimable.', 'danger')
632
  return redirect(url_for('item_detail', item_id=item_id))
633
+
634
+ if item['user_id'] == user_id:
635
+ flash("Cannot claim your own item.", 'danger')
636
  return redirect(url_for('item_detail', item_id=item_id))
637
 
638
+ existing = query_db("SELECT id FROM claims WHERE item_id=? AND claimant_id=?", [item_id, user_id], one=True)
639
  if existing:
640
+ flash('You already submitted a claim.', 'warning')
641
  return redirect(url_for('item_detail', item_id=item_id))
642
 
643
  proof_text = request.form.get('proof_text', '').strip()
644
  proof_image = save_upload(request.files['proof_image']) if 'proof_image' in request.files else None
645
+
646
  if not proof_text:
647
+ flash('Proof required.', 'danger')
648
  return redirect(url_for('item_detail', item_id=item_id))
649
 
650
+ claim_id = query_db("INSERT INTO claims (item_id, claimant_id, proof_text, proof_image) VALUES (?,?,?,?)", [item_id, user_id, proof_text, proof_image], commit=True)
651
+ add_notification(item['user_id'], 'Claim Received', f"Someone claimed '{item['title']}'", 'warning', f'/item/{item_id}')
652
+ add_notification(user_id, 'Claim Submitted', f"Your claim for '{item['title']}' is under review.", 'info')
653
+ flash('Claim submitted!', 'success')
654
  return redirect(url_for('claim_chat', claim_id=claim_id))
655
 
656
  @app.route('/claim/<int:claim_id>/chat', methods=['GET', 'POST'])
 
657
  def claim_chat(claim_id):
658
+ token = request.cookies.get('session_token')
659
+ user_id = validate_session_token(token) if token else None
660
+
661
+ if not user_id:
662
+ flash('Please log in.', 'warning')
663
+ return redirect(url_for('login'))
664
+
665
+ claim = query_db("""SELECT c.*, i.title as item_title, i.user_id as finder_id,
666
+ u.username as claimant_name, u2.username as finder_name
667
+ FROM claims c
668
+ JOIN items i ON c.item_id=i.id
669
+ JOIN users u ON c.claimant_id=u.id
670
+ JOIN users u2 ON i.user_id=u2.id
671
+ WHERE c.id=?""", [claim_id], one=True)
672
+
673
  if not claim:
674
  flash('Claim not found.', 'danger')
675
  return redirect(url_for('dashboard'))
676
 
677
+ if user_id != claim['claimant_id'] and user_id != claim['finder_id']:
678
  flash('Unauthorized.', 'danger')
679
  return redirect(url_for('dashboard'))
680
 
681
  if request.method == 'POST':
682
  msg = request.form.get('message', '').strip()
683
  if msg:
684
+ query_db("INSERT INTO claim_messages (claim_id, sender_id, message) VALUES (?,?,?)", [claim_id, user_id, msg], commit=True)
685
 
686
  messages = query_db("SELECT cm.*, u.username FROM claim_messages cm JOIN users u ON cm.sender_id=u.id WHERE cm.claim_id=? ORDER BY cm.created_at ASC", [claim_id])
687
  return render_template('claim_chat.html', claim=claim, messages=messages)
688
 
689
  @app.route('/notifications')
 
690
  def notifications():
691
+ token = request.cookies.get('session_token')
692
+ user_id = validate_session_token(token) if token else None
693
+
694
+ if not user_id:
695
+ flash('Please log in.', 'warning')
696
+ return redirect(url_for('login'))
697
+
698
+ notifs = query_db("SELECT * FROM notifications WHERE user_id=? ORDER BY created_at DESC", [user_id])
699
+ query_db("UPDATE notifications SET is_read=1 WHERE user_id=?", [user_id], commit=True)
700
  return render_template('notifications.html', notifications=notifs)
701
 
702
  @app.route('/profile', methods=['GET', 'POST'])
 
703
  def profile():
704
+ token = request.cookies.get('session_token')
705
+ user_id = validate_session_token(token) if token else None
706
+
707
+ if not user_id:
708
+ flash('Please log in.', 'warning')
709
+ return redirect(url_for('login'))
710
+
711
+ user = query_db("SELECT * FROM users WHERE id=?", [user_id], one=True)
712
+
713
  if request.method == 'POST':
714
  action = request.form.get('action')
715
  if action == 'update_prefs':
716
  query_db("UPDATE users SET email_notifications=?, match_alerts=?, claim_updates=? WHERE id=?",
717
+ [1 if request.form.get('email_notifications') else 0,
718
+ 1 if request.form.get('match_alerts') else 0,
719
+ 1 if request.form.get('claim_updates') else 0, user_id], commit=True)
720
  flash('Preferences updated!', 'success')
721
  elif action == 'change_password':
722
  current = request.form.get('current_password')
723
  new_pw = request.form.get('new_password')
724
  confirm = request.form.get('confirm_password')
725
  if not bcrypt.check_password_hash(user['password_hash'], current):
726
+ flash('Current password incorrect.', 'danger')
727
  elif new_pw != confirm:
728
  flash('Passwords do not match.', 'danger')
729
  elif len(new_pw) < 6:
730
+ flash('Password must be 6+ characters.', 'danger')
731
  else:
732
+ query_db("UPDATE users SET password_hash=? WHERE id=?", [bcrypt.generate_password_hash(new_pw).decode('utf-8'), user_id], commit=True)
733
+ flash('Password changed!', 'success')
734
  return redirect(url_for('profile'))
735
 
736
+ my_items = query_db("SELECT * FROM items WHERE user_id=? ORDER BY created_at DESC", [user_id])
737
+ my_claims = query_db("SELECT c.*, i.title as item_title FROM claims c JOIN items i ON c.item_id=i.id WHERE c.claimant_id=? ORDER BY c.created_at DESC", [user_id])
738
  return render_template('profile.html', user=user, my_items=my_items, my_claims=my_claims)
739
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
740
  @app.route('/api/notifications/count')
 
741
  def notif_count():
742
+ token = request.cookies.get('session_token')
743
+ user_id = validate_session_token(token) if token else None
744
+ if not user_id:
745
+ return jsonify({'count': 0})
746
+ count = query_db("SELECT COUNT(*) as c FROM notifications WHERE user_id=? AND is_read=0", [user_id], one=True)['c'] or 0
747
  return jsonify({'count': count})
748
 
749
  @app.route('/rate', methods=['POST'])
 
750
  def submit_rating():
751
+ token = request.cookies.get('session_token')
752
+ user_id = validate_session_token(token) if token else None
753
+ if not user_id:
754
+ flash('Please log in.', 'warning')
755
+ return redirect(url_for('login'))
756
+
757
  score = int(request.form.get('score', 0))
758
  feedback = request.form.get('feedback', '').strip()
759
  if 1 <= score <= 5:
760
+ query_db("INSERT INTO ratings (user_id, score, feedback) VALUES (?,?,?)", [user_id, score, feedback], commit=True)
761
  flash('Thank you for your feedback!', 'success')
762
  return redirect(url_for('dashboard'))
763
 
764
  # ── Admin Routes ──────────────────────────────────────────────────────────────
765
  @app.route('/nimda')
766
  def admin_dashboard():
767
+ token = request.cookies.get('session_token')
768
+ user_id = validate_session_token(token) if token else None
769
+ if not user_id:
770
  return redirect(url_for('login'))
771
+ user = query_db("SELECT role FROM users WHERE id=?", [user_id], one=True)
 
772
  if not user or user['role'] != 'admin':
773
  flash('Admin access required.', 'danger')
774
  return redirect(url_for('dashboard'))
 
775
  return render_template('admin.html')
776
 
777
  @app.route('/nimda/api/users')
778
  def admin_api_users():
779
+ token = request.cookies.get('session_token')
780
+ user_id = validate_session_token(token) if token else None
781
+ if not user_id:
782
  return jsonify({'error': 'Unauthorized'}), 401
783
+ admin = query_db("SELECT role FROM users WHERE id=?", [user_id], one=True)
784
+ if not admin or admin['role'] != 'admin':
 
785
  return jsonify({'error': 'Unauthorized'}), 401
 
786
  users = query_db("SELECT id, username, email, role, is_banned, created_at FROM users ORDER BY created_at DESC")
787
  return jsonify([dict(u) for u in users])
788
 
789
  @app.route('/nimda/api/items')
790
  def admin_api_items():
791
+ token = request.cookies.get('session_token')
792
+ user_id = validate_session_token(token) if token else None
793
+ if not user_id:
794
  return jsonify({'error': 'Unauthorized'}), 401
795
+ admin = query_db("SELECT role FROM users WHERE id=?", [user_id], one=True)
796
+ if not admin or admin['role'] != 'admin':
 
797
  return jsonify({'error': 'Unauthorized'}), 401
 
798
  items = query_db("SELECT i.*, u.username FROM items i JOIN users u ON i.user_id=u.id ORDER BY i.created_at DESC")
799
  return jsonify([dict(it) for it in items])
800
 
801
  @app.route('/nimda/api/claims')
802
  def admin_api_claims():
803
+ token = request.cookies.get('session_token')
804
+ user_id = validate_session_token(token) if token else None
805
+ if not user_id:
806
  return jsonify({'error': 'Unauthorized'}), 401
807
+ admin = query_db("SELECT role FROM users WHERE id=?", [user_id], one=True)
808
+ if not admin or admin['role'] != 'admin':
 
809
  return jsonify({'error': 'Unauthorized'}), 401
 
810
  claims = query_db("""
811
  SELECT c.*, i.title as item_title, u.username as claimant_name, u2.username as finder_name
812
  FROM claims c
 
819
 
820
  @app.route('/nimda/api/stats')
821
  def admin_api_stats():
822
+ token = request.cookies.get('session_token')
823
+ user_id = validate_session_token(token) if token else None
824
+ if not user_id:
825
  return jsonify({'error': 'Unauthorized'}), 401
826
+ admin = query_db("SELECT role FROM users WHERE id=?", [user_id], one=True)
827
+ if not admin or admin['role'] != 'admin':
 
828
  return jsonify({'error': 'Unauthorized'}), 401
 
829
  stats = {
830
  'total_users': query_db("SELECT COUNT(*) as c FROM users WHERE role='user'", one=True)['c'] or 0,
831
  'total_items': query_db("SELECT COUNT(*) as c FROM items", one=True)['c'] or 0,
 
834
  'resolved': query_db("SELECT COUNT(*) as c FROM items WHERE status='resolved'", one=True)['c'] or 0,
835
  'pending_claims': query_db("SELECT COUNT(*) as c FROM claims WHERE status='pending'", one=True)['c'] or 0,
836
  'total_matches': query_db("SELECT COUNT(*) as c FROM matches", one=True)['c'] or 0,
 
837
  }
 
 
 
838
  return jsonify({'stats': stats})
839
 
840
  @app.route('/nimda/api/users/<int:user_id>/ban', methods=['POST'])
841
  def admin_api_ban_user(user_id):
842
+ token = request.cookies.get('session_token')
843
+ user_id_admin = validate_session_token(token) if token else None
844
+ if not user_id_admin:
845
  return jsonify({'error': 'Unauthorized'}), 401
846
+ admin = query_db("SELECT role FROM users WHERE id=?", [user_id_admin], one=True)
847
+ if not admin or admin['role'] != 'admin':
 
848
  return jsonify({'error': 'Unauthorized'}), 401
849
+ query_db("UPDATE users SET is_banned=1 WHERE id=?", [user_id], commit=True)
 
 
850
  return jsonify({'success': True})
851
 
852
  @app.route('/nimda/api/users/<int:user_id>/unban', methods=['POST'])
853
  def admin_api_unban_user(user_id):
854
+ token = request.cookies.get('session_token')
855
+ user_id_admin = validate_session_token(token) if token else None
856
+ if not user_id_admin:
857
  return jsonify({'error': 'Unauthorized'}), 401
858
+ admin = query_db("SELECT role FROM users WHERE id=?", [user_id_admin], one=True)
859
+ if not admin or admin['role'] != 'admin':
 
860
  return jsonify({'error': 'Unauthorized'}), 401
861
+ query_db("UPDATE users SET is_banned=0 WHERE id=?", [user_id], commit=True)
 
862
  return jsonify({'success': True})
863
 
864
  @app.route('/nimda/api/users/<int:user_id>/delete', methods=['POST'])
865
  def admin_api_delete_user(user_id):
866
+ token = request.cookies.get('session_token')
867
+ user_id_admin = validate_session_token(token) if token else None
868
+ if not user_id_admin:
869
  return jsonify({'error': 'Unauthorized'}), 401
870
+ admin = query_db("SELECT role FROM users WHERE id=?", [user_id_admin], one=True)
871
+ if not admin or admin['role'] != 'admin':
 
872
  return jsonify({'error': 'Unauthorized'}), 401
 
873
  query_db("DELETE FROM users WHERE id=?", [user_id], commit=True)
874
  return jsonify({'success': True})
875
 
876
  @app.route('/nimda/api/items/<int:item_id>/delete', methods=['POST'])
877
  def admin_api_delete_item(item_id):
878
+ token = request.cookies.get('session_token')
879
+ user_id = validate_session_token(token) if token else None
880
+ if not user_id:
881
  return jsonify({'error': 'Unauthorized'}), 401
882
+ admin = query_db("SELECT role FROM users WHERE id=?", [user_id], one=True)
883
+ if not admin or admin['role'] != 'admin':
 
884
  return jsonify({'error': 'Unauthorized'}), 401
 
885
  query_db("DELETE FROM items WHERE id=?", [item_id], commit=True)
886
  return jsonify({'success': True})
887
 
888
  @app.route('/nimda/api/claims/<int:claim_id>/approve', methods=['POST'])
889
  def admin_api_approve_claim(claim_id):
890
+ token = request.cookies.get('session_token')
891
+ user_id = validate_session_token(token) if token else None
892
+ if not user_id:
893
  return jsonify({'error': 'Unauthorized'}), 401
894
+ admin = query_db("SELECT role FROM users WHERE id=?", [user_id], one=True)
895
+ if not admin or admin['role'] != 'admin':
 
896
  return jsonify({'error': 'Unauthorized'}), 401
 
897
  query_db("UPDATE claims SET status='approved', resolved_at=datetime('now') WHERE id=?", [claim_id], commit=True)
898
  claim = query_db("SELECT * FROM claims WHERE id=?", [claim_id], one=True)
899
  if claim:
900
  query_db("UPDATE items SET status='resolved' WHERE id=?", [claim['item_id']], commit=True)
 
901
  return jsonify({'success': True})
902
 
903
  @app.route('/nimda/api/claims/<int:claim_id>/reject', methods=['POST'])
904
  def admin_api_reject_claim(claim_id):
905
+ token = request.cookies.get('session_token')
906
+ user_id = validate_session_token(token) if token else None
907
+ if not user_id:
908
  return jsonify({'error': 'Unauthorized'}), 401
909
+ admin = query_db("SELECT role FROM users WHERE id=?", [user_id], one=True)
910
+ if not admin or admin['role'] != 'admin':
 
911
  return jsonify({'error': 'Unauthorized'}), 401
912
+ query_db("UPDATE claims SET status='rejected', resolved_at=datetime('now') WHERE id=?", [claim_id], commit=True)
 
 
 
 
 
913
  return jsonify({'success': True})
914
 
915
  # ── Context processor ────────────────────────────────────────────────────────
916
  @app.context_processor
917
  def inject_globals():
918
  unread = 0
919
+ token = request.cookies.get('session_token')
920
+ if token:
921
+ user_id = validate_session_token(token)
922
+ if user_id:
923
+ try:
924
+ row = query_db("SELECT COUNT(*) as c FROM notifications WHERE user_id=? AND is_read=0", [user_id], one=True)
925
+ unread = row['c'] if row else 0
926
+ except:
927
+ pass
928
+ return {'unread_count': unread, 'current_year': datetime.now().year}
929
 
930
  # ── Boot ─────────────────────────────────────────────────────────────────────
931
  if __name__ == '__main__':