base setup

This commit is contained in:
2026-01-07 12:09:20 +05:30
commit 0c275efea1
278 changed files with 11228 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
from django.contrib import admin
from .models import *
admin.site.register(PageVisit)
# Register your models here.

View 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'

View 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
}

View 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

View File

@@ -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')],
},
),
]

View File

@@ -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'),
),
]

View 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)

View 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 %}

View 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))

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View 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'),
]

View 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 ''