mwask commited on
Commit
79d1711
·
1 Parent(s): aa16289

tracking and nav

Browse files
api/models/user_activity.py CHANGED
@@ -22,44 +22,101 @@ def _ensure_indexes():
22
  _ensure_indexes()
23
 
24
 
25
- def ping_user(user_id, username, avatar, page_url, page_title=""):
26
- """Record or update a user's presence on the site."""
 
 
 
 
 
 
 
 
 
 
27
  now = datetime.now(timezone.utc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  activity_collection.update_one(
29
- {"user_id": str(user_id)},
30
- {"$set": {
31
- "user_id": str(user_id),
32
- "username": username,
33
- "avatar": avatar or "",
34
- "page_url": page_url,
35
- "page_title": page_title,
36
- "last_seen": now,
37
- },
38
- "$setOnInsert": {
39
- "first_seen": now,
40
- }},
41
  upsert=True
42
  )
43
  return True
44
 
45
 
46
  def get_active_users(minutes=5):
47
- """Get users active within the last N minutes."""
 
 
48
  cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
49
  cursor = activity_collection.find(
50
  {"last_seen": {"$gte": cutoff}}
51
  ).sort("last_seen", DESCENDING)
52
-
 
53
  users = []
54
  for doc in cursor:
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  users.append({
56
  "user_id": doc.get("user_id", ""),
57
- "username": doc.get("username", "Unknown"),
58
  "avatar": doc.get("avatar", ""),
59
  "page_url": doc.get("page_url", ""),
60
  "page_title": doc.get("page_title", ""),
61
  "last_seen": doc["last_seen"].isoformat() if doc.get("last_seen") else None,
62
  "first_seen": doc["first_seen"].isoformat() if doc.get("first_seen") else None,
 
63
  })
64
  return users
65
 
@@ -70,4 +127,3 @@ def get_active_user_count(minutes=5):
70
  return activity_collection.count_documents({"last_seen": {"$gte": cutoff}})
71
 
72
 
73
-
 
22
  _ensure_indexes()
23
 
24
 
25
+ def ping_user(user_id, username, avatar, page_url, page_title="", anonymous_id=None, ip_address=None):
26
+ """Record or update a user's presence on the site.
27
+
28
+ For authenticated users:
29
+ - keyed by user_id (MongoDB ObjectId str)
30
+ - shows their username & avatar
31
+
32
+ For anonymous visitors:
33
+ - keyed by anonymous_id (client-generated UUID stored in localStorage)
34
+ - deduplicated by ip_address if anonymous_id not provided
35
+ - shows "Guest" in the activity feed
36
+ """
37
  now = datetime.now(timezone.utc)
38
+
39
+ if user_id and username:
40
+ # Authenticated user
41
+ filter_key = {"user_id": str(user_id)}
42
+ display_name = username
43
+ elif anonymous_id:
44
+ # Anonymous user with client ID
45
+ filter_key = {"anonymous_id": anonymous_id}
46
+ display_name = "Guest"
47
+ elif ip_address:
48
+ # Anonymous user identified by IP (fallback)
49
+ filter_key = {"ip_address": ip_address, "user_id": {"$exists": False}}
50
+ display_name = "Guest"
51
+ else:
52
+ return False
53
+
54
+ doc = {
55
+ "last_seen": now,
56
+ "page_url": page_url,
57
+ "page_title": page_title,
58
+ }
59
+
60
+ if user_id and username:
61
+ doc["user_id"] = str(user_id)
62
+ doc["username"] = username
63
+ doc["avatar"] = avatar or ""
64
+ # Remove any anonymous fields if user later logs in
65
+ activity_collection.update_many(
66
+ {"ip_address": ip_address, "user_id": {"$exists": False}},
67
+ {"$set": {"user_id": str(user_id), "username": username, "avatar": avatar or ""}}
68
+ )
69
+ else:
70
+ doc["is_anonymous"] = True
71
+ doc["username"] = "Guest"
72
+ doc["avatar"] = ""
73
+ if anonymous_id:
74
+ doc["anonymous_id"] = anonymous_id
75
+ if ip_address:
76
+ doc["ip_address"] = ip_address
77
+
78
  activity_collection.update_one(
79
+ filter_key,
80
+ {"$set": doc, "$setOnInsert": {"first_seen": now}},
 
 
 
 
 
 
 
 
 
 
81
  upsert=True
82
  )
83
  return True
84
 
85
 
86
  def get_active_users(minutes=5):
87
+ """Get users active within the last N minutes.
88
+ Returns: list of dicts with user info including is_anonymous flag.
89
+ """
90
  cutoff = datetime.now(timezone.utc) - timedelta(minutes=minutes)
91
  cursor = activity_collection.find(
92
  {"last_seen": {"$gte": cutoff}}
93
  ).sort("last_seen", DESCENDING)
94
+
95
+ seen_ids = set()
96
  users = []
97
  for doc in cursor:
98
+ # Deduplicate: prefer authenticated version over anonymous
99
+ uid = doc.get("user_id") or doc.get("anonymous_id") or doc.get("ip_address") or ""
100
+ if uid in seen_ids:
101
+ continue
102
+ seen_ids.add(uid)
103
+
104
+ is_anon = doc.get("is_anonymous", False) or not doc.get("user_id")
105
+ display_name = doc.get("username", "Guest")
106
+ # Differentiate anonymous guests with a short ID suffix
107
+ if is_anon and doc.get("anonymous_id"):
108
+ short_id = str(doc["anonymous_id"])[-5:]
109
+ display_name = f"Guest_{short_id}"
110
+
111
  users.append({
112
  "user_id": doc.get("user_id", ""),
113
+ "username": display_name,
114
  "avatar": doc.get("avatar", ""),
115
  "page_url": doc.get("page_url", ""),
116
  "page_title": doc.get("page_title", ""),
117
  "last_seen": doc["last_seen"].isoformat() if doc.get("last_seen") else None,
118
  "first_seen": doc["first_seen"].isoformat() if doc.get("first_seen") else None,
119
+ "is_anonymous": is_anon,
120
  })
121
  return users
122
 
 
127
  return activity_collection.count_documents({"last_seen": {"$gte": cutoff}})
128
 
129
 
 
api/routes/shared/bug_reports_api.py CHANGED
@@ -296,21 +296,40 @@ def remove_announcement(announcement_id):
296
 
297
  @bug_reports_api_bp.route("/activity/ping", methods=["POST"])
298
  def user_heartbeat():
299
- """POST /api/activity/ping — record user activity (public, requires auth)."""
300
- if "username" not in session or "_id" not in session:
301
- return jsonify({"success": False, "message": "Login required"}), 401
302
-
303
  data = request.get_json() or {}
304
  page_url = data.get("page_url", request.referrer or "/")
305
  page_title = data.get("page_title", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
 
307
- ping_user(
308
- user_id=session["_id"],
309
- username=session["username"],
310
- avatar=session.get("avatar", ""),
311
- page_url=page_url,
312
- page_title=page_title,
313
- )
314
  return jsonify({"success": True})
315
 
316
 
 
296
 
297
  @bug_reports_api_bp.route("/activity/ping", methods=["POST"])
298
  def user_heartbeat():
299
+ """POST /api/activity/ping — record user activity.
300
+ Works for both logged-in and anonymous visitors.
301
+ """
 
302
  data = request.get_json() or {}
303
  page_url = data.get("page_url", request.referrer or "/")
304
  page_title = data.get("page_title", "")
305
+ anonymous_id = data.get("anonymous_id")
306
+ ip_address = request.remote_addr or ""
307
+
308
+ if "username" in session and "_id" in session:
309
+ # Logged-in user
310
+ ping_user(
311
+ user_id=str(session["_id"]),
312
+ username=session["username"],
313
+ avatar=session.get("avatar", ""),
314
+ page_url=page_url,
315
+ page_title=page_title,
316
+ ip_address=ip_address,
317
+ )
318
+ elif anonymous_id:
319
+ # Anonymous visitor with client ID
320
+ ping_user(
321
+ user_id=None,
322
+ username=None,
323
+ avatar=None,
324
+ page_url=page_url,
325
+ page_title=page_title,
326
+ anonymous_id=anonymous_id,
327
+ ip_address=ip_address,
328
+ )
329
+ else:
330
+ # Anonymous without ID — skip silently
331
+ return jsonify({"success": False, "message": "No identifier"}), 200
332
 
 
 
 
 
 
 
 
333
  return jsonify({"success": True})
334
 
335
 
api/templates/shared/admin_announcements.html CHANGED
@@ -344,8 +344,11 @@
344
  </style>
345
  {% endblock %}
346
 
347
- {% block content %}
348
- <div class="aa-page">
 
 
 
349
  <div class="aa-header">
350
  <h1>
351
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--aa-warning);">
 
344
  </style>
345
  {% endblock %}
346
 
347
+ {% block content %} <div class="aa-page">
348
+ <a href="/admin" style="display:inline-flex;align-items:center;gap:5px;font-size:0.78rem;color:#555;text-decoration:none;margin-bottom:14px;transition:color 0.2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#555'">
349
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
350
+ Back to Dashboard
351
+ </a>
352
  <div class="aa-header">
353
  <h1>
354
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--aa-warning);">
api/templates/shared/admin_bug_reports.html CHANGED
@@ -482,8 +482,11 @@
482
  </style>
