From 908ea2c0a78c09e0dfea1582cf7e67feec2fdfa4 Mon Sep 17 00:00:00 2001 From: Naman Sharma Date: Thu, 26 Feb 2026 13:06:32 +0000 Subject: [PATCH 1/2] Add consent system with student only middleware enforcement --- consent/__init__.py | 0 consent/admin.py | 3 ++ consent/apps.py | 5 +++ consent/middleware.py | 45 ++++++++++++++++++++++++++ consent/migrations/0001_initial.py | 43 ++++++++++++++++++++++++ consent/migrations/__init__.py | 0 consent/models.py | 33 +++++++++++++++++++ consent/templates/consent/consent.html | 22 +++++++++++++ consent/tests.py | 3 ++ consent/urls.py | 9 ++++++ consent/utils.py | 24 ++++++++++++++ consent/views.py | 35 ++++++++++++++++++++ media/consent/terms.txt | 34 +++++++++++++++++++ spoken/urls.py | 3 ++ 14 files changed, 259 insertions(+) create mode 100644 consent/__init__.py create mode 100644 consent/admin.py create mode 100644 consent/apps.py create mode 100644 consent/middleware.py create mode 100644 consent/migrations/0001_initial.py create mode 100644 consent/migrations/__init__.py create mode 100644 consent/models.py create mode 100644 consent/templates/consent/consent.html create mode 100644 consent/tests.py create mode 100644 consent/urls.py create mode 100644 consent/utils.py create mode 100644 consent/views.py create mode 100644 media/consent/terms.txt diff --git a/consent/__init__.py b/consent/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/consent/admin.py b/consent/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/consent/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/consent/apps.py b/consent/apps.py new file mode 100644 index 000000000..1867de28e --- /dev/null +++ b/consent/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ConsentConfig(AppConfig): + name = 'consent' diff --git a/consent/middleware.py b/consent/middleware.py new file mode 100644 index 000000000..059787c84 --- /dev/null +++ b/consent/middleware.py @@ -0,0 +1,45 @@ +from django.conf import settings +from django.shortcuts import redirect +from django.urls import reverse + +from .models import ConsentVersion, UserConsent +from .utils import is_student_user + +CONSENT_SESSION_KEY = 'consent_version_id' + + +class ConsentMiddleware(object): + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + user = getattr(request, 'user', None) + if not user or not user.is_authenticated: + return self.get_response(request) + + if not is_student_user(user): + return self.get_response(request) + + path = request.path + consent_url = reverse('consent:consent') + if ( + path.startswith('/admin/') + or path.startswith(consent_url) + or path.startswith(settings.STATIC_URL) + or path.startswith(settings.MEDIA_URL) + ): + return self.get_response(request) + + active = ConsentVersion.objects.filter(is_active=True).first() + if not active: + return self.get_response(request) + + session_version = request.session.get(CONSENT_SESSION_KEY) + if session_version == active.pk: + return self.get_response(request) + + if UserConsent.objects.filter(user=user, consent=active).exists(): + request.session[CONSENT_SESSION_KEY] = active.pk + return self.get_response(request) + + return redirect('%s?next=%s' % (consent_url, request.get_full_path())) diff --git a/consent/migrations/0001_initial.py b/consent/migrations/0001_initial.py new file mode 100644 index 000000000..35f9fe388 --- /dev/null +++ b/consent/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ConsentVersion', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file_name', models.CharField(max_length=255)), + ('file_hash', models.CharField(max_length=64)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('is_active', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='UserConsent', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('accepted_at', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ('consent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='consent.ConsentVersion')), + ], + options={ + 'unique_together': {('user', 'consent')}, + }, + ), + ] diff --git a/consent/migrations/__init__.py b/consent/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/consent/models.py b/consent/models.py new file mode 100644 index 000000000..6a5cc9b32 --- /dev/null +++ b/consent/models.py @@ -0,0 +1,33 @@ +from django.conf import settings +from django.db import models + + +class ConsentVersion(models.Model): + file_name = models.CharField(max_length=255) + file_hash = models.CharField(max_length=64) + created_at = models.DateTimeField(auto_now_add=True) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ['-created_at'] + + def save(self, *args, **kwargs): + if self.is_active: + ConsentVersion.objects.filter(is_active=True).exclude(pk=self.pk).update(is_active=False) + super(ConsentVersion, self).save(*args, **kwargs) + + def __str__(self): + return '%s (%s)' % (self.file_name, 'active' if self.is_active else 'inactive') + + +class UserConsent(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + consent = models.ForeignKey(ConsentVersion, on_delete=models.CASCADE) + accepted_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ('user', 'consent') + + def __str__(self): + return '%s - v%s' % (self.user, self.consent_id) +gi \ No newline at end of file diff --git a/consent/templates/consent/consent.html b/consent/templates/consent/consent.html new file mode 100644 index 000000000..b09424e32 --- /dev/null +++ b/consent/templates/consent/consent.html @@ -0,0 +1,22 @@ +{% extends 'spoken/templates/base.html' %} + +{% block title %}Consent Required{% endblock %} + +{% block heading %}Consent Required{% endblock %} + +{% block content %} +
+
+ Consent Terms +
+
+ +
+ {% csrf_token %} + +
+
+
+{% endblock %} diff --git a/consent/tests.py b/consent/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/consent/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/consent/urls.py b/consent/urls.py new file mode 100644 index 000000000..1ed55edbc --- /dev/null +++ b/consent/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import url + +from .views import consent_view + +app_name = 'consent' + +urlpatterns = [ + url(r'^$', consent_view, name='consent'), +] diff --git a/consent/utils.py b/consent/utils.py new file mode 100644 index 000000000..37565f300 --- /dev/null +++ b/consent/utils.py @@ -0,0 +1,24 @@ +import hashlib +import os + +from django.conf import settings + + +def compute_file_hash(file_path): + digest = hashlib.sha256() + with open(file_path, 'rb') as fh: + for chunk in iter(lambda: fh.read(8192), b''): + digest.update(chunk) + return digest.hexdigest() + + +def get_consent_file_path(file_name): + return os.path.join(settings.MEDIA_ROOT, 'consent', file_name) + + +def is_student_user(user): + if not user.is_authenticated: + return False + if user.is_staff or user.is_superuser: + return False + return True diff --git a/consent/views.py b/consent/views.py new file mode 100644 index 000000000..0e5e4b3a3 --- /dev/null +++ b/consent/views.py @@ -0,0 +1,35 @@ +import os + +from django.conf import settings +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseServerError +from django.shortcuts import redirect, render + +from .middleware import CONSENT_SESSION_KEY +from .models import ConsentVersion, UserConsent + + +@login_required +def consent_view(request): + active = ConsentVersion.objects.filter(is_active=True).first() + if not active: + return HttpResponseServerError('Consent file is not configured.') + + file_path = os.path.join(settings.MEDIA_ROOT, 'consent', active.file_name) + if not os.path.isfile(file_path): + return HttpResponseServerError('Consent file is not configured.') + + if request.method == 'POST': + UserConsent.objects.get_or_create( + user=request.user, + consent=active, + ) + request.session[CONSENT_SESSION_KEY] = active.pk + return redirect('/') + + with open(file_path, 'r', encoding='utf-8', errors='replace') as fh: + consent_text = fh.read() + + return render(request, 'consent/consent.html', { + 'consent_text': consent_text, + }) diff --git a/media/consent/terms.txt b/media/consent/terms.txt new file mode 100644 index 000000000..e5565203e --- /dev/null +++ b/media/consent/terms.txt @@ -0,0 +1,34 @@ +Spoken Tutorial Project - Consent Agreement + +By using this platform, you agree to the following terms: + +1. Data Collection + We collect your name, email address, and academic details to provide + training services and issue certificates. + +2. Usage of Data + Your personal data will be used solely for the purpose of managing + your training, assessments, and certification on this platform. + +3. Communication + You consent to receive emails related to your training progress, + upcoming workshops, and certification updates. + +4. Content Usage + Spoken Tutorial content is licensed under Creative Commons and is + intended for educational purposes only. You agree not to use it + for commercial purposes without prior written permission. + +5. Code of Conduct + You agree to use the platform responsibly and respect the guidelines + set by the Spoken Tutorial Project, IIT Bombay. + +6. Data Retention + Your data will be retained as long as your account is active or as + required for certification and audit purposes. + +7. Right to Withdraw + You may withdraw your consent at any time by contacting the + administrator. Withdrawal may affect your access to certain services. + +For questions, contact: support@spoken-tutorial.org diff --git a/spoken/urls.py b/spoken/urls.py index f29dbd6ed..b2d691f4b 100644 --- a/spoken/urls.py +++ b/spoken/urls.py @@ -132,6 +132,9 @@ # Youtube API V3 url(r'^youtube/', include('youtube.urls', namespace='youtube')), + # consent + url(r'^consent/', include('consent.urls', namespace='consent')), + # reports url(r'^reports/', include('reports.urls', namespace='reports')), From 49186075e7bf3aca028c024dfed9530559cc47ff Mon Sep 17 00:00:00 2001 From: Naman Sharma Date: Sat, 28 Feb 2026 08:36:05 +0000 Subject: [PATCH 2/2] Updated consent model and added django admin utility to manage the tables --- consent/admin.py | 9 ++++- consent/middleware.py | 4 +-- .../migrations/0002_rename_and_add_fields.py | 33 +++++++++++++++++++ consent/models.py | 25 +++++++++----- consent/utils.py | 7 ---- consent/views.py | 10 +++--- 6 files changed, 63 insertions(+), 25 deletions(-) create mode 100644 consent/migrations/0002_rename_and_add_fields.py diff --git a/consent/admin.py b/consent/admin.py index 8c38f3f3d..5f1f94579 100644 --- a/consent/admin.py +++ b/consent/admin.py @@ -1,3 +1,10 @@ from django.contrib import admin -# Register your models here. +from .models import Consent + + +@admin.register(Consent) +class ConsentAdmin(admin.ModelAdmin): + list_display = ('file', 'type', 'is_active', 'file_hash', 'created_at') + list_filter = ('is_active', 'type') + readonly_fields = ('file_hash', 'created_at') diff --git a/consent/middleware.py b/consent/middleware.py index 059787c84..b99d4581a 100644 --- a/consent/middleware.py +++ b/consent/middleware.py @@ -2,7 +2,7 @@ from django.shortcuts import redirect from django.urls import reverse -from .models import ConsentVersion, UserConsent +from .models import Consent, UserConsent from .utils import is_student_user CONSENT_SESSION_KEY = 'consent_version_id' @@ -30,7 +30,7 @@ def __call__(self, request): ): return self.get_response(request) - active = ConsentVersion.objects.filter(is_active=True).first() + active = Consent.objects.filter(is_active=True).first() if not active: return self.get_response(request) diff --git a/consent/migrations/0002_rename_and_add_fields.py b/consent/migrations/0002_rename_and_add_fields.py new file mode 100644 index 000000000..df0a8042b --- /dev/null +++ b/consent/migrations/0002_rename_and_add_fields.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('consent', '0001_initial'), + ] + + operations = [ + migrations.RenameModel( + old_name='ConsentVersion', + new_name='Consent', + ), + migrations.AddField( + model_name='consent', + name='type', + field=models.PositiveIntegerField(default=1), + ), + migrations.RemoveField( + model_name='consent', + name='file_name', + ), + migrations.AddField( + model_name='consent', + name='file', + field=models.FileField(default='', upload_to='consent/'), + preserve_default=False, + ), + ] diff --git a/consent/models.py b/consent/models.py index 6a5cc9b32..af94e536b 100644 --- a/consent/models.py +++ b/consent/models.py @@ -1,10 +1,13 @@ from django.conf import settings from django.db import models +from .utils import compute_file_hash -class ConsentVersion(models.Model): - file_name = models.CharField(max_length=255) - file_hash = models.CharField(max_length=64) + +class Consent(models.Model): + file = models.FileField(upload_to='consent/') + file_hash = models.CharField(max_length=64, blank=True) + type = models.PositiveIntegerField(default=1) created_at = models.DateTimeField(auto_now_add=True) is_active = models.BooleanField(default=True) @@ -13,21 +16,25 @@ class Meta: def save(self, *args, **kwargs): if self.is_active: - ConsentVersion.objects.filter(is_active=True).exclude(pk=self.pk).update(is_active=False) - super(ConsentVersion, self).save(*args, **kwargs) + Consent.objects.filter(is_active=True).exclude(pk=self.pk).update(is_active=False) + super(Consent, self).save(*args, **kwargs) + if self.file: + new_hash = compute_file_hash(self.file.path) + if new_hash != self.file_hash: + self.file_hash = new_hash + super(Consent, self).save(update_fields=['file_hash']) def __str__(self): - return '%s (%s)' % (self.file_name, 'active' if self.is_active else 'inactive') + return '%s (%s)' % (self.file.name, 'active' if self.is_active else 'inactive') class UserConsent(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) - consent = models.ForeignKey(ConsentVersion, on_delete=models.CASCADE) + consent = models.ForeignKey(Consent, on_delete=models.CASCADE) accepted_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ('user', 'consent') def __str__(self): - return '%s - v%s' % (self.user, self.consent_id) -gi \ No newline at end of file + return '%s - v%s' % (self.user, self.consent_id) \ No newline at end of file diff --git a/consent/utils.py b/consent/utils.py index 37565f300..8a3e74aa9 100644 --- a/consent/utils.py +++ b/consent/utils.py @@ -1,7 +1,4 @@ import hashlib -import os - -from django.conf import settings def compute_file_hash(file_path): @@ -12,10 +9,6 @@ def compute_file_hash(file_path): return digest.hexdigest() -def get_consent_file_path(file_name): - return os.path.join(settings.MEDIA_ROOT, 'consent', file_name) - - def is_student_user(user): if not user.is_authenticated: return False diff --git a/consent/views.py b/consent/views.py index 0e5e4b3a3..489675bcf 100644 --- a/consent/views.py +++ b/consent/views.py @@ -1,22 +1,20 @@ import os -from django.conf import settings from django.contrib.auth.decorators import login_required from django.http import HttpResponseServerError from django.shortcuts import redirect, render from .middleware import CONSENT_SESSION_KEY -from .models import ConsentVersion, UserConsent +from .models import Consent, UserConsent @login_required def consent_view(request): - active = ConsentVersion.objects.filter(is_active=True).first() + active = Consent.objects.filter(is_active=True).first() if not active: return HttpResponseServerError('Consent file is not configured.') - file_path = os.path.join(settings.MEDIA_ROOT, 'consent', active.file_name) - if not os.path.isfile(file_path): + if not active.file or not os.path.isfile(active.file.path): return HttpResponseServerError('Consent file is not configured.') if request.method == 'POST': @@ -27,7 +25,7 @@ def consent_view(request): request.session[CONSENT_SESSION_KEY] = active.pk return redirect('/') - with open(file_path, 'r', encoding='utf-8', errors='replace') as fh: + with open(active.file.path, 'r', encoding='utf-8', errors='replace') as fh: consent_text = fh.read() return render(request, 'consent/consent.html', {