base setup
This commit is contained in:
0
at_django_boilerplate/user_activity/__init__.py
Executable file
0
at_django_boilerplate/user_activity/__init__.py
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
5
at_django_boilerplate/user_activity/admin.py
Executable file
5
at_django_boilerplate/user_activity/admin.py
Executable file
@@ -0,0 +1,5 @@
|
||||
from django.contrib import admin
|
||||
from .models import *
|
||||
|
||||
admin.site.register(PageVisit)
|
||||
# Register your models here.
|
||||
6
at_django_boilerplate/user_activity/apps.py
Executable file
6
at_django_boilerplate/user_activity/apps.py
Executable file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class UserActivityConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'at_django_boilerplate.user_activity'
|
||||
10
at_django_boilerplate/user_activity/context_processors.py
Normal file
10
at_django_boilerplate/user_activity/context_processors.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from at_django_boilerplate.user_activity.models import SessionConsentModel
|
||||
|
||||
def user_consent_received(request):
|
||||
consent = SessionConsentModel.objects.filter(
|
||||
session_id=request.session.session_key
|
||||
).first()
|
||||
|
||||
return {
|
||||
'consent_received': consent.consent_received if consent else False
|
||||
}
|
||||
0
at_django_boilerplate/user_activity/middleware/__init__.py
Executable file
0
at_django_boilerplate/user_activity/middleware/__init__.py
Executable file
Binary file not shown.
Binary file not shown.
85
at_django_boilerplate/user_activity/middleware/get_web_user.py
Executable file
85
at_django_boilerplate/user_activity/middleware/get_web_user.py
Executable file
@@ -0,0 +1,85 @@
|
||||
from django.utils import timezone
|
||||
from django.utils.deprecation import MiddlewareMixin
|
||||
import logging
|
||||
from at_django_boilerplate.user_activity.models import PageVisit
|
||||
from at_django_boilerplate.utils.geolocation import get_city_and_country_from_ip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXCLUDE_PATH_PREFIXES = [
|
||||
'/admin/',
|
||||
'/login',
|
||||
'/logout',
|
||||
'/password_reset',
|
||||
'/password_reset/done',
|
||||
'/password_reset/confirm',
|
||||
'/password_reset/complete',
|
||||
'/media/',
|
||||
'/static/',
|
||||
'/user-activity/',
|
||||
'/auth/',
|
||||
'admin-views',
|
||||
'/.well-known',
|
||||
'/accounts/login',
|
||||
'/leads/'
|
||||
|
||||
]
|
||||
|
||||
class UserActivityMiddleware(MiddlewareMixin):
|
||||
def process_request(self, request):
|
||||
path = request.path
|
||||
if any(path.startswith(prefix) for prefix in EXCLUDE_PATH_PREFIXES):
|
||||
logger.debug(f"Skipping logging for excluded path: {path}")
|
||||
return None
|
||||
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
ip_address = x_forwarded_for.split(',')[0].strip() if x_forwarded_for else request.META.get('REMOTE_ADDR', 'N/A')
|
||||
|
||||
session = request.session
|
||||
if not session.session_key:
|
||||
session.save()
|
||||
session_id = session.session_key or 'anonymous'
|
||||
|
||||
if '_session_init_timestamp_' not in session:
|
||||
session['_session_init_timestamp_'] = timezone.now().isoformat()
|
||||
|
||||
session_data = {
|
||||
'session_key': session_id,
|
||||
'ip_address': ip_address,
|
||||
}
|
||||
|
||||
user = request.user if request.user.is_authenticated else None
|
||||
if user:
|
||||
user.last_active = timezone.now()
|
||||
user.save(update_fields=['last_active'])
|
||||
|
||||
query_params = request.GET.urlencode() if request.GET else ''
|
||||
user_agent = request.META.get('HTTP_USER_AGENT', '')
|
||||
print('User Agent:',user_agent)
|
||||
url = request.build_absolute_uri().split('?')[0][:255]
|
||||
tmp = get_city_and_country_from_ip(ip_address)
|
||||
try:
|
||||
PageVisit.objects.create(
|
||||
user=user,
|
||||
session_id=session_id,
|
||||
url=url,
|
||||
ip_address=ip_address[:45],
|
||||
query_params=query_params[:512],
|
||||
timestamp=timezone.now(),
|
||||
path=path[:512],
|
||||
user_agent=user_agent[:1024],
|
||||
session_data=session_data,
|
||||
location=tmp['city'],
|
||||
country=tmp['country'],
|
||||
latitude=tmp['latitude'],
|
||||
longitude=tmp['longitude']
|
||||
|
||||
)
|
||||
logger.debug(f"Logged visit to {path} from {ip_address} with session {session_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error logging page visit: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def process_response(self, request, response):
|
||||
return response
|
||||
@@ -0,0 +1,66 @@
|
||||
# Generated by Django 5.2.6 on 2025-12-29 05:20
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SessionConsentModel',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('session_id', models.CharField(max_length=255, unique=True)),
|
||||
('accept_all_consent_received', models.BooleanField(default=False)),
|
||||
('essential_only_consent_received', models.BooleanField(default=False)),
|
||||
('received_on', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='GeolocationCache',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('ip_address', models.CharField(max_length=45, unique=True)),
|
||||
('location', models.CharField(max_length=255)),
|
||||
('last_updated', models.DateTimeField(auto_now=True)),
|
||||
('request_count', models.PositiveIntegerField(default=0)),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['ip_address'], name='user_activi_ip_addr_664fe3_idx')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PageVisit',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('session_id', models.CharField(max_length=255)),
|
||||
('url', models.CharField(max_length=255)),
|
||||
('ip_address', models.CharField(max_length=45)),
|
||||
('query_params', models.TextField(blank=True, null=True)),
|
||||
('timestamp', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('path', models.CharField(max_length=512)),
|
||||
('user_agent', models.TextField(blank=True, null=True)),
|
||||
('session_data', models.JSONField(blank=True, default=dict, null=True)),
|
||||
('location', models.CharField(blank=True, max_length=64, null=True)),
|
||||
('country', models.CharField(blank=True, max_length=64, null=True)),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-timestamp'],
|
||||
'indexes': [models.Index(fields=['session_id', 'timestamp'], name='user_activi_session_a322d8_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,125 @@
|
||||
# Generated by Django 5.2.6 on 2026-01-05 04:33
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('user_activity', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ClickEvent',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('page_path', models.CharField(blank=True, max_length=512, null=True)),
|
||||
('element_text', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('element_type', models.CharField(blank=True, max_length=50, null=True)),
|
||||
('timestamp', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='DailyUserStats',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('date', models.DateField(blank=True, null=True, unique=True)),
|
||||
('new_users', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('returning_users', models.PositiveIntegerField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VisitorModel',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('visiter_number', models.IntegerField()),
|
||||
('session_id', models.CharField(max_length=255)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='VisitorSession',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('session_id', models.CharField(blank=True, max_length=255, null=True, unique=True)),
|
||||
('is_bounce', models.BooleanField(blank=True, default=False, null=True)),
|
||||
('started_at', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('ended_at', models.DateTimeField(blank=True, null=True)),
|
||||
('duration_seconds', models.PositiveIntegerField(blank=True, default=0, null=True)),
|
||||
('visitor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='user_activity.visitormodel')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='GeolocationCache',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagevisit',
|
||||
name='entered_at',
|
||||
field=models.DateTimeField(auto_now_add=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagevisit',
|
||||
name='exited_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagevisit',
|
||||
name='is_new_user',
|
||||
field=models.BooleanField(default=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagevisit',
|
||||
name='latitude',
|
||||
field=models.CharField(blank=True, max_length=64, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagevisit',
|
||||
name='longitude',
|
||||
field=models.CharField(blank=True, max_length=64, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagevisit',
|
||||
name='referrer',
|
||||
field=models.CharField(blank=True, max_length=512, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagevisit',
|
||||
name='time_spent_seconds',
|
||||
field=models.PositiveIntegerField(blank=True, default=0, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagevisit',
|
||||
name='visit_count',
|
||||
field=models.PositiveIntegerField(blank=True, default=1, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clickevent',
|
||||
name='visitor',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='user_activity.visitormodel'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pagevisit',
|
||||
name='visitor',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='page_visits', to='user_activity.visitormodel'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clickevent',
|
||||
name='session',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='clicks', to='user_activity.visitorsession'),
|
||||
),
|
||||
]
|
||||
0
at_django_boilerplate/user_activity/migrations/__init__.py
Executable file
0
at_django_boilerplate/user_activity/migrations/__init__.py
Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
93
at_django_boilerplate/user_activity/models.py
Executable file
93
at_django_boilerplate/user_activity/models.py
Executable file
@@ -0,0 +1,93 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils import timezone
|
||||
from at_django_boilerplate.utils.mixins import UUIDMixin
|
||||
|
||||
class SessionConsentModel(UUIDMixin):
|
||||
session_id = models.CharField(max_length=255, unique=True)
|
||||
accept_all_consent_received = models.BooleanField(default=False)
|
||||
essential_only_consent_received = models.BooleanField(default=False)
|
||||
received_on = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@property
|
||||
def consent_received(self):
|
||||
return self.accept_all_consent_received or self.essential_only_consent_received
|
||||
|
||||
|
||||
class VisitorModel(UUIDMixin):
|
||||
visiter_number = models.IntegerField()
|
||||
session_id = models.CharField(max_length=255)
|
||||
|
||||
def __str__(self):
|
||||
return f'#{self.visiter_number}'
|
||||
|
||||
class PageVisit(UUIDMixin):
|
||||
user = models.ForeignKey(get_user_model(), on_delete=models.SET_NULL, null=True, blank=True)
|
||||
session_id = models.CharField(max_length=255)
|
||||
visitor = models.ForeignKey(VisitorModel,related_name='page_visits',null=True,blank=True ,on_delete=models.SET_NULL)
|
||||
url = models.CharField(max_length=255)
|
||||
ip_address = models.CharField(max_length=45) # Supports IPv4 and IPv6
|
||||
query_params = models.TextField(blank=True, null=True)
|
||||
timestamp = models.DateTimeField(default=timezone.now)
|
||||
path = models.CharField(max_length=512)
|
||||
user_agent = models.TextField(blank=True, null=True)
|
||||
session_data = models.JSONField(default=dict, blank=True, null=True) # Keep this field
|
||||
location = models.CharField(max_length=64,null=True,blank=True)
|
||||
country = models.CharField(max_length=64,null=True,blank=True)
|
||||
latitude=models.CharField(max_length=64,null=True,blank=True)
|
||||
longitude=models.CharField(max_length=64,null=True,blank=True)
|
||||
is_new_user = models.BooleanField(default=True)
|
||||
referrer = models.CharField(max_length=512, blank=True, null=True)
|
||||
visit_count = models.PositiveIntegerField(default=1,null=True,blank=True)
|
||||
entered_at = models.DateTimeField(auto_now_add=True,null=True,blank=True)
|
||||
exited_at = models.DateTimeField(null=True, blank=True)
|
||||
time_spent_seconds = models.PositiveIntegerField(default=0,null=True,blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-timestamp']
|
||||
indexes = [
|
||||
models.Index(fields=['session_id', 'timestamp']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email if self.user else 'Anonymous'} visited {self.url} at {self.timestamp}"
|
||||
def save(self, *args, **kwargs):
|
||||
# Detect if this is a new user
|
||||
if self.user and PageVisit.objects.filter(user=self.user).exists():
|
||||
self.is_new_user = False
|
||||
super().save(*args, **kwargs)
|
||||
def calculate_time_spent(self):
|
||||
if self.exited_at:
|
||||
self.time_spent_seconds = int(
|
||||
(self.exited_at - self.entered_at).total_seconds()
|
||||
)
|
||||
|
||||
class DailyUserStats(UUIDMixin):
|
||||
date = models.DateField(unique=True,null=True,blank=True)
|
||||
new_users = models.PositiveIntegerField(null=True,blank=True)
|
||||
returning_users = models.PositiveIntegerField(null=True,blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True,null=True,blank=True)
|
||||
|
||||
|
||||
class VisitorSession(UUIDMixin):
|
||||
session_id = models.CharField(max_length=255, unique=True,null=True,blank=True)
|
||||
visitor = models.ForeignKey(VisitorModel, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
is_bounce = models.BooleanField(default=False,null=True,blank=True)
|
||||
|
||||
started_at = models.DateTimeField(auto_now_add=True,null=True,blank=True)
|
||||
ended_at = models.DateTimeField(null=True, blank=True)
|
||||
duration_seconds = models.PositiveIntegerField(default=0,null=True,blank=True)
|
||||
|
||||
def calculate_duration(self):
|
||||
if self.ended_at:
|
||||
self.duration_seconds = int(
|
||||
(self.ended_at - self.started_at).total_seconds()
|
||||
)
|
||||
|
||||
class ClickEvent(UUIDMixin):
|
||||
session = models.ForeignKey(VisitorSession, on_delete=models.SET_NULL, related_name="clicks",null=True,blank=True)
|
||||
visitor = models.ForeignKey(VisitorModel, on_delete=models.SET_NULL, null=True, blank=True)
|
||||
page_path = models.CharField(max_length=512,null=True,blank=True)
|
||||
element_text = models.CharField(max_length=255,null=True,blank=True)
|
||||
element_type = models.CharField(max_length=50,null=True,blank=True)
|
||||
timestamp = models.DateTimeField(auto_now_add=True,null=True,blank=True)
|
||||
535
at_django_boilerplate/user_activity/templates/active_sessions.html
Executable file
535
at_django_boilerplate/user_activity/templates/active_sessions.html
Executable file
@@ -0,0 +1,535 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block content %}
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||
|
||||
<div class="container py-5">
|
||||
<div class="card shadow-lg rounded-3 overflow-hidden">
|
||||
<!-- Header -->
|
||||
<div class="card-header py-4" style="background: linear-gradient(135deg, #4f46e5, #7c3aed);">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h3 class="mb-0 fw-bold text-white">
|
||||
<i class="fas fa-chart-pie me-2"></i>Website Analytics Dashboard
|
||||
</h3>
|
||||
<div class="text-white small opacity-75">
|
||||
<div><i class="fas fa-network-wired me-2"></i>IP: {{ current_ip|default:"Unknown" }}</div>
|
||||
<div><i class="fas fa-map-marker-alt me-2"></i>Location: {{ current_location|default:"Unknown" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="card-body p-5">
|
||||
<!-- FY Selector & Total Visits -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-5">
|
||||
<h4 class="fw-bold text-gray-800">
|
||||
<i class="fas fa-chart-bar me-2 text-primary"></i>Public Page Analytics
|
||||
</h4>
|
||||
|
||||
<!-- Date Filter -->
|
||||
<div class="d-flex flex-wrap gap-3 align-items-end mb-3">
|
||||
<!-- Quick Select -->
|
||||
<div class="flex-grow-1" style="min-width:150px;">
|
||||
<label class="text-gray-600 fw-medium small">Quick Select</label>
|
||||
<select id="quickSelect" class="form-select form-select-sm" onchange="applyQuickSelect()">
|
||||
<option value="">Custom</option>
|
||||
<option value="today">Today</option>
|
||||
<option value="last7">Last 7 Days</option>
|
||||
<option value="last14">Last 14 Days</option>
|
||||
<option value="lastMonth">Last Month</option>
|
||||
<option value="lastQuarter">Last Quarter</option>
|
||||
<option value="ytd">Year to Date</option>
|
||||
<option value="last365">Last 365 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- From Date -->
|
||||
<div>
|
||||
<label class="text-gray-600 fw-medium small">From</label>
|
||||
<input type="date" id="fromDate" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<!-- To Date -->
|
||||
<div>
|
||||
<label class="text-gray-600 fw-medium small">To</label>
|
||||
<input type="date" id="toDate" class="form-control form-control-sm">
|
||||
</div>
|
||||
|
||||
<!-- Apply Button -->
|
||||
<div class="align-self-end">
|
||||
<button class="btn btn-sm btn-primary mb-1" onclick="applyDateFilter()">
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function applyQuickSelect() {
|
||||
const select = document.getElementById('quickSelect');
|
||||
const fromInput = document.getElementById('fromDate');
|
||||
const toInput = document.getElementById('toDate');
|
||||
|
||||
const today = new Date();
|
||||
let fromDate, toDate;
|
||||
|
||||
switch(select.value) {
|
||||
case 'today':
|
||||
fromDate = today; toDate = today; break;
|
||||
case 'last7':
|
||||
fromDate = new Date(today); fromDate.setDate(today.getDate() - 6); toDate = today; break;
|
||||
case 'last14':
|
||||
fromDate = new Date(today); fromDate.setDate(today.getDate() - 13); toDate = today; break;
|
||||
case 'lastMonth':
|
||||
fromDate = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
toDate = new Date(today.getFullYear(), today.getMonth(), 0); break;
|
||||
case 'lastQuarter':
|
||||
const currentMonth = today.getMonth();
|
||||
const currentQuarter = Math.floor(currentMonth / 3);
|
||||
fromDate = new Date(today.getFullYear(), (currentQuarter - 1) * 3, 1);
|
||||
toDate = new Date(today.getFullYear(), currentQuarter * 3, 0); break;
|
||||
case 'ytd':
|
||||
fromDate = new Date(today.getFullYear(), 0, 1); toDate = today; break;
|
||||
case 'last365':
|
||||
fromDate = new Date(today); fromDate.setDate(today.getDate() - 364); toDate = today; break;
|
||||
default:
|
||||
fromInput.value = ''; toInput.value = ''; return;
|
||||
}
|
||||
|
||||
const format = d => d.toISOString().split('T')[0];
|
||||
fromInput.value = format(fromDate);
|
||||
toInput.value = format(toDate);
|
||||
}
|
||||
|
||||
function applyDateFilter() {
|
||||
const from = document.getElementById('fromDate').value;
|
||||
const to = document.getElementById('toDate').value;
|
||||
|
||||
const url = new URL(window.location);
|
||||
if (from) url.searchParams.set('from', from);
|
||||
if (to) url.searchParams.set('to', to);
|
||||
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="text-center text-gray-600 mb-5 fs-5">
|
||||
<strong>{{ total_visits|default:0 }}</strong> total page visits in FY {{ selected_fy }}
|
||||
</div>
|
||||
|
||||
<!-- Top 10 Most Visited Pages -->
|
||||
<h5 class="fw-bold text-gray-800 mb-4">
|
||||
<i class="fas fa-globe me-2 text-primary"></i>Top 10 Most Visited Pages
|
||||
</h5>
|
||||
{% if top_parts %}
|
||||
<div class="row mb-5">
|
||||
<div class="col-lg-6 mb-4 mb-lg-0">
|
||||
<div class="table-responsive shadow-sm rounded">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light text-uppercase small text-gray-600">
|
||||
<tr>
|
||||
<th class="ps-4">Page Path</th>
|
||||
<th class="text-end pe-4">Visits</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for part in top_parts %}
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<code class="small bg-gray-100 px-2 py-1 rounded">{{ part.name|default:"/" }}</code>
|
||||
</td>
|
||||
<td class="text-end pe-4 fw-bold">{{ part.visits }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="chart-container shadow-sm rounded">
|
||||
<canvas id="topPartsChart" height="350"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4 text-gray-500">
|
||||
<i class="fas fa-globe fa-3x opacity-25 mb-3"></i>
|
||||
<p>No page visit data available for this period.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr class="my-5 border-gray-300">
|
||||
|
||||
<!-- Visits by Location -->
|
||||
<h5 class="fw-bold text-gray-800 mb-4">
|
||||
<i class="fas fa-map-marker-alt me-2 text-primary"></i>Visits by Location (Top 10)
|
||||
</h5>
|
||||
{% if location_analytics.data %}
|
||||
<div class="row mb-5">
|
||||
<div class="col-lg-6 mb-4 mb-lg-0">
|
||||
<div class="table-responsive shadow-sm rounded">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light text-uppercase small text-gray-600">
|
||||
<tr>
|
||||
<th class="ps-4">Location</th>
|
||||
<th class="text-end">Visits</th>
|
||||
<th class="text-end pe-4">Percentage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for label, count, percentage in location_analytics.data %}
|
||||
<tr>
|
||||
<td class="ps-4">{{ label|default:"Unknown" }}</td>
|
||||
<td class="text-end">{{ count }}</td>
|
||||
<td class="text-end pe-4">{{ percentage }}%</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="chart-container shadow-sm rounded">
|
||||
<canvas id="locationChart" height="350"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-4 text-gray-500">
|
||||
<i class="fas fa-map-marker-alt fa-3x opacity-25 mb-3"></i>
|
||||
<p>No location data available.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr class="my-5 border-gray-300">
|
||||
|
||||
<!-- Top Paths by Location -->
|
||||
<h5 class="fw-bold text-gray-800 mb-4">
|
||||
<i class="fas fa-link me-2 text-primary"></i>Top Pages by Location
|
||||
</h5>
|
||||
{% if paths_by_location.locations %}
|
||||
{% for loc in paths_by_location.locations %}
|
||||
<div class="mb-5">
|
||||
<h6 class="text-gray-700 fw-semibold mb-3">{{ loc.location|default:"Unknown" }}</h6>
|
||||
<div class="row">
|
||||
<div class="col-lg-6 mb-4 mb-lg-0">
|
||||
<div class="table-responsive shadow-sm rounded">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light text-uppercase small text-gray-600">
|
||||
<tr>
|
||||
<th class="ps-4">Page Path</th>
|
||||
<th class="text-end">Visits</th>
|
||||
<th class="text-end pe-4">Percentage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for label, count, percentage in loc.data %}
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<code class="small bg-gray-100 px-2 py-1 rounded">{{ label|truncatechars:50 }}</code>
|
||||
</td>
|
||||
<td class="text-end">{{ count }}</td>
|
||||
<td class="text-end pe-4">{{ percentage }}%</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="chart-container shadow-sm rounded">
|
||||
<canvas id="pathChart_{{ forloop.counter }}" height="350"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center py-4 text-gray-500">
|
||||
<i class="fas fa-link fa-3x opacity-25 mb-3"></i>
|
||||
<p>No path data available by location.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<hr class="my-5 border-gray-300">
|
||||
|
||||
<!-- Devices by Location -->
|
||||
<h5 class="fw-bold text-gray-800 mb-4">
|
||||
<i class="fas fa-mobile-alt me-2 text-primary"></i>Device Types by Location
|
||||
</h5>
|
||||
{% if devices_by_location.locations %}
|
||||
{% for loc in devices_by_location.locations %}
|
||||
<div class="mb-5">
|
||||
<h6 class="text-gray-700 fw-semibold mb-3">{{ loc.location|default:"Unknown" }}</h6>
|
||||
<div class="row">
|
||||
<div class="col-lg-6 mb-4 mb-lg-0">
|
||||
<div class="table-responsive shadow-sm rounded">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="bg-light text-uppercase small text-gray-600">
|
||||
<tr>
|
||||
<th class="ps-4">Device</th>
|
||||
<th class="text-end">Visits</th>
|
||||
<th class="text-end pe-4">Percentage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for label, count, percentage in loc.data %}
|
||||
<tr>
|
||||
<td class="ps-4">
|
||||
<i class="fas {% if label == 'Mobile' %}fa-mobile-alt{% elif label == 'Desktop' %}fa-laptop{% elif label == 'Tablet' %}fa-tablet-alt{% else %}fa-question-circle{% endif %} me-2 text-gray-600"></i>
|
||||
{{ label }}
|
||||
</td>
|
||||
<td class="text-end">{{ count }}</td>
|
||||
<td class="text-end pe-4">{{ percentage }}%</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="chart-container shadow-sm rounded">
|
||||
<canvas id="deviceChart_{{ forloop.counter }}" height="350"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center py-4 text-gray-500">
|
||||
<i class="fas fa-mobile-alt fa-3x opacity-25 mb-3"></i>
|
||||
<p>No device data available.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="card-footer bg-gray-50 py-4">
|
||||
<div class="text-end">
|
||||
<button class="btn btn-sm btn-gradient-primary" onclick="window.location.reload()">
|
||||
<i class="fas fa-sync-alt me-1"></i> Refresh Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Styles -->
|
||||
<style>
|
||||
body { font-family: 'Inter', sans-serif; background-color: #f3f4f6; }
|
||||
.card { border: none; border-radius: 1.5rem; background: #ffffff; }
|
||||
.chart-container {
|
||||
position: relative;
|
||||
height: 350px;
|
||||
background: #fff;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
code { font-family: 'Courier New', monospace; background: #f3f4f6; border-radius: 6px; }
|
||||
.btn-gradient-primary {
|
||||
background: linear-gradient(135deg, #4f46e5, #7c3aed);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.75rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-gradient-primary:hover {
|
||||
background: linear-gradient(135deg, #4338ca, #6d28d9);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.text-gray-600 { color: #4b5563; }
|
||||
.text-gray-700 { color: #374151; }
|
||||
.text-gray-800 { color: #1f2937; }
|
||||
.bg-gray-50 { background-color: #f9fafb; }
|
||||
.bg-gray-100 { background-color: #f3f4f6; }
|
||||
</style>
|
||||
|
||||
<!-- Charts -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.2.0/dist/chartjs-plugin-datalabels.min.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const colors = {
|
||||
background: ['#4f46e5', '#7c3aed', '#ec4899', '#f43f5e', '#fbbf24', '#2dd4bf', '#22c55e', '#3b82f6', '#a855f7', '#eab308'],
|
||||
border: ['#4338ca', '#6d28d9', '#db2777', '#dc2626', '#f59e0b', '#14b8a6', '#16a34a', '#2563eb', '#9333ea', '#ca8a04']
|
||||
};
|
||||
|
||||
Chart.register(ChartDataLabels);
|
||||
|
||||
// Top Pages Chart
|
||||
{% if top_parts %}
|
||||
new Chart(document.getElementById('topPartsChart'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: [{% for part in top_parts %}'{{ part.name|escapejs }}'{% if not forloop.last %}, {% endif %}{% endfor %}],
|
||||
datasets: [{
|
||||
data: [{% for part in top_parts %}{{ part.visits }}{% if not forloop.last %}, {% endif %}{% endfor %}],
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right', labels: { font: { family: 'Inter', size: 13 } } },
|
||||
title: { display: true, text: 'Top 10 Most Visited Pages', font: { size: 16, family: 'Inter', weight: '600' }, color: '#1f2937' },
|
||||
datalabels: {
|
||||
color: '#fff',
|
||||
font: { weight: 'bold', size: 12 },
|
||||
formatter: (value, ctx) => {
|
||||
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = Math.round((value / total) * 100);
|
||||
return percentage > 5 ? percentage + '%' : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [ChartDataLabels]
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
// Location Chart
|
||||
{% if location_analytics.data %}
|
||||
new Chart(document.getElementById('locationChart'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: {{ location_analytics.labels|safe }},
|
||||
datasets: [{
|
||||
data: {{ location_analytics.counts|safe }},
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right' },
|
||||
title: { display: true, text: 'Visits by Location', font: { size: 16, family: 'Inter', weight: '600' }, color: '#1f2937' },
|
||||
datalabels: {
|
||||
color: '#fff',
|
||||
font: { weight: 'bold' },
|
||||
formatter: (value, ctx) => {
|
||||
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = Math.round((value / total) * 100);
|
||||
return percentage > 5 ? percentage + '%' : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [ChartDataLabels]
|
||||
});
|
||||
{% endif %}
|
||||
|
||||
// Paths by Location Charts
|
||||
{% for loc in paths_by_location.locations %}
|
||||
new Chart(document.getElementById('pathChart_{{ forloop.counter }}'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: {{ loc.labels|safe }},
|
||||
datasets: [{
|
||||
data: {{ loc.counts|safe }},
|
||||
backgroundColor: colors.background,
|
||||
borderColor: colors.border,
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right' },
|
||||
title: { display: true, text: 'Top Pages in {{ loc.location|escapejs }}', font: { size: 15, family: 'Inter' } },
|
||||
datalabels: {
|
||||
color: '#fff',
|
||||
font: { weight: 'bold' },
|
||||
formatter: (value, ctx) => {
|
||||
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = Math.round((value / total) * 100);
|
||||
return percentage > 8 ? percentage + '%' : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [ChartDataLabels]
|
||||
});
|
||||
{% endfor %}
|
||||
|
||||
// Devices by Location Charts
|
||||
{% for loc in devices_by_location.locations %}
|
||||
new Chart(document.getElementById('deviceChart_{{ forloop.counter }}'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
labels: {{ loc.labels|safe }},
|
||||
datasets: [{
|
||||
data: {{ loc.counts|safe }},
|
||||
backgroundColor: colors.background.slice(0, {{ loc.labels|length }}),
|
||||
borderColor: colors.border.slice(0, {{ loc.labels|length }}),
|
||||
borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'right' },
|
||||
title: { display: true, text: 'Devices in {{ loc.location|escapejs }}', font: { size: 15, family: 'Inter' } },
|
||||
datalabels: {
|
||||
color: '#fff',
|
||||
font: { weight: 'bold' },
|
||||
formatter: (value, ctx) => {
|
||||
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
|
||||
const percentage = Math.round((value / total) * 100);
|
||||
return percentage > 10 ? percentage + '%' : '';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [ChartDataLabels]
|
||||
});
|
||||
{% endfor %}
|
||||
|
||||
// FY Update Function
|
||||
window.updateFY = function(fy) {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set('fy', fy);
|
||||
window.location.href = url.toString();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
<script>
|
||||
function applyDateFilter() {
|
||||
const from = document.getElementById('fromDate').value;
|
||||
const to = document.getElementById('toDate').value;
|
||||
|
||||
console.log("From date:", from);
|
||||
console.log("To date:", to);
|
||||
|
||||
const url = new URL(window.location);
|
||||
if (from) url.searchParams.set('from', from);
|
||||
if (to) url.searchParams.set('to', to);
|
||||
|
||||
console.log("Redirecting to:", url.toString());
|
||||
|
||||
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
0
at_django_boilerplate/user_activity/templatetags/__init__.py
Executable file
0
at_django_boilerplate/user_activity/templatetags/__init__.py
Executable file
Binary file not shown.
Binary file not shown.
10
at_django_boilerplate/user_activity/templatetags/utils.py
Executable file
10
at_django_boilerplate/user_activity/templatetags/utils.py
Executable file
@@ -0,0 +1,10 @@
|
||||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
@register.filter
|
||||
def zip(*args):
|
||||
args = list(args) + [[]] * (3 - len(args))
|
||||
args = args[:3]
|
||||
args = [arg if isinstance(arg, (list, tuple)) else [] for arg in args]
|
||||
return list(zip(*args))
|
||||
3
at_django_boilerplate/user_activity/tests.py
Executable file
3
at_django_boilerplate/user_activity/tests.py
Executable file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
11
at_django_boilerplate/user_activity/urls.py
Executable file
11
at_django_boilerplate/user_activity/urls.py
Executable file
@@ -0,0 +1,11 @@
|
||||
# your_app/urls.py
|
||||
from django.urls import path
|
||||
from django.contrib.auth import views as auth_views
|
||||
from .views import user_activity,user_consent_update
|
||||
|
||||
urlpatterns = [
|
||||
path('user-activity/', user_activity, name='user_activity'),
|
||||
|
||||
path('user/consent/update', user_consent_update, name='user_consent_update'),
|
||||
|
||||
]
|
||||
517
at_django_boilerplate/user_activity/views.py
Executable file
517
at_django_boilerplate/user_activity/views.py
Executable file
@@ -0,0 +1,517 @@
|
||||
# Django imports
|
||||
from django.contrib.auth.decorators import user_passes_test
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.core.cache import cache
|
||||
from django.core.paginator import Paginator, EmptyPage
|
||||
from django.shortcuts import render
|
||||
from django.http import HttpResponseRedirect, JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.db.models import F
|
||||
from django.utils import timezone
|
||||
from django.db.models import Count
|
||||
|
||||
# Standard library imports
|
||||
import logging
|
||||
import ipaddress
|
||||
import uuid
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from collections import Counter, defaultdict
|
||||
|
||||
# Third-party imports
|
||||
import requests
|
||||
|
||||
# Local app imports
|
||||
from at_django_boilerplate.user_activity.models import (
|
||||
PageVisit,
|
||||
SessionConsentModel,DailyUserStats,ClickEvent
|
||||
)
|
||||
from at_django_boilerplate.utils.geolocation import get_city_and_country_from_ip
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
User = get_user_model()
|
||||
LIVE_WINDOW_SECONDS = 40
|
||||
|
||||
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def user_consent_update(request):
|
||||
try:
|
||||
payload = json.loads(request.body)
|
||||
consent_id = int(payload.get("consent_id"))
|
||||
except (ValueError, json.JSONDecodeError):
|
||||
return JsonResponse(
|
||||
{"error": "Invalid JSON payload"},
|
||||
status=400
|
||||
)
|
||||
|
||||
session_id = request.session.session_key
|
||||
if not session_id:
|
||||
request.session.create()
|
||||
session_id = request.session.session_key
|
||||
|
||||
consent, _ = SessionConsentModel.objects.get_or_create(
|
||||
session_id=session_id
|
||||
)
|
||||
|
||||
if consent_id == 1:
|
||||
consent.accept_all_consent_received = True
|
||||
consent.essential_only_consent_received = False
|
||||
elif consent_id == 2:
|
||||
consent.essential_only_consent_received = True
|
||||
consent.accept_all_consent_received = False
|
||||
else:
|
||||
return JsonResponse(
|
||||
{"error": "Invalid consent_id"},
|
||||
status=400
|
||||
)
|
||||
|
||||
consent.save()
|
||||
|
||||
return JsonResponse({
|
||||
"success": True,
|
||||
"consent_received": consent.consent_received
|
||||
})
|
||||
|
||||
|
||||
# IP & Geolocation Utilities (unchanged)
|
||||
def is_private_ip(ip_address):
|
||||
try:
|
||||
ip = ipaddress.ip_address(ip_address)
|
||||
return ip.is_private
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def get_client_ip(request):
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0].strip()
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
|
||||
if not ip or is_private_ip(ip):
|
||||
return get_public_ip() # fallback for localhost/dev
|
||||
return ip
|
||||
|
||||
def get_public_ip():
|
||||
cached_ip = cache.get('public_ip')
|
||||
if cached_ip:
|
||||
return cached_ip
|
||||
try:
|
||||
response = requests.get('https://api.ipify.org?format=json', timeout=5)
|
||||
response.raise_for_status()
|
||||
ip = response.json().get('ip')
|
||||
if ip:
|
||||
cache.set('public_ip', ip, timeout=3600)
|
||||
return ip
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Failed to fetch public IP: {e}")
|
||||
return 'Unknown'
|
||||
|
||||
def _format_location(data):
|
||||
city = data.get('city', '')
|
||||
region = data.get('regionName', '')
|
||||
country = data.get('country', '')
|
||||
parts = [part for part in [city, region, country] if part]
|
||||
return ', '.join(parts) if parts else 'Unknown'
|
||||
|
||||
GEO_TTL = 60 * 60 * 24 # 24 hours
|
||||
|
||||
def get_location_from_ip(ip_address):
|
||||
cache_key = f'geo_{ip_address}'
|
||||
cached = cache.get(cache_key)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
if not ip_address or is_private_ip(ip_address):
|
||||
cache.set(cache_key, 'Local Network', 86400)
|
||||
return 'Local Network'
|
||||
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
f'https://ip-api.com/json/{ip_address}',
|
||||
params={'fields': 'status,message,country,regionName,city'},
|
||||
timeout=2
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get('status') == 'success':
|
||||
location = _format_location(data)
|
||||
|
||||
return location
|
||||
|
||||
except requests.RequestException as e:
|
||||
logger.error(f"Geo lookup failed for {ip_address}: {e}")
|
||||
|
||||
cache.set(cache_key, 'Unknown', 86400)
|
||||
return 'Unknown'
|
||||
|
||||
|
||||
# Utility Functions (updated for session data changes)
|
||||
def is_admin(user):
|
||||
return user.is_authenticated and user.is_staff and user.is_superuser
|
||||
|
||||
def _is_valid_session_key(session_key):
|
||||
try:
|
||||
uuid.UUID(session_key)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def _parse_login_time(timestamp):
|
||||
if not timestamp:
|
||||
return None
|
||||
try:
|
||||
return timezone.datetime.fromisoformat(timestamp)
|
||||
except (ValueError, TypeError):
|
||||
logger.warning(f"Invalid timestamp: {timestamp}")
|
||||
return None
|
||||
|
||||
# Financial Year Utilities (unchanged)
|
||||
def get_fy_dates(fy):
|
||||
try:
|
||||
start_year = int(fy.split('-')[0])
|
||||
end_year = int(fy.split('-')[1]) + 2000
|
||||
start_date = timezone.make_aware(datetime(start_year, 4, 1))
|
||||
end_date = timezone.make_aware(datetime(end_year, 3, 31, 23, 59, 59))
|
||||
return start_date, end_date
|
||||
except (ValueError, IndexError):
|
||||
logger.error(f"Invalid FY format: {fy}")
|
||||
today = timezone.now()
|
||||
start_year = today.year if today.month >= 4 else today.year - 1
|
||||
start_date = timezone.make_aware(datetime(start_year, 4, 1))
|
||||
end_date = timezone.make_aware(datetime(start_year + 1, 3, 31, 23, 59, 59))
|
||||
return start_date, end_date
|
||||
|
||||
def get_fy_choices():
|
||||
today = timezone.now()
|
||||
current_year = today.year if today.month >= 4 else today.year - 1
|
||||
fy_choices = [f"{year}-{str(year + 1)[2:]}" for year in range(2020, current_year + 1)]
|
||||
return fy_choices
|
||||
|
||||
# Device Detection (unchanged)
|
||||
def detect_device(user_agent):
|
||||
if not user_agent:
|
||||
return 'Unknown'
|
||||
user_agent = user_agent.lower()
|
||||
if 'mobile' in user_agent and 'tablet' not in user_agent:
|
||||
return 'Mobile'
|
||||
elif 'tablet' in user_agent or 'ipad' in user_agent:
|
||||
return 'Tablet'
|
||||
elif 'windows' in user_agent or 'macintosh' in user_agent or 'linux' in user_agent:
|
||||
return 'Desktop'
|
||||
else:
|
||||
return 'Other'
|
||||
|
||||
# Path Filtering (unchanged)
|
||||
def is_excluded_path(path):
|
||||
if not path:
|
||||
return True
|
||||
|
||||
admin_patterns = [
|
||||
r'^/admin/',
|
||||
r'^/admin_session/(?!user-activity/).*$', # Exclude /admin_session/ paths except /admin_session/user-activity/
|
||||
]
|
||||
for pattern in admin_patterns:
|
||||
if re.match(pattern, path):
|
||||
return True
|
||||
|
||||
media_patterns = [
|
||||
r'^/media/',
|
||||
r'^/static/',
|
||||
r'\.(jpg|jpeg|png|gif|svg|css|js|woff|woff2|ttf|eot|ico|mp4|webm|ogg|mp3|wav)$',
|
||||
]
|
||||
for pattern in media_patterns:
|
||||
if re.search(pattern, path, re.IGNORECASE):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# Analytics Functions (unchanged)
|
||||
def get_location_analytics(visits):
|
||||
if not visits:
|
||||
logger.debug("No visits for location analytics")
|
||||
return {'labels': [], 'counts': [], 'percentages': [], 'data': []}
|
||||
|
||||
location_counts = Counter(visit['location'] for visit in visits if visit['location'])
|
||||
total_visits = sum(location_counts.values())
|
||||
if total_visits == 0:
|
||||
logger.debug("No valid locations found")
|
||||
return {'labels': [], 'counts': [], 'percentages': [], 'data': []}
|
||||
|
||||
top_locations = location_counts.most_common(10)
|
||||
labels = [location for location, _ in top_locations]
|
||||
counts = [count for _, count in top_locations]
|
||||
percentages = [f"{(count / total_visits * 100):.2f}" for count in counts]
|
||||
data = list(zip(labels, counts, percentages))
|
||||
|
||||
logger.debug(f"Location Analytics: labels={labels}, counts={counts}, percentages={percentages}")
|
||||
return {
|
||||
'labels': labels,
|
||||
'counts': counts,
|
||||
'percentages': percentages,
|
||||
'data': data
|
||||
}
|
||||
|
||||
def get_paths_by_location(visits):
|
||||
if not visits:
|
||||
logger.debug("No visits for path analytics")
|
||||
return {'locations': []}
|
||||
|
||||
location_paths = defaultdict(Counter)
|
||||
for visit in visits:
|
||||
location = visit['location']
|
||||
path = visit['path']
|
||||
if location and path and not is_excluded_path(path):
|
||||
location_paths[location][path] += 1
|
||||
|
||||
locations_data = []
|
||||
for location, path_counts in location_paths.items():
|
||||
total_visits = sum(path_counts.values())
|
||||
if total_visits == 0:
|
||||
continue
|
||||
top_paths = path_counts.most_common(5)
|
||||
labels = [path for path, _ in top_paths]
|
||||
counts = [count for _, count in top_paths]
|
||||
percentages = [f"{(count / total_visits * 100):.2f}" for count in counts]
|
||||
data = list(zip(labels, counts, percentages))
|
||||
locations_data.append({
|
||||
'location': location,
|
||||
'labels': labels,
|
||||
'counts': counts,
|
||||
'percentages': percentages,
|
||||
'data': data
|
||||
})
|
||||
|
||||
locations_data.sort(key=lambda x: sum(x['counts']), reverse=True)
|
||||
locations_data = locations_data[:10]
|
||||
|
||||
logger.debug(f"Paths by Location: {locations_data}")
|
||||
return {'locations': locations_data}
|
||||
|
||||
def get_devices_by_location(visits):
|
||||
if not visits:
|
||||
logger.debug("No visits for device analytics")
|
||||
return {'locations': []}
|
||||
|
||||
location_devices = defaultdict(Counter)
|
||||
for visit in visits:
|
||||
location = visit['location']
|
||||
device = detect_device(visit['user_agent'])
|
||||
if location:
|
||||
location_devices[location][device] += 1
|
||||
|
||||
locations_data = []
|
||||
for location, device_counts in location_devices.items():
|
||||
total_visits = sum(device_counts.values())
|
||||
labels = list(device_counts.keys())
|
||||
counts = list(device_counts.values())
|
||||
percentages = [f"{(count / total_visits * 100):.2f}" for count in counts]
|
||||
data = list(zip(labels, counts, percentages))
|
||||
locations_data.append({
|
||||
'location': location,
|
||||
'labels': labels,
|
||||
'counts': counts,
|
||||
'percentages': percentages,
|
||||
'data': data
|
||||
})
|
||||
|
||||
locations_data.sort(key=lambda x: sum(x['counts']), reverse=True)
|
||||
locations_data = locations_data[:10]
|
||||
|
||||
logger.debug(f"Devices by Location: {locations_data}")
|
||||
return {'locations': locations_data}
|
||||
@user_passes_test(is_admin)
|
||||
def user_activity(request):
|
||||
"""
|
||||
Public Website Analytics Dashboard
|
||||
- Shows only anonymous (guest) user visits to public pages
|
||||
- Supports custom date range (?from=YYYY-MM-DD&to=YYYY-MM-DD)
|
||||
- Falls back to Financial Year filter
|
||||
"""
|
||||
ip_address = get_client_ip(request)
|
||||
tmp = get_city_and_country_from_ip(ip=ip_address)
|
||||
current_location = tmp.get('city', 'Unknown')
|
||||
|
||||
# === Custom Date Range Filter ===
|
||||
date_from_str = request.GET.get('from')
|
||||
date_to_str = request.GET.get('to')
|
||||
custom_date_range = False
|
||||
start_date = end_date = None
|
||||
|
||||
if date_from_str and date_to_str:
|
||||
try:
|
||||
start_date = timezone.make_aware(datetime.strptime(date_from_str, '%Y-%m-%d'))
|
||||
end_date = timezone.make_aware(
|
||||
datetime.strptime(date_to_str, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1)
|
||||
)
|
||||
if start_date <= end_date:
|
||||
custom_date_range = True
|
||||
else:
|
||||
# Swap if user entered backwards
|
||||
start_date, end_date = end_date, start_date
|
||||
custom_date_range = True
|
||||
except ValueError:
|
||||
pass # Invalid dates → fall back to FY
|
||||
|
||||
# === Financial Year Fallback ===
|
||||
fy_choices = get_fy_choices()
|
||||
selected_fy = request.GET.get('fy', fy_choices[-1] if fy_choices else '2024-25')
|
||||
|
||||
if not custom_date_range:
|
||||
start_date, end_date = get_fy_dates(selected_fy)
|
||||
|
||||
display_period = (
|
||||
f"{start_date.strftime('%b %d, %Y')} – {end_date.strftime('%b %d, %Y')}"
|
||||
if custom_date_range else
|
||||
f"FY {selected_fy}"
|
||||
)
|
||||
|
||||
# === Fetch Public Visits (anonymous only) ===
|
||||
public_visits_qs = PageVisit.objects.filter(
|
||||
timestamp__range=(start_date, end_date),
|
||||
user__isnull=True,
|
||||
).order_by('-timestamp')
|
||||
# === New vs Returning Users ===
|
||||
new_users_count = PageVisit.objects.filter(
|
||||
timestamp__range=(start_date, end_date),
|
||||
is_new_user=True
|
||||
).values('session_id').distinct().count()
|
||||
|
||||
returning_users_count = PageVisit.objects.filter(
|
||||
timestamp__range=(start_date, end_date),
|
||||
is_new_user=False
|
||||
).values('session_id').distinct().count()
|
||||
|
||||
# Build visit data (no path exclusion needed — handled in middleware)
|
||||
visit_data = []
|
||||
for visit in public_visits_qs:
|
||||
# Fix legacy missing locations
|
||||
if not visit.location or visit.location in ['', 'Unknown']:
|
||||
geo = get_city_and_country_from_ip(ip=visit.ip_address)
|
||||
visit.location = geo.get('city', 'Unknown')
|
||||
visit.save(update_fields=['location'])
|
||||
|
||||
visit_data.append({
|
||||
'path': visit.path,
|
||||
'location': visit.location or 'Unknown',
|
||||
'user_agent': visit.user_agent or 'Unknown',
|
||||
})
|
||||
|
||||
# Analytics
|
||||
location_analytics = get_location_analytics(visit_data)
|
||||
paths_by_location = get_paths_by_location(visit_data)
|
||||
devices_by_location = get_devices_by_location(visit_data)
|
||||
|
||||
# Top 10 most visited pages
|
||||
path_counter = Counter(v['path'] for v in visit_data)
|
||||
top_paths = path_counter.most_common(10)
|
||||
top_parts = [{'name': path, 'visits': count} for path, count in top_paths]
|
||||
live_visitors_count = get_live_visitors()
|
||||
|
||||
context = {
|
||||
'current_ip': ip_address,
|
||||
'current_location': current_location,
|
||||
'total_visits': len(visit_data),
|
||||
'location_analytics': location_analytics,
|
||||
'paths_by_location': paths_by_location,
|
||||
'devices_by_location': devices_by_location,
|
||||
'top_parts': top_parts,
|
||||
'fy_choices': fy_choices,
|
||||
'selected_fy': selected_fy,
|
||||
'display_period': display_period,
|
||||
'date_from': date_from_str or '',
|
||||
'date_to': date_to_str or '',
|
||||
'is_custom_range': custom_date_range,
|
||||
'new_users_count': new_users_count,
|
||||
'returning_users_count': returning_users_count,
|
||||
'live_visitors_count': live_visitors_count,
|
||||
|
||||
}
|
||||
|
||||
return render(request, 'active_sessions.html', context)
|
||||
|
||||
def track_page_visit(request):
|
||||
user = request.user if request.user.is_authenticated else None
|
||||
session_id = request.session.session_key or request.session.create()
|
||||
referrer = request.META.get('HTTP_REFERER', '')
|
||||
|
||||
# Check if user already visited in this session
|
||||
last_visit = PageVisit.objects.filter(session_id=session_id).order_by('-timestamp').first()
|
||||
visit_count = 1
|
||||
if last_visit:
|
||||
visit_count = last_visit.visit_count + 1
|
||||
|
||||
PageVisit.objects.create(
|
||||
user=user,
|
||||
session_id=session_id,
|
||||
url=request.build_absolute_uri(),
|
||||
path=request.path,
|
||||
ip_address=get_client_ip(request),
|
||||
user_agent=request.META.get('HTTP_USER_AGENT', ''),
|
||||
session_data=dict(request.session),
|
||||
referrer=referrer,
|
||||
visit_count=visit_count
|
||||
)
|
||||
|
||||
def get_client_ip(request):
|
||||
"""Extracts IP address safely"""
|
||||
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||
if x_forwarded_for:
|
||||
ip = x_forwarded_for.split(',')[0]
|
||||
else:
|
||||
ip = request.META.get('REMOTE_ADDR')
|
||||
return ip
|
||||
|
||||
def daily_user_report():
|
||||
today = timezone.now().date()
|
||||
start = timezone.make_aware(datetime.combine(today, datetime.min.time()))
|
||||
end = timezone.make_aware(datetime.combine(today, datetime.max.time()))
|
||||
|
||||
new_users = PageVisit.objects.filter(
|
||||
is_new_user=True,
|
||||
timestamp__range=(start, end)
|
||||
).count()
|
||||
|
||||
returning_users = PageVisit.objects.filter(
|
||||
is_new_user=False,
|
||||
timestamp__range=(start, end)
|
||||
).count()
|
||||
|
||||
DailyUserStats.objects.update_or_create(
|
||||
date=today,
|
||||
defaults={
|
||||
'new_users': new_users,
|
||||
'returning_users': returning_users
|
||||
}
|
||||
)
|
||||
def get_live_visitors():
|
||||
window_start = timezone.now() - timedelta(seconds=LIVE_WINDOW_SECONDS)
|
||||
|
||||
return (
|
||||
PageVisit.objects
|
||||
.filter(timestamp__gte=window_start)
|
||||
.values('session_id')
|
||||
.distinct()
|
||||
.count()
|
||||
)
|
||||
|
||||
@csrf_exempt
|
||||
@require_POST
|
||||
def track_click(request):
|
||||
data = json.loads(request.body)
|
||||
ClickEvent.objects.create(
|
||||
session_id=data["session_id"],
|
||||
page_path=data["path"],
|
||||
element_text=data["text"],
|
||||
element_type=data["type"]
|
||||
)
|
||||
return ''
|
||||
Reference in New Issue
Block a user