483
  {% endblock %}
484
 
485
- {% block content %}
486
- <div class="abg-page">
 
 
 
487
  <div class="abg-header">
488
  <h1>
489
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--abg-error);">
 
482
  </style>
483
  {% endblock %}
484
 
485
+ {% block content %} <div class="abg-page">
486
+ <a href="/admin" style="display:inline-flex;align-items:center;gap:5px;font-size:0.78rem;color:#555;text-decoration:none;margin-bottom:14px;transition:color 0.2s;" onmouseover="this.style.color='#fff'" onmouseout="this.style.color='#555'">
487
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
488
+ Back to Dashboard
489
+ </a>
490
  <div class="abg-header">
491
  <h1>
492
  <svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color: var(--abg-error);">
api/templates/shared/admin_dashboard.html CHANGED
@@ -635,18 +635,32 @@
635
  activityList.innerHTML = '<div class="ad-empty">No users currently active.</div>';
636
  } else {
637
  activityList.innerHTML = activeUsers.map(u => {
638
- const initial = (u.username || '?')[0].toUpperCase();
639
- const avatar = u.avatar
640
- ? `<img class="ad-activity-avatar" src="${u.avatar}" alt="${u.username}" onerror="this.style.display='none'">`
641
- : `<div class="ad-activity-avatar-placeholder">${initial}</div>`;
642
- const pageLabel = u.page_title || getPageLabel(u.page_url);
643
- const timeLabel = timeAgo(u.last_seen);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
644
 
645
  return `
646
  <div class="ad-activity-item">
647
- ${avatar}
648
  <div class="ad-activity-info">
649
- <div class="ad-activity-username">${u.username}</div>
650
  <div class="ad-activity-page" title="${u.page_url || ''}">📍 ${pageLabel}</div>
651
  </div>
652
  <div class="ad-activity-time">${timeLabel}</div>
 
635
  activityList.innerHTML = '<div class="ad-empty">No users currently active.</div>';
636
  } else {
637
  activityList.innerHTML = activeUsers.map(u => {
638
+ var isGuest = u.is_anonymous === true;
639
+ var initial = (u.username || '?')[0].toUpperCase();
640
+ var avatarHtml;
641
+ if (isGuest) {
642
+ avatarHtml = `<div class="ad-activity-avatar-placeholder" style="background:#1a1a2e;color:#888;">
643
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
644
+ <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
645
+ <circle cx="9" cy="7" r="4"></circle>
646
+ </svg>
647
+ </div>`;
648
+ } else if (u.avatar) {
649
+ avatarHtml = `<img class="ad-activity-avatar" src="${u.avatar}" alt="${u.username}" onerror="this.style.display='none'">`;
650
+ } else {
651
+ avatarHtml = `<div class="ad-activity-avatar-placeholder">${initial}</div>`;
652
+ }
653
+ var usernameHtml = isGuest
654
+ ? `<span style="color:#666;">${u.username}</span>`
655
+ : `<span>${u.username}</span>`;
656
+ var pageLabel = u.page_title || getPageLabel(u.page_url);
657
+ var timeLabel = timeAgo(u.last_seen);
658
 
659
  return `
660
  <div class="ad-activity-item">
661
+ ${avatarHtml}
662
  <div class="ad-activity-info">
663
+ <div class="ad-activity-username">${usernameHtml}</div>
664
  <div class="ad-activity-page" title="${u.page_url || ''}">📍 ${pageLabel}</div>
665
  </div>
666
  <div class="ad-activity-time">${timeLabel}</div>
api/templates/shared/base.html CHANGED
@@ -1061,21 +1061,38 @@
1061
  <script src="{{ url_for('static', filename='js/search.js') }}"></script>
1062
  <script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
1063
 
1064
- <!-- User Activity Heartbeat -->
1065
- {% if session.get('username') %}
1066
  <script>
1067
  (function() {
 
 
 
 
 
 
 
 
 
 
 
 
 
1068
  var heartbeatInterval = 30000; // 30 seconds
1069
 
1070
  function sendHeartbeat() {
1071
  var pageUrl = window.location.pathname + window.location.search;
1072
  var pageTitle = document.title;
1073
 
1074
- var payload = JSON.stringify({ page_url: pageUrl, page_title: pageTitle });
 
 
 
 
1075
 
1076
- // Use sendBeacon for reliability on page unload, fallback to fetch
1077
  if (navigator.sendBeacon) {
1078
  try {
 
1079
  var blob = new Blob([payload], { type: 'application/json' });
1080
  navigator.sendBeacon('/api/activity/ping', blob);
1081
  } catch (e) {
@@ -1083,6 +1100,7 @@
1083
  method: 'POST',
1084
  headers: { 'Content-Type': 'application/json' },
1085
  body: payload,
 
1086
  keepalive: true
1087
  }).catch(function(){});
1088
  }
@@ -1090,18 +1108,20 @@
1090
  fetch('/api/activity/ping', {
1091
  method: 'POST',
1092
  headers: { 'Content-Type': 'application/json' },
1093
- body: payload
 
 
1094
  }).catch(function(){});
1095
  }
1096
  }
1097
 
1098
- // Send immediately on page load
1099
- sendHeartbeat();
1100
 
1101
  // Send periodically
1102
  setInterval(sendHeartbeat, heartbeatInterval);
1103
 
1104
- // Send heartbeat when user leaves the page (visibility + beacon for reliability)
1105
  window.addEventListener('visibilitychange', function() {
1106
  if (document.visibilityState === 'hidden') {
1107
  sendHeartbeat();
@@ -1109,7 +1129,6 @@
1109
  });
1110
  })();
1111
  </script>
1112
- {% endif %}
1113
 
1114
  <!-- Announcement Popup Script -->
1115
  <script>
 
1061
  <script src="{{ url_for('static', filename='js/search.js') }}"></script>
1062
  <script src="{{ url_for('static', filename='js/notifications.js') }}"></script>
1063
 
1064
+ <!-- User Activity Heartbeat (tracks ALL visitors — logged-in + anonymous) -->
 
1065
  <script>
1066
  (function() {
1067
+ // Get or create anonymous visitor ID
1068
+ var ANON_KEY = '_ac_anon_id';
1069
+ var anonymousId = null;
1070
+ try {
1071
+ anonymousId = localStorage.getItem(ANON_KEY);
1072
+ if (!anonymousId) {
1073
+ anonymousId = 'anon_' + Date.now() + '_' + Math.random().toString(36).substring(2, 10);
1074
+ localStorage.setItem(ANON_KEY, anonymousId);
1075
+ }
1076
+ } catch (e) {
1077
+ anonymousId = 'anon_' + Date.now();
1078
+ }
1079
+
1080
  var heartbeatInterval = 30000; // 30 seconds
1081
 
1082
  function sendHeartbeat() {
1083
  var pageUrl = window.location.pathname + window.location.search;
1084
  var pageTitle = document.title;
1085
 
1086
+ var payload = JSON.stringify({
1087
+ page_url: pageUrl,
1088
+ page_title: pageTitle,
1089
+ anonymous_id: anonymousId
1090
+ });
1091
 
1092
+ // Use sendBeacon for unload reliability, fallback to fetch
1093
  if (navigator.sendBeacon) {
1094
  try {
1095
+ // Must use a Blob with explicit content-type so Flask parses it as JSON
1096
  var blob = new Blob([payload], { type: 'application/json' });
1097
  navigator.sendBeacon('/api/activity/ping', blob);
1098
  } catch (e) {
 
1100
  method: 'POST',
1101
  headers: { 'Content-Type': 'application/json' },
1102
  body: payload,
1103
+ credentials: 'same-origin',
1104
  keepalive: true
1105
  }).catch(function(){});
1106
  }
 
1108
  fetch('/api/activity/ping', {
1109
  method: 'POST',
1110
  headers: { 'Content-Type': 'application/json' },
1111
+ body: payload,
1112
+ credentials: 'same-origin',
1113
+ keepalive: true
1114
  }).catch(function(){});
1115
  }
1116
  }
1117
 
1118
+ // Send immediately on page load (with slight delay to not block rendering)
1119
+ setTimeout(sendHeartbeat, 500);
1120
 
1121
  // Send periodically
1122
  setInterval(sendHeartbeat, heartbeatInterval);
1123
 
1124
+ // Send heartbeat when user leaves the page
1125
  window.addEventListener('visibilitychange', function() {
1126
  if (document.visibilityState === 'hidden') {
1127
  sendHeartbeat();
 
1129
  });
1130
  })();
1131
  </script>
 
1132
 
1133
  <!-- Announcement Popup Script -->
1134
  <script>