diff --git a/consent/__init__.py b/consent/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/consent/admin.py b/consent/admin.py new file mode 100644 index 00000000..5f1f9457 --- /dev/null +++ b/consent/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +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/apps.py b/consent/apps.py new file mode 100644 index 00000000..1867de28 --- /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 00000000..b99d4581 --- /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 Consent, 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 = Consent.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 00000000..35f9fe38 --- /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/0002_rename_and_add_fields.py b/consent/migrations/0002_rename_and_add_fields.py new file mode 100644 index 00000000..df0a8042 --- /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/migrations/__init__.py b/consent/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/consent/models.py b/consent/models.py new file mode 100644 index 00000000..af94e536 --- /dev/null +++ b/consent/models.py @@ -0,0 +1,40 @@ +from django.conf import settings +from django.db import models + +from .utils import compute_file_hash + + +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) + + class Meta: + ordering = ['-created_at'] + + def save(self, *args, **kwargs): + if self.is_active: + 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') + + +class UserConsent(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, 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) \ 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 00000000..b09424e3 --- /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 00000000..7ce503c2 --- /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 00000000..1ed55edb --- /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 00000000..8a3e74aa --- /dev/null +++ b/consent/utils.py @@ -0,0 +1,17 @@ +import hashlib + + +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 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 00000000..489675bc --- /dev/null +++ b/consent/views.py @@ -0,0 +1,33 @@ +import os + +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 Consent, UserConsent + + +@login_required +def consent_view(request): + active = Consent.objects.filter(is_active=True).first() + if not active: + return HttpResponseServerError('Consent file is not configured.') + + if not active.file or not os.path.isfile(active.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(active.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 00000000..e5565203 --- /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 f29dbd6e..b2d691f4 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')),