From eb6f00d9b3a120315620ecaa9ce2d519506c695e Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi Date: Wed, 10 Sep 2025 18:08:13 +0530 Subject: [PATCH 1/9] Spam_questions --- .env.example | 9 - forums/settings.py | 20 +- forums/views.py | 12 +- seed_spam_rules.py | 116 ++++++++++++ static/website/js/thread-user.js | 24 +++ website/forms.py | 73 +++++--- website/helpers.py | 173 +++++++++++++++++- website/migrations/0001_initial.py | 131 +++++++++++++ website/migrations/0002_auto_20250910_1740.py | 35 ++++ website/migrations/__init__.py | 0 website/models.py | 53 +++++- website/views.py | 145 ++++++++------- 12 files changed, 665 insertions(+), 126 deletions(-) delete mode 100644 .env.example create mode 100644 seed_spam_rules.py create mode 100644 website/migrations/0001_initial.py create mode 100644 website/migrations/0002_auto_20250910_1740.py create mode 100644 website/migrations/__init__.py diff --git a/.env.example b/.env.example deleted file mode 100644 index b9a00a7..0000000 --- a/.env.example +++ /dev/null @@ -1,9 +0,0 @@ -DB='forums' -DB_USER='root' -DB_PASSWORD= '1234' -SPOKEN_DB='13thJune' - -SECRET_KEY='' -VIDEO_PATH='' -DEBUG=True -TEMPLATE_DEBUG=True \ No newline at end of file diff --git a/forums/settings.py b/forums/settings.py index 6c549d2..821dda3 100644 --- a/forums/settings.py +++ b/forums/settings.py @@ -114,16 +114,16 @@ 'HOST': '', 'PORT': '', # Set to empty string for default. }, - 'cdeep': { - 'ENGINE': 'django.db.backends.mysql', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. - 'NAME': 'CDEEP', # Or path to database file if using sqlite3. - # The following settings are not used with sqlite3: - 'USER': os.getenv("DB_USER"), - 'PASSWORD': os.getenv("DB_PASSWORD"), - # Empty for localhost through domain sockets or '127.0.0.1' for localhost through TCP. - 'HOST': '', - 'PORT': '', # Set to empty string for default. - }, + #'cdeep': { + # 'ENGINE': 'django.db.backends.mysql', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. + # 'NAME': 'CDEEP', # Or path to database file if using sqlite3. + # # The following settings are not used with sqlite3: + # 'USER': os.getenv("DB_USER"), + # 'PASSWORD': os.getenv("DB_PASSWORD"), + # # Empty for localhost through domain sockets or '127.0.0.1' for localhost through TCP. + # 'HOST': '', + # 'PORT': '', # Set to empty string for default. + #}, } # Password validation diff --git a/forums/views.py b/forums/views.py index c73b8a6..007ed3f 100755 --- a/forums/views.py +++ b/forums/views.py @@ -1,6 +1,6 @@ from django.http import HttpResponseRedirect, HttpResponse from django.contrib.auth import login, logout -from django.shortcuts import render_to_response +from django.shortcuts import render from django.template.context_processors import csrf from forums.forms import UserLoginForm @@ -29,7 +29,7 @@ def user_login(request): 'resetpasssucs': resetpasssucs } context.update(csrf(request)) - return render_to_response('forums/templates/user-login.html', context) + return render(request,'forums/templates/user-login.html', context) else: return HttpResponseRedirect('/') @@ -49,7 +49,7 @@ def updatepassword(request): confirm = request.POST['confirm_new_password'] if new_password == "" or confirm == "": context['empty'] = True - return render_to_response("update-password.html", context) + return render(request,"update-password.html", context) if new_password == confirm: user.set_password(new_password) user.save() @@ -61,14 +61,14 @@ def updatepassword(request): return HttpResponseRedirect('/') else: context['no_match'] = True - return render_to_response("forums/templates/update-password.html", context) + return render(request,"forums/templates/update-password.html", context) else: - return render_to_response("forums/templates/update-password.html", context) + return render(request,"forums/templates/update-password.html", context) else: form = UserLoginForm() context['form'] = form context['for_update_password'] = True - return render_to_response('website/templates/index.html', context) + return render(request,'website/templates/index.html', context) def robots_txt(request): with open('robots.txt', 'r') as f: diff --git a/seed_spam_rules.py b/seed_spam_rules.py new file mode 100644 index 0000000..3e393b0 --- /dev/null +++ b/seed_spam_rules.py @@ -0,0 +1,116 @@ +# Script to seed the database with predefined spam rules +import os +import django + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "forums.settings") +django.setup() + +from django.db.models import Q +from website.models import SpamRule + + + +def seed_spam_rules(): + rules = { + # Certification/Exam dump patterns + "Certification/Exam Spam": { + "score": 30, + "type": SpamRule.KEYWORD, + "patterns": [ + r"exam\s+dumps?", r"braindumps?", r"practice\s+test", + r"certification\s+exam", r"test\s+preparation", + r"exam\s+questions?", r"study\s+guides?", + r"pdf\s+\+\s+testing\s+engine", r"testing\s+engine", + r"exam\s+prep", r"mock\s+exam", r"real\s+exam", + r"dumps\s+pdf", r"braindump" + ], + }, + + # Promotional spam + "Promotional Spam": { + "score": 25, + "type": SpamRule.KEYWORD, + "patterns": [ + r"click\s+here", r"join\s+now", r"limited\s+time", + r"discount", r"coupon\s+code", r"20%\s+off", + r"free\s+download", r"get\s+certified", + r"unlock\s+your\s+career", r"master\s+the", + r"boost\s+your\s+career", r"cert20", + r"at\s+checkout", r"special\s+offer", + ], + }, + + # Suspicious domains + "Suspicious Domain": { + "score": 35, + "type": SpamRule.DOMAIN, + "patterns": [ + r"dumpscafe\.com", r"certsout\.com", r"mycertshub\.com", + r"vmexam\.com", r"kissnutra\.com", r"dumps.*\.com", + r"cert.*\.com", r"exam.*\.com", + ], + }, + + # Generic business language + "Business/Career Spam": { + "score": 15, + "type": SpamRule.KEYWORD, + "patterns": [ + r"attests\s+to\s+your\s+proficiency", + r"esteemed\s+(?:accreditation|certification|credential)", + r"valuable\s+asset\s+to\s+companies", + r"demonstrates\s+your\s+ability", + r"comprehensive\s+study\s+(?:tools|materials)", + r"interactive\s+practice\s+tests", + r"real\s+exam\s+questions", + r"actual\s+exam\s+questions", + r"validated\s+by\s+.*certification", + r"urgently\s+need\s+experts", + ], + }, + + # Gaming content + "Gaming Spam": { + "score": 20, + "type": SpamRule.KEYWORD, + "patterns": [ + r"spacebar\s+clicker", r"clicker\s+game", + r"addictive\s+game", r"upgrades\s+available", + r"instant\s+rewards", + ], + }, + + # Health/Supplement spam + "Health Spam": { + "score": 22, + "type": SpamRule.KEYWORD, + "patterns": [ + r"vitalit[äa]t", r"nahrungserg[äa]nzungsmittel", + r"libido", r"fruchtbarkeit", r"energie", + r"hormonelle\s+balance", r"perforan", + ], + }, + } + + inserted, skipped = 0, 0 + for note, config in rules.items(): + for pattern in config["patterns"]: + exists = SpamRule.objects.filter( + Q(pattern=pattern) & Q(type=config["type"]) + ).exists() + if not exists: + SpamRule.objects.create( + type=config["type"], + pattern=pattern, + score=config["score"], + notes=note, + ) + inserted += 1 + else: + skipped += 1 + + print(f"✅ Inserted {inserted} new rules, skipped {skipped} existing ones.") + + +# Run it +seed_spam_rules() diff --git a/static/website/js/thread-user.js b/static/website/js/thread-user.js index fbf4d47..588e2a7 100644 --- a/static/website/js/thread-user.js +++ b/static/website/js/thread-user.js @@ -6,6 +6,30 @@ bkLib.onDomLoaded(function() { questionNicEditor.setPanel('questionNicPanel'); questionNicEditor.addInstance('questionInstance'); }); +function getCookie(name) { + let cookieValue = null; + if (document.cookie && document.cookie !== '') { + const cookies = document.cookie.split(';'); + for (let i = 0; i < cookies.length; i++) { + const cookie = cookies[i].trim(); + if (cookie.substring(0, name.length + 1) === (name + '=')) { + cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); + break; + } + } + } + return cookieValue; +} +const csrftoken = getCookie('csrftoken'); + +$.ajaxSetup({ + beforeSend: function(xhr, settings) { + if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrftoken); + } + } +}); + $(document).ready(function() { /* diff --git a/website/forms.py b/website/forms.py index 4a41b7e..902535b 100755 --- a/website/forms.py +++ b/website/forms.py @@ -8,13 +8,34 @@ minutes = () seconds = () +# Pre-fetch FOSS ids shown on homepage +foss_ids = list( + FossCategory.objects.using('spoken') + .filter(show_on_homepage=1) + .values_list('id', flat=True) # get ids only +) + class NewQuestionForm(forms.Form): - category = forms.ChoiceField(choices=[('', 'Select a Category'), ] + list(TutorialResources.objects.filter( - Q(status=1) | Q(status=2), language__name='English',tutorial_detail__foss__show_on_homepage=1).values('tutorial_detail__foss__foss').order_by( - 'tutorial_detail__foss__foss').values_list('tutorial_detail__foss__foss', - 'tutorial_detail__foss__foss').distinct()), - widget=forms.Select(attrs={}), required=True, error_messages={'required': 'State field is required.'}) + category = forms.ChoiceField( + choices=[('', 'Select a Category')] + + list( + TutorialResources.objects.using('spoken') + .filter( + Q(status__in=[1, 2]), # cleaner than Q(status=1)|Q(status=2) + language_id=22, # use id instead of name + tutorial_detail__foss_id__in=foss_ids + ) + .values('tutorial_detail__foss__foss') + .order_by('tutorial_detail__foss__foss') + .values_list('tutorial_detail__foss__foss', 'tutorial_detail__foss__foss') + .distinct() + ), + widget=forms.Select(attrs={}), + required=True, + error_messages={'required': 'State field is required.'} + ) + title = forms.CharField(max_length=200) body = forms.CharField(widget=forms.Textarea()) @@ -24,49 +45,43 @@ def __init__(self, *args, **kwargs): select_min = kwargs.pop('minute_range', None) select_sec = kwargs.pop('second_range', None) + super(NewQuestionForm, self).__init__(*args, **kwargs) - tutorial_choices = ( - ("Select a Tutorial", "Select a Tutorial"), - ) - # check minute_range, secpnd_range coming from spoken website - # user clicks on post question link through website - if (select_min is None and select_sec is None): - minutes = ( - (select_min, select_min), - ) - seconds = ( - (select_sec, select_sec), - ) + + tutorial_choices = (("Select a Tutorial", "Select a Tutorial"),) + + # Set minutes & seconds + if select_min is None and select_sec is None: + minutes = ((select_min, select_min),) + seconds = ((select_sec, select_sec),) else: - minutes = ( - ("", "min"), - ) - seconds = ( - ("", "sec"), - ) + minutes = (("", "min"),) + seconds = (("", "sec"),) + # Handle category logic if not category and args and 'category' in args[0] and args[0]['category']: category = args[0]['category'] - if FossCategory.objects.filter(foss=category).exists(): + + if FossCategory.objects.using('spoken').filter(foss=category).exists(): self.fields['category'].initial = category tutorials = TutorialDetails.objects.using('spoken').filter(foss__foss=category) for tutorial in tutorials: tutorial_choices += ((tutorial.tutorial, tutorial.tutorial),) + self.fields['tutorial'] = forms.CharField(widget=forms.Select(choices=tutorial_choices)) + if TutorialDetails.objects.using('spoken').filter(tutorial=selecttutorial).exists(): self.fields['tutorial'].initial = selecttutorial - self.fields['minute_range'] = forms.CharField(widget=forms.Select(choices=minutes)) - self.fields['second_range'] = forms.CharField(widget=forms.Select(choices=seconds)) - else: - self.fields['minute_range'] = forms.CharField(widget=forms.Select(choices=minutes)) - self.fields['second_range'] = forms.CharField(widget=forms.Select(choices=seconds)) + self.fields['minute_range'] = forms.CharField(widget=forms.Select(choices=minutes)) + self.fields['second_range'] = forms.CharField(widget=forms.Select(choices=seconds)) else: self.fields['tutorial'] = forms.CharField(widget=forms.Select(choices=tutorial_choices)) self.fields['minute_range'] = forms.CharField(widget=forms.Select(choices=minutes)) self.fields['second_range'] = forms.CharField(widget=forms.Select(choices=seconds)) + class AnswerQuesitionForm(forms.Form): question = forms.IntegerField(widget=forms.HiddenInput()) body = forms.CharField(widget=forms.Textarea()) diff --git a/website/helpers.py b/website/helpers.py index 5db0a53..e42c5d1 100755 --- a/website/helpers.py +++ b/website/helpers.py @@ -1,10 +1,25 @@ import re -from website.models import Question -from nltk.corpus import stopwords -from nltk.tokenize import word_tokenize +import json +import logging +from datetime import datetime +from typing import Dict, List, Tuple, Optional +from website.models import Question, User +from nltk.corpus import stopwords +from website.templatetags.permission_tags import can_edit, can_hide_delete from sklearn.metrics.pairwise import cosine_similarity +from django.conf import settings +from django.utils import timezone +from django.db.models import Q +import re +from .models import SpamRule, SpamLog # assuming app is `forum` + sw = stopwords.words('english') +# Configure logging for spam detection +logging.basicConfig(level=logging.INFO) +spam_logger = logging.getLogger('spam_detection') + + def get_video_info(path): """Uses ffmpeg to determine information about a video. This has not been broadly tested and your milage may vary""" @@ -50,11 +65,13 @@ def pre_process(text): return text def clean_user_data(text): + from nltk.tokenize import word_tokenize words = word_tokenize(pre_process(text.lower())) clean_list = [w for w in words if not w in sw] return clean_list def get_similar_questions(user_ques,question): + from nltk.tokenize import word_tokenize total = [] l1 = [] l2 = [] @@ -67,4 +84,152 @@ def get_similar_questions(user_ques,question): if w in question: l2.append(1) else: l2.append(0) cs = cosine_similarity((l1,l2)) - return cs[0][1] \ No newline at end of file + return cs[0][1] + + +# helpers.py + +MULTIPLE_URL_WEIGHT = 20 +MULTIPLE_URL_THRESHOLD = 3 + +class SpamQuestionDetector: + def __init__(self): + # load only active + not expired rules + now = timezone.now() + qs = SpamRule.objects.filter(active=True).filter( + Q(expires_at__isnull=True) | Q(expires_at__gt=now) + ) + self._compiled = [] + for r in qs: + try: + cre = re.compile(r.pattern, re.IGNORECASE) + except re.error: + spam_logger.warning(f"Invalid regex in SpamRule id={r.id}: {r.pattern}") + continue + self._compiled.append({ + 'rule': r, + 'compiled': cre + }) + + def extract_urls(self, text: str): + return re.findall(r'https?://[^\s)<>"]+', text) + + def detect_spam(self, title: str, content: str, category: str = "", tutorial: str = "") -> dict: + combined_text = " ".join(filter(None, [title, content, category, tutorial])).lower() + spam_score = 0 + matches = [] + + for entry in self._compiled: + rule = entry['rule'] + cre = entry['compiled'] + if cre.search(combined_text): + spam_score += rule.score + matches.append({ + 'id': rule.id, + 'pattern': rule.pattern, + 'score': rule.score, + 'type': rule.type, + 'notes': rule.notes + }) + + # detect multiple URLs (we keep this behaviour from original) + urls = self.extract_urls(combined_text) + if len(urls) >= MULTIPLE_URL_THRESHOLD: + spam_score += MULTIPLE_URL_WEIGHT + matches.append({ + 'pattern': f'{len(urls)} URLs', + 'score': MULTIPLE_URL_WEIGHT, + 'type': 'urls' + }) + + # classification (same thresholds as earlier) + if spam_score >= 60: + confidence, action = 'HIGH', 'DELETE' + elif spam_score >= 30: + confidence, action = 'MEDIUM', 'REVIEW' + elif spam_score >= 15: + confidence, action = 'LOW', 'REVIEW' + else: + confidence, action = 'CLEAN', 'APPROVE' + + result = { + 'spam_score': spam_score, + 'matches': matches, + 'confidence': confidence, + 'recommended_action': action, + 'url_count': len(urls) + } + + # debug log + spam_logger.info(f"SpamDetect result: score={spam_score} action={action} matches={len(matches)}") + + return result + + +def handle_spam(question, user, delete_on_high=True, save_question_metadata_before_delete=True): + """ + Runs detection on a saved Question instance and logs/takes action. + - question: saved Question instance (has .id) + - user: Django user instance who created the question (for logging) + - delete_on_high: if True, HIGH confidence -> delete from DB; otherwise hide it (status=0) + Returns a status string: 'AUTO_DELETE', 'FLAGGED', 'APPROVED', 'HIDDEN' + """ + detector = SpamQuestionDetector() + result = detector.detect_spam( + title=getattr(question, 'title', '') or '', + content=getattr(question, 'body', '') or '', + category=getattr(question, 'category', '') or '', + tutorial=getattr(question, 'tutorial', '') or '' + ) + + spam_score = result['spam_score'] + confidence = result['confidence'] + action = result['recommended_action'] + details = result['matches'] + + # prepare log payload + log_payload = { + #'question_id': question.id, + 'user_id': user.id if user else None, + 'category': getattr(question, 'category', '') or '', + 'title': getattr(question, 'title', '') or '', + 'content': getattr(question, 'body', '') or '', + 'action': None, + 'spam_score': spam_score, + 'confidence': confidence, + 'details': details + } + + # TAKE ACTION + if action == 'DELETE' and confidence == 'HIGH': + log_payload['action'] = 'AUTO_DELETE' + SpamLog.objects.create(**log_payload) + + if delete_on_high: + # delete after logging + spam_logger.info(f"AUTO_DELETE: Question {question.id} by user {user.id} score={spam_score}") + question.delete() + return 'AUTO_DELETE' + else: + # hide instead of delete + question.spam = True + question.status = 0 + question.save(update_fields=['spam', 'status']) + return 'HIDDEN' + + elif action == 'REVIEW': + # flag for admin review + log_payload['action'] = 'FLAGGED' + SpamLog.objects.create(**log_payload) + + question.approval_required = True + question.spam = False + question.save(update_fields=['approval_required', 'spam']) + return 'FLAGGED' + + else: + # APPROVE / CLEAN + question.spam = False + question.approval_required = False + question.save(update_fields=['spam', 'approval_required']) + return 'APPROVED' \ No newline at end of file diff --git a/website/migrations/0001_initial.py b/website/migrations/0001_initial.py new file mode 100644 index 0000000..6c4f8d4 --- /dev/null +++ b/website/migrations/0001_initial.py @@ -0,0 +1,131 @@ +# Generated by Django 2.2.6 on 2025-09-10 12:01 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Answer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.IntegerField()), + ('body', models.TextField()), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_modified', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.IntegerField()), + ('pid', models.IntegerField()), + ('qid', models.IntegerField()), + ('aid', models.IntegerField(default=0)), + ('cid', models.IntegerField(default=0)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Question', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.IntegerField()), + ('category', models.CharField(max_length=200)), + ('tutorial', models.CharField(max_length=200)), + ('minute_range', models.CharField(max_length=10)), + ('second_range', models.CharField(max_length=10)), + ('title', models.CharField(max_length=200)), + ('body', models.TextField()), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_modified', models.DateTimeField(auto_now=True)), + ('views', models.IntegerField(default=1)), + ('status', models.IntegerField(default=1)), + ('last_active', models.DateTimeField(null=True)), + ('last_post_by', models.IntegerField(null=True)), + ], + options={ + 'get_latest_by': 'date_created', + }, + ), + migrations.CreateModel( + name='SpamLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField(blank=True, null=True)), + ('category', models.CharField(blank=True, max_length=200)), + ('title', models.CharField(blank=True, max_length=200)), + ('content', models.TextField(blank=True)), + ('action', models.CharField(choices=[('AUTO_DELETE', 'Auto Deleted'), ('FLAGGED', 'Flagged for Review'), ('APPROVED', 'Approved')], max_length=20)), + ('spam_score', models.IntegerField()), + ('confidence', models.CharField(max_length=20)), + ('details', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='SpamRule', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('keyword', 'Keyword'), ('domain', 'Domain / URL')], max_length=10)), + ('pattern', models.CharField(max_length=500)), + ('score', models.IntegerField(default=1)), + ('active', models.BooleanField(default=True)), + ('notes', models.CharField(blank=True, max_length=200)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='QuestionVote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.IntegerField()), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Question')), + ], + ), + migrations.CreateModel( + name='QuestionComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.IntegerField()), + ('body', models.TextField()), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_modified', models.DateTimeField(auto_now=True)), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Question')), + ], + ), + migrations.CreateModel( + name='AnswerVote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.IntegerField()), + ('answer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Answer')), + ], + ), + migrations.CreateModel( + name='AnswerComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.IntegerField()), + ('body', models.TextField()), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_modified', models.DateTimeField(auto_now=True)), + ('answer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Answer')), + ], + ), + migrations.AddField( + model_name='answer', + name='question', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Question'), + ), + ] diff --git a/website/migrations/0002_auto_20250910_1740.py b/website/migrations/0002_auto_20250910_1740.py new file mode 100644 index 0000000..071ddf0 --- /dev/null +++ b/website/migrations/0002_auto_20250910_1740.py @@ -0,0 +1,35 @@ +# Generated by Django 2.2.6 on 2025-09-10 12:10 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('website', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='question', + name='approval_required', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='question', + name='approved_by', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='question', + name='date_approved', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='question', + name='spam', + field=models.BooleanField(default=False), + ), + ] diff --git a/website/migrations/__init__.py b/website/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/website/models.py b/website/models.py index 9dbf6f0..799519f 100755 --- a/website/models.py +++ b/website/models.py @@ -1,6 +1,6 @@ from django.db import models from django.contrib.auth import get_user_model - +import json User = get_user_model() @@ -18,6 +18,11 @@ class Question(models.Model): status = models.IntegerField(default=1) last_active = models.DateTimeField(null=True) last_post_by = models.IntegerField(null=True) + spam = models.BooleanField(default=False) + approval_required = models.BooleanField(default=False) + approved_by = models.IntegerField(null=True) + date_approved = models.DateTimeField(auto_now_add=True) + # votes = models.IntegerField(default=0) def user(self): @@ -88,3 +93,49 @@ def poster(self): return user.username # CDEEP database created using inspectdb arg of manage.py +class SpamRule(models.Model): + KEYWORD = "keyword" + DOMAIN = "domain" + TYPES = [(KEYWORD, "Keyword"), (DOMAIN, "Domain / URL")] + + type = models.CharField(max_length=10, choices=TYPES) + pattern = models.CharField(max_length=500) + score = models.IntegerField(default=1) + active = models.BooleanField(default=True) + notes = models.CharField(max_length=200, blank=True) + expires_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.type}: {self.pattern} ({self.score})" + +class SpamLog(models.Model): + ACTIONS = [ + ("AUTO_DELETE", "Auto Deleted"), + ("FLAGGED", "Flagged for Review"), + ("APPROVED", "Approved"), + ] + + #question_id = models.IntegerField() + user_id = models.IntegerField(null=True, blank=True) + category = models.CharField(max_length=200, blank=True) + title = models.CharField(max_length=200, blank=True) + content = models.TextField(blank=True) + action = models.CharField(max_length=20, choices=ACTIONS) + spam_score = models.IntegerField() + confidence = models.CharField(max_length=20) + details = models.TextField(blank=True, null=True) + + def set_details(self, data): + self.details = json.dumps(data) + + def get_details(self): + try: + return json.loads(self.details) + except Exception: + return {} + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Q{self.question_id} - {self.action} ({self.spam_score})" diff --git a/website/views.py b/website/views.py index c40fce4..c0221cb 100755 --- a/website/views.py +++ b/website/views.py @@ -8,11 +8,19 @@ from django.core.mail import EmailMultiAlternatives from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.contrib.auth import get_user_model - +from django.contrib import messages from website.models import Question, Answer, Notification, AnswerComment from spoken_auth.models import TutorialDetails, TutorialResources from website.forms import NewQuestionForm, AnswerQuesitionForm -from website.helpers import get_video_info, prettify, clean_user_data, get_similar_questions +from website.helpers import ( + get_video_info, + prettify, + clean_user_data, + get_similar_questions, + SpamQuestionDetector, + handle_spam +) + from django.conf import settings from website.templatetags.permission_tags import can_edit, can_hide_delete from spoken_auth.models import FossCategory @@ -68,39 +76,32 @@ def home(request): def questions(request): - questions = Question.objects.filter(status=1).order_by('category', 'tutorial') - questions = questions.annotate(total_answers=Count('answer')) + context = {} + if request.method == 'POST': + form = NewQuestionForm(request.POST) + if form.is_valid(): + cleaned_data = form.cleaned_data + question = Question( + uid=request.user.id, + category=cleaned_data['category'].replace(' ', '-'), + tutorial=cleaned_data['tutorial'].replace(' ', '-'), + minute_range=cleaned_data['minute_range'], + second_range=cleaned_data['second_range'], + title=cleaned_data['title'], + body=cleaned_data['body'], + views=1, + ) + question.save() - raw_get_data = request.GET.get('o', None) + action = handle_spam(question, request.user, delete_on_high=True) - header = { - 1: SortableHeader('category', True, 'Foss'), - 2: SortableHeader('tutorial', True, 'Tutorial Name'), - 3: SortableHeader('minute_range', True, 'Mins'), - 4: SortableHeader('second_range', True, 'Secs'), - 5: SortableHeader('title', True, 'Title'), - 6: SortableHeader('date_created', True, 'Date'), - 7: SortableHeader('views', True, 'Views'), - 8: SortableHeader('total_answers', 'True', 'Answers'), - 9: SortableHeader('username', False, 'User') - } - - tmp_recs = get_sorted_list(request, questions, header, raw_get_data) - ordering = get_field_index(raw_get_data) - paginator = Paginator(tmp_recs, 20) - page = request.GET.get('page') - try: - questions = paginator.page(page) - except PageNotAnInteger: - questions = paginator.page(1) - except EmptyPage: - questions = paginator.page(paginator.num_pages) - context = { - 'questions': questions, - 'header': header, - 'ordering': ordering - } - return render(request, 'website/templates/questions.html', context) + if action == 'AUTO_DELETE': + # inform the user, stop further processing (email etc.) + messages.error(request, "Your question was removed because it looks like spam.") + return HttpResponseRedirect('/') # or another page + elif action == 'FLAGGED': + messages.warning(request, "Your question is pending moderator review.") + return render(request, 'website/templates/questions.html', context) def hidden_questions(request): @@ -331,47 +332,57 @@ def new_question(request): question.body = cleaned_data['body'] question.views = 1 question.save() + # Run spam detection + action = handle_spam(question, request.user) + + if action == "AUTO_DELETE": + messages.error(request, " Your question was removed because it looks like spam.") + return HttpResponseRedirect('/') + + elif action == "FLAGGED": + messages.warning(request, " Your question is pending moderator review.") + # Don’t send email for flagged content + return HttpResponseRedirect('/') + + else: # APPROVED + + subject = 'New Forum Question' + message = f""" + The following new question has been posted in the Spoken Tutorial Forum:
+ Title: {question.title}
+ Category: {question.category}
+ Tutorial: {question.tutorial}
+ Link: + http://forums.spoken-tutorial.org/question/{question.id} +
+ Question: {question.body}
+ """ + email = EmailMultiAlternatives( + subject, '', 'forums', + ['team@spoken-tutorial.org', 'team@fossee.in'], + headers={"Content-type": "text/html;charset=iso-8859-1"} + ) + email.attach_alternative(message, "text/html") + email.send(fail_silently=True) + return HttpResponseRedirect('/') - # Sending email when a new question is asked - subject = 'New Forum Question' - message = """ - The following new question has been posted in the Spoken Tutorial Forum:
- Title: {0}
- Category: {1}
- Tutorial: {2}
- Link: {3}
- Question: {4}
- """.format( - question.title, - question.category, - question.tutorial, - 'http://forums.spoken-tutorial.org/question/' + str(question.id), - question.body - ) - email = EmailMultiAlternatives( - subject, '', 'forums', - ['team@spoken-tutorial.org', 'team@fossee.in'], - headers={"Content-type": "text/html;charset=iso-8859-1"} - ) - email.attach_alternative(message, "text/html") - email.send(fail_silently=True) - # End of email send + # If form not valid → re-render with errors + context['form'] = form + return render(request, 'website/templates/new-question.html', context) - return HttpResponseRedirect('/') else: - # get values from URL. + # GET request → render empty form category = request.GET.get('category', None) tutorial = request.GET.get('tutorial', None) minute_range = request.GET.get('minute_range', None) second_range = request.GET.get('second_range', None) - # pass minute_range and second_range value to NewQuestionForm to populate on select - form = NewQuestionForm(category=category, tutorial=tutorial, - minute_range=minute_range, second_range=second_range) + form = NewQuestionForm( + category=category, tutorial=tutorial, + minute_range=minute_range, second_range=second_range + ) + context['form'] = form context['category'] = category - - context['form'] = form - context.update(csrf(request)) - return render(request, 'website/templates/new-question.html', context) + return render(request, 'website/templates/new-question.html', context) # Notification Section @@ -616,7 +627,7 @@ def ajax_delete_question(request): if can_edit(user=request.user, obj=question) or can_hide_delete(user=request.user, obj=question): question.delete() result = True - return HttpResponse(json.dumps(result), mimetype='application/json') + return HttpResponse(json.dumps(result), content_type='application/json') @login_required From 853308d7527bf581225064f5b95de7e3c5a84526 Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi Date: Thu, 11 Sep 2025 16:55:57 +0530 Subject: [PATCH 2/9] Modified/Corrected --- .env.example | 9 ++++ forums/settings.py | 20 ++++----- forums/views.py | 12 ++--- static/website/js/thread-user.js | 24 ---------- website/forms.py | 75 +++++++++++++------------------- website/helpers.py | 3 +- website/views.py | 69 +++++++++++++++-------------- 7 files changed, 90 insertions(+), 122 deletions(-) create mode 100644 .env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b9a00a7 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +DB='forums' +DB_USER='root' +DB_PASSWORD= '1234' +SPOKEN_DB='13thJune' + +SECRET_KEY='' +VIDEO_PATH='' +DEBUG=True +TEMPLATE_DEBUG=True \ No newline at end of file diff --git a/forums/settings.py b/forums/settings.py index 821dda3..6c549d2 100644 --- a/forums/settings.py +++ b/forums/settings.py @@ -114,16 +114,16 @@ 'HOST': '', 'PORT': '', # Set to empty string for default. }, - #'cdeep': { - # 'ENGINE': 'django.db.backends.mysql', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. - # 'NAME': 'CDEEP', # Or path to database file if using sqlite3. - # # The following settings are not used with sqlite3: - # 'USER': os.getenv("DB_USER"), - # 'PASSWORD': os.getenv("DB_PASSWORD"), - # # Empty for localhost through domain sockets or '127.0.0.1' for localhost through TCP. - # 'HOST': '', - # 'PORT': '', # Set to empty string for default. - #}, + 'cdeep': { + 'ENGINE': 'django.db.backends.mysql', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. + 'NAME': 'CDEEP', # Or path to database file if using sqlite3. + # The following settings are not used with sqlite3: + 'USER': os.getenv("DB_USER"), + 'PASSWORD': os.getenv("DB_PASSWORD"), + # Empty for localhost through domain sockets or '127.0.0.1' for localhost through TCP. + 'HOST': '', + 'PORT': '', # Set to empty string for default. + }, } # Password validation diff --git a/forums/views.py b/forums/views.py index 007ed3f..c73b8a6 100755 --- a/forums/views.py +++ b/forums/views.py @@ -1,6 +1,6 @@ from django.http import HttpResponseRedirect, HttpResponse from django.contrib.auth import login, logout -from django.shortcuts import render +from django.shortcuts import render_to_response from django.template.context_processors import csrf from forums.forms import UserLoginForm @@ -29,7 +29,7 @@ def user_login(request): 'resetpasssucs': resetpasssucs } context.update(csrf(request)) - return render(request,'forums/templates/user-login.html', context) + return render_to_response('forums/templates/user-login.html', context) else: return HttpResponseRedirect('/') @@ -49,7 +49,7 @@ def updatepassword(request): confirm = request.POST['confirm_new_password'] if new_password == "" or confirm == "": context['empty'] = True - return render(request,"update-password.html", context) + return render_to_response("update-password.html", context) if new_password == confirm: user.set_password(new_password) user.save() @@ -61,14 +61,14 @@ def updatepassword(request): return HttpResponseRedirect('/') else: context['no_match'] = True - return render(request,"forums/templates/update-password.html", context) + return render_to_response("forums/templates/update-password.html", context) else: - return render(request,"forums/templates/update-password.html", context) + return render_to_response("forums/templates/update-password.html", context) else: form = UserLoginForm() context['form'] = form context['for_update_password'] = True - return render(request,'website/templates/index.html', context) + return render_to_response('website/templates/index.html', context) def robots_txt(request): with open('robots.txt', 'r') as f: diff --git a/static/website/js/thread-user.js b/static/website/js/thread-user.js index 588e2a7..fbf4d47 100644 --- a/static/website/js/thread-user.js +++ b/static/website/js/thread-user.js @@ -6,30 +6,6 @@ bkLib.onDomLoaded(function() { questionNicEditor.setPanel('questionNicPanel'); questionNicEditor.addInstance('questionInstance'); }); -function getCookie(name) { - let cookieValue = null; - if (document.cookie && document.cookie !== '') { - const cookies = document.cookie.split(';'); - for (let i = 0; i < cookies.length; i++) { - const cookie = cookies[i].trim(); - if (cookie.substring(0, name.length + 1) === (name + '=')) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; -} -const csrftoken = getCookie('csrftoken'); - -$.ajaxSetup({ - beforeSend: function(xhr, settings) { - if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { - xhr.setRequestHeader("X-CSRFToken", csrftoken); - } - } -}); - $(document).ready(function() { /* diff --git a/website/forms.py b/website/forms.py index 902535b..38211e5 100755 --- a/website/forms.py +++ b/website/forms.py @@ -8,34 +8,13 @@ minutes = () seconds = () -# Pre-fetch FOSS ids shown on homepage -foss_ids = list( - FossCategory.objects.using('spoken') - .filter(show_on_homepage=1) - .values_list('id', flat=True) # get ids only -) - class NewQuestionForm(forms.Form): - category = forms.ChoiceField( - choices=[('', 'Select a Category')] + - list( - TutorialResources.objects.using('spoken') - .filter( - Q(status__in=[1, 2]), # cleaner than Q(status=1)|Q(status=2) - language_id=22, # use id instead of name - tutorial_detail__foss_id__in=foss_ids - ) - .values('tutorial_detail__foss__foss') - .order_by('tutorial_detail__foss__foss') - .values_list('tutorial_detail__foss__foss', 'tutorial_detail__foss__foss') - .distinct() - ), - widget=forms.Select(attrs={}), - required=True, - error_messages={'required': 'State field is required.'} - ) - + category = forms.ChoiceField(choices=[('', 'Select a Category'), ] + list(TutorialResources.objects.filter( + Q(status=1) | Q(status=2), language__name='English',tutorial_detail__foss__show_on_homepage=1).values('tutorial_detail__foss__foss').order_by( + 'tutorial_detail__foss__foss').values_list('tutorial_detail__foss__foss', + 'tutorial_detail__foss__foss').distinct()), + widget=forms.Select(attrs={}), required=True, error_messages={'required': 'State field is required.'}) title = forms.CharField(max_length=200) body = forms.CharField(widget=forms.Textarea()) @@ -45,43 +24,49 @@ def __init__(self, *args, **kwargs): select_min = kwargs.pop('minute_range', None) select_sec = kwargs.pop('second_range', None) - super(NewQuestionForm, self).__init__(*args, **kwargs) - - tutorial_choices = (("Select a Tutorial", "Select a Tutorial"),) - - # Set minutes & seconds - if select_min is None and select_sec is None: - minutes = ((select_min, select_min),) - seconds = ((select_sec, select_sec),) + tutorial_choices = ( + ("Select a Tutorial", "Select a Tutorial"), + ) + # check minute_range, secpnd_range coming from spoken website + # user clicks on post question link through website + if (select_min is None and select_sec is None): + minutes = ( + (select_min, select_min), + ) + seconds = ( + (select_sec, select_sec), + ) else: - minutes = (("", "min"),) - seconds = (("", "sec"),) + minutes = ( + ("", "min"), + ) + seconds = ( + ("", "sec"), + ) - # Handle category logic if not category and args and 'category' in args[0] and args[0]['category']: category = args[0]['category'] - - if FossCategory.objects.using('spoken').filter(foss=category).exists(): + if FossCategory.objects.filter(foss=category).exists(): self.fields['category'].initial = category tutorials = TutorialDetails.objects.using('spoken').filter(foss__foss=category) for tutorial in tutorials: tutorial_choices += ((tutorial.tutorial, tutorial.tutorial),) - self.fields['tutorial'] = forms.CharField(widget=forms.Select(choices=tutorial_choices)) - if TutorialDetails.objects.using('spoken').filter(tutorial=selecttutorial).exists(): self.fields['tutorial'].initial = selecttutorial - self.fields['minute_range'] = forms.CharField(widget=forms.Select(choices=minutes)) - self.fields['second_range'] = forms.CharField(widget=forms.Select(choices=seconds)) + self.fields['minute_range'] = forms.CharField(widget=forms.Select(choices=minutes)) + self.fields['second_range'] = forms.CharField(widget=forms.Select(choices=seconds)) + else: + self.fields['minute_range'] = forms.CharField(widget=forms.Select(choices=minutes)) + self.fields['second_range'] = forms.CharField(widget=forms.Select(choices=seconds)) else: self.fields['tutorial'] = forms.CharField(widget=forms.Select(choices=tutorial_choices)) self.fields['minute_range'] = forms.CharField(widget=forms.Select(choices=minutes)) self.fields['second_range'] = forms.CharField(widget=forms.Select(choices=seconds)) - class AnswerQuesitionForm(forms.Form): question = forms.IntegerField(widget=forms.HiddenInput()) - body = forms.CharField(widget=forms.Textarea()) + body = forms.CharField(widget=forms.Textarea()) \ No newline at end of file diff --git a/website/helpers.py b/website/helpers.py index e42c5d1..546f453 100755 --- a/website/helpers.py +++ b/website/helpers.py @@ -5,6 +5,7 @@ from typing import Dict, List, Tuple, Optional from website.models import Question, User from nltk.corpus import stopwords +from nltk.tokenize import word_tokenize from website.templatetags.permission_tags import can_edit, can_hide_delete from sklearn.metrics.pairwise import cosine_similarity from django.conf import settings @@ -65,13 +66,11 @@ def pre_process(text): return text def clean_user_data(text): - from nltk.tokenize import word_tokenize words = word_tokenize(pre_process(text.lower())) clean_list = [w for w in words if not w in sw] return clean_list def get_similar_questions(user_ques,question): - from nltk.tokenize import word_tokenize total = [] l1 = [] l2 = [] diff --git a/website/views.py b/website/views.py index c0221cb..bff1bef 100755 --- a/website/views.py +++ b/website/views.py @@ -12,15 +12,7 @@ from website.models import Question, Answer, Notification, AnswerComment from spoken_auth.models import TutorialDetails, TutorialResources from website.forms import NewQuestionForm, AnswerQuesitionForm -from website.helpers import ( - get_video_info, - prettify, - clean_user_data, - get_similar_questions, - SpamQuestionDetector, - handle_spam -) - +from website.helpers import get_video_info, prettify,clean_user_data, get_similar_questions, SpamQuestionDetector, handle_spam from django.conf import settings from website.templatetags.permission_tags import can_edit, can_hide_delete from spoken_auth.models import FossCategory @@ -76,32 +68,39 @@ def home(request): def questions(request): - context = {} - if request.method == 'POST': - form = NewQuestionForm(request.POST) - if form.is_valid(): - cleaned_data = form.cleaned_data - question = Question( - uid=request.user.id, - category=cleaned_data['category'].replace(' ', '-'), - tutorial=cleaned_data['tutorial'].replace(' ', '-'), - minute_range=cleaned_data['minute_range'], - second_range=cleaned_data['second_range'], - title=cleaned_data['title'], - body=cleaned_data['body'], - views=1, - ) - question.save() - - action = handle_spam(question, request.user, delete_on_high=True) + questions = Question.objects.filter(status=1).order_by('category', 'tutorial') + questions = questions.annotate(total_answers=Count('answer')) + + raw_get_data = request.GET.get('o', None) + + header = { + 1: SortableHeader('category', True, 'Foss'), + 2: SortableHeader('tutorial', True, 'Tutorial Name'), + 3: SortableHeader('minute_range', True, 'Mins'), + 4: SortableHeader('second_range', True, 'Secs'), + 5: SortableHeader('title', True, 'Title'), + 6: SortableHeader('date_created', True, 'Date'), + 7: SortableHeader('views', True, 'Views'), + 8: SortableHeader('total_answers', 'True', 'Answers'), + 9: SortableHeader('username', False, 'User') + } - if action == 'AUTO_DELETE': - # inform the user, stop further processing (email etc.) - messages.error(request, "Your question was removed because it looks like spam.") - return HttpResponseRedirect('/') # or another page - elif action == 'FLAGGED': - messages.warning(request, "Your question is pending moderator review.") - return render(request, 'website/templates/questions.html', context) + tmp_recs = get_sorted_list(request, questions, header, raw_get_data) + ordering = get_field_index(raw_get_data) + paginator = Paginator(tmp_recs, 20) + page = request.GET.get('page') + try: + questions = paginator.page(page) + except PageNotAnInteger: + questions = paginator.page(1) + except EmptyPage: + questions = paginator.page(paginator.num_pages) + context = { + 'questions': questions, + 'header': header, + 'ordering': ordering + } + return render(request, 'website/templates/questions.html', context) def hidden_questions(request): @@ -627,7 +626,7 @@ def ajax_delete_question(request): if can_edit(user=request.user, obj=question) or can_hide_delete(user=request.user, obj=question): question.delete() result = True - return HttpResponse(json.dumps(result), content_type='application/json') + return HttpResponse(json.dumps(result), mimetype='application/json') @login_required From 1473d91c82c2b03375cd041272a212421b8ee325 Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi <139005062+Bhavishya-Chaturvedi@users.noreply.github.com> Date: Thu, 11 Sep 2025 16:57:11 +0530 Subject: [PATCH 3/9] Delete website/migrations directory --- website/migrations/0001_initial.py | 131 ------------------ website/migrations/0002_auto_20250910_1740.py | 35 ----- website/migrations/__init__.py | 0 3 files changed, 166 deletions(-) delete mode 100644 website/migrations/0001_initial.py delete mode 100644 website/migrations/0002_auto_20250910_1740.py delete mode 100644 website/migrations/__init__.py diff --git a/website/migrations/0001_initial.py b/website/migrations/0001_initial.py deleted file mode 100644 index 6c4f8d4..0000000 --- a/website/migrations/0001_initial.py +++ /dev/null @@ -1,131 +0,0 @@ -# Generated by Django 2.2.6 on 2025-09-10 12:01 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Answer', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uid', models.IntegerField()), - ('body', models.TextField()), - ('date_created', models.DateTimeField(auto_now_add=True)), - ('date_modified', models.DateTimeField(auto_now=True)), - ], - ), - migrations.CreateModel( - name='Notification', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uid', models.IntegerField()), - ('pid', models.IntegerField()), - ('qid', models.IntegerField()), - ('aid', models.IntegerField(default=0)), - ('cid', models.IntegerField(default=0)), - ('date_created', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='Question', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uid', models.IntegerField()), - ('category', models.CharField(max_length=200)), - ('tutorial', models.CharField(max_length=200)), - ('minute_range', models.CharField(max_length=10)), - ('second_range', models.CharField(max_length=10)), - ('title', models.CharField(max_length=200)), - ('body', models.TextField()), - ('date_created', models.DateTimeField(auto_now_add=True)), - ('date_modified', models.DateTimeField(auto_now=True)), - ('views', models.IntegerField(default=1)), - ('status', models.IntegerField(default=1)), - ('last_active', models.DateTimeField(null=True)), - ('last_post_by', models.IntegerField(null=True)), - ], - options={ - 'get_latest_by': 'date_created', - }, - ), - migrations.CreateModel( - name='SpamLog', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('user_id', models.IntegerField(blank=True, null=True)), - ('category', models.CharField(blank=True, max_length=200)), - ('title', models.CharField(blank=True, max_length=200)), - ('content', models.TextField(blank=True)), - ('action', models.CharField(choices=[('AUTO_DELETE', 'Auto Deleted'), ('FLAGGED', 'Flagged for Review'), ('APPROVED', 'Approved')], max_length=20)), - ('spam_score', models.IntegerField()), - ('confidence', models.CharField(max_length=20)), - ('details', models.TextField(blank=True, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ], - ), - migrations.CreateModel( - name='SpamRule', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('type', models.CharField(choices=[('keyword', 'Keyword'), ('domain', 'Domain / URL')], max_length=10)), - ('pattern', models.CharField(max_length=500)), - ('score', models.IntegerField(default=1)), - ('active', models.BooleanField(default=True)), - ('notes', models.CharField(blank=True, max_length=200)), - ('expires_at', models.DateTimeField(blank=True, null=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('updated_at', models.DateTimeField(auto_now=True)), - ], - ), - migrations.CreateModel( - name='QuestionVote', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uid', models.IntegerField()), - ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Question')), - ], - ), - migrations.CreateModel( - name='QuestionComment', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uid', models.IntegerField()), - ('body', models.TextField()), - ('date_created', models.DateTimeField(auto_now_add=True)), - ('date_modified', models.DateTimeField(auto_now=True)), - ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Question')), - ], - ), - migrations.CreateModel( - name='AnswerVote', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uid', models.IntegerField()), - ('answer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Answer')), - ], - ), - migrations.CreateModel( - name='AnswerComment', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('uid', models.IntegerField()), - ('body', models.TextField()), - ('date_created', models.DateTimeField(auto_now_add=True)), - ('date_modified', models.DateTimeField(auto_now=True)), - ('answer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Answer')), - ], - ), - migrations.AddField( - model_name='answer', - name='question', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Question'), - ), - ] diff --git a/website/migrations/0002_auto_20250910_1740.py b/website/migrations/0002_auto_20250910_1740.py deleted file mode 100644 index 071ddf0..0000000 --- a/website/migrations/0002_auto_20250910_1740.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 2.2.6 on 2025-09-10 12:10 - -from django.db import migrations, models -import django.utils.timezone - - -class Migration(migrations.Migration): - - dependencies = [ - ('website', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='question', - name='approval_required', - field=models.BooleanField(default=False), - ), - migrations.AddField( - model_name='question', - name='approved_by', - field=models.IntegerField(null=True), - ), - migrations.AddField( - model_name='question', - name='date_approved', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='question', - name='spam', - field=models.BooleanField(default=False), - ), - ] diff --git a/website/migrations/__init__.py b/website/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 From ec1e8ee35a71b0a8aa5c2d0d89b7bde7c28ae7f7 Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi Date: Fri, 12 Sep 2025 11:27:48 +0530 Subject: [PATCH 4/9] Resolved logging --- forums/settings.py | 30 ++++++++++++++++++++++++++++-- website/helpers.py | 18 +++++++++++------- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/forums/settings.py b/forums/settings.py index 6c549d2..f76f9cc 100644 --- a/forums/settings.py +++ b/forums/settings.py @@ -267,28 +267,54 @@ except ImportError: pass - LOGGING = { 'version': 1, 'disable_existing_loggers': False, + 'filters': { 'require_debug_false': { '()': 'django.utils.log.RequireDebugFalse' } }, + + 'formatters': { + 'verbose': { + 'format': '[{asctime}] {levelname} {name}: {message}', + 'style': '{', + }, + }, + 'handlers': { 'mail_admins': { 'level': 'ERROR', 'filters': ['require_debug_false'], 'class': 'django.utils.log.AdminEmailHandler' - } + }, + + # 🔹 New handler for spam detection + 'spam_file': { + 'level': 'INFO', + 'class': 'logging.handlers.RotatingFileHandler', + 'filename': BASE_DIR / 'logs/spam_detection.log', # create logs/ dir + 'maxBytes': 1024 * 1024 * 5, # 5 MB + 'backupCount': 5, # keep 5 old logs + 'formatter': 'verbose', + }, }, + 'loggers': { 'django.request': { 'handlers': ['mail_admins'], 'level': 'ERROR', 'propagate': True, }, + + # 🔹 New dedicated logger + 'spam_detection': { + 'handlers': ['spam_file'], + 'level': 'INFO', + 'propagate': False, + }, } } diff --git a/website/helpers.py b/website/helpers.py index 546f453..77fa8cf 100755 --- a/website/helpers.py +++ b/website/helpers.py @@ -17,7 +17,7 @@ sw = stopwords.words('english') # Configure logging for spam detection -logging.basicConfig(level=logging.INFO) +import logging spam_logger = logging.getLogger('spam_detection') @@ -113,7 +113,7 @@ def __init__(self): def extract_urls(self, text: str): return re.findall(r'https?://[^\s)<>"]+', text) - def detect_spam(self, title: str, content: str, category: str = "", tutorial: str = "") -> dict: + def detect_spam(self,user,question, title: str, content: str, category: str = "", tutorial: str = "") -> dict: combined_text = " ".join(filter(None, [title, content, category, tutorial])).lower() spam_score = 0 matches = [] @@ -160,8 +160,10 @@ def detect_spam(self, title: str, content: str, category: str = "", tutorial: st } # debug log - spam_logger.info(f"SpamDetect result: score={spam_score} action={action} matches={len(matches)}") - + spam_logger.info( + "SpamDetect result: question_id=%s user_id=%s score=%s action=%s matches=%s", + question.id, user.id, spam_score, action, len(matches) + ) return result @@ -175,6 +177,8 @@ def handle_spam(question, user, delete_on_high=True, save_question_metadata_befo """ detector = SpamQuestionDetector() result = detector.detect_spam( + user=user, + question=question, title=getattr(question, 'title', '') or '', content=getattr(question, 'body', '') or '', category=getattr(question, 'category', '') or '', @@ -188,7 +192,7 @@ def handle_spam(question, user, delete_on_high=True, save_question_metadata_befo # prepare log payload log_payload = { - #'question_id': question.id, + 'question_id': question.id, 'user_id': user.id if user else None, 'category': getattr(question, 'category', '') or '', 'title': getattr(question, 'title', '') or '', @@ -206,8 +210,8 @@ def handle_spam(question, user, delete_on_high=True, save_question_metadata_befo if delete_on_high: # delete after logging - spam_logger.info(f"AUTO_DELETE: Question {question.id} by user {user.id} score={spam_score}") - question.delete() + spam_logger.info(f"MARK_INACTIVE: Question {question.id} by user {user.id} score={spam_score}") + question.status = 0 return 'AUTO_DELETE' else: # hide instead of delete From 86e1a350c27632bd0c2c1781619bb367eb5c1b11 Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi Date: Fri, 12 Sep 2025 12:37:17 +0530 Subject: [PATCH 5/9] Updated .env.example settings.py helpers.py --- .env.example | 3 ++- forums/settings.py | 8 ++++---- website/helpers.py | 1 + 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index b9a00a7..d1a9246 100644 --- a/.env.example +++ b/.env.example @@ -6,4 +6,5 @@ SPOKEN_DB='13thJune' SECRET_KEY='' VIDEO_PATH='' DEBUG=True -TEMPLATE_DEBUG=True \ No newline at end of file +TEMPLATE_DEBUG=True +SPAM_LOG_FILE='' \ No newline at end of file diff --git a/forums/settings.py b/forums/settings.py index f76f9cc..9b4a62c 100644 --- a/forums/settings.py +++ b/forums/settings.py @@ -291,11 +291,11 @@ 'class': 'django.utils.log.AdminEmailHandler' }, - # 🔹 New handler for spam detection + # New handler for spam detection 'spam_file': { 'level': 'INFO', 'class': 'logging.handlers.RotatingFileHandler', - 'filename': BASE_DIR / 'logs/spam_detection.log', # create logs/ dir + 'filename': SPAM_LOG_FILE, # create logs/ dir 'maxBytes': 1024 * 1024 * 5, # 5 MB 'backupCount': 5, # keep 5 old logs 'formatter': 'verbose', @@ -309,7 +309,7 @@ 'propagate': True, }, - # 🔹 New dedicated logger + # New dedicated logger 'spam_detection': { 'handlers': ['spam_file'], 'level': 'INFO', @@ -317,5 +317,5 @@ }, } } - +SPAM_LOG_FILE = os.getenv("SPAM_LOG_FILE", BASE_DIR / "logs/spam_detection.log") VIDEO_PATH = os.getenv("VIDEO_PATH") \ No newline at end of file diff --git a/website/helpers.py b/website/helpers.py index 77fa8cf..a3e410c 100755 --- a/website/helpers.py +++ b/website/helpers.py @@ -212,6 +212,7 @@ def handle_spam(question, user, delete_on_high=True, save_question_metadata_befo # delete after logging spam_logger.info(f"MARK_INACTIVE: Question {question.id} by user {user.id} score={spam_score}") question.status = 0 + question.save(update_fields=["status"]) return 'AUTO_DELETE' else: # hide instead of delete From 0db431f4b494e6f57ee6c0053dd2f3b99b330ddc Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi Date: Fri, 12 Sep 2025 18:09:29 +0530 Subject: [PATCH 6/9] migrations/logging --- forums/logs/spam_detection.log | 0 forums/settings.py | 2 +- website/migrations/0001_initial.py | 102 ++++++++++++++++++ website/migrations/0002_auto_20250912_1713.py | 65 +++++++++++ website/migrations/__init__.py | 0 website/models.py | 2 +- 6 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 forums/logs/spam_detection.log create mode 100644 website/migrations/0001_initial.py create mode 100644 website/migrations/0002_auto_20250912_1713.py create mode 100644 website/migrations/__init__.py diff --git a/forums/logs/spam_detection.log b/forums/logs/spam_detection.log new file mode 100644 index 0000000..e69de29 diff --git a/forums/settings.py b/forums/settings.py index 9b4a62c..0ee0882 100644 --- a/forums/settings.py +++ b/forums/settings.py @@ -17,6 +17,7 @@ # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +SPAM_LOG_FILE = os.getenv("SPAM_LOG_FILE") # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ @@ -317,5 +318,4 @@ }, } } -SPAM_LOG_FILE = os.getenv("SPAM_LOG_FILE", BASE_DIR / "logs/spam_detection.log") VIDEO_PATH = os.getenv("VIDEO_PATH") \ No newline at end of file diff --git a/website/migrations/0001_initial.py b/website/migrations/0001_initial.py new file mode 100644 index 0000000..8c484ae --- /dev/null +++ b/website/migrations/0001_initial.py @@ -0,0 +1,102 @@ +# Generated by Django 2.2.6 on 2025-09-12 11:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Answer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.IntegerField()), + ('body', models.TextField()), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_modified', models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.IntegerField()), + ('pid', models.IntegerField()), + ('qid', models.IntegerField()), + ('aid', models.IntegerField(default=0)), + ('cid', models.IntegerField(default=0)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Question', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.IntegerField()), + ('category', models.CharField(max_length=200)), + ('tutorial', models.CharField(max_length=200)), + ('minute_range', models.CharField(max_length=10)), + ('second_range', models.CharField(max_length=10)), + ('title', models.CharField(max_length=200)), + ('body', models.TextField()), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_modified', models.DateTimeField(auto_now=True)), + ('views', models.IntegerField(default=1)), + ('status', models.IntegerField(default=1)), + ('last_active', models.DateTimeField(null=True)), + ('last_post_by', models.IntegerField(null=True)), + ], + options={ + 'get_latest_by': 'date_created', + }, + ), + migrations.CreateModel( + name='QuestionVote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.IntegerField()), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Question')), + ], + ), + migrations.CreateModel( + name='QuestionComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.IntegerField()), + ('body', models.TextField()), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_modified', models.DateTimeField(auto_now=True)), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Question')), + ], + ), + migrations.CreateModel( + name='AnswerVote', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.IntegerField()), + ('answer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Answer')), + ], + ), + migrations.CreateModel( + name='AnswerComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uid', models.IntegerField()), + ('body', models.TextField()), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_modified', models.DateTimeField(auto_now=True)), + ('answer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Answer')), + ], + ), + migrations.AddField( + model_name='answer', + name='question', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='website.Question'), + ), + ] diff --git a/website/migrations/0002_auto_20250912_1713.py b/website/migrations/0002_auto_20250912_1713.py new file mode 100644 index 0000000..9dd45e8 --- /dev/null +++ b/website/migrations/0002_auto_20250912_1713.py @@ -0,0 +1,65 @@ +# Generated by Django 2.2.6 on 2025-09-12 11:43 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('website', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='SpamLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('question_id', models.IntegerField()), + ('user_id', models.IntegerField(blank=True, null=True)), + ('category', models.CharField(blank=True, max_length=200)), + ('title', models.CharField(blank=True, max_length=200)), + ('content', models.TextField(blank=True)), + ('action', models.CharField(choices=[('AUTO_DELETE', 'Auto Deleted'), ('FLAGGED', 'Flagged for Review'), ('APPROVED', 'Approved')], max_length=20)), + ('spam_score', models.IntegerField()), + ('confidence', models.CharField(max_length=20)), + ('details', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='SpamRule', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.CharField(choices=[('keyword', 'Keyword'), ('domain', 'Domain / URL')], max_length=10)), + ('pattern', models.CharField(max_length=500)), + ('score', models.IntegerField(default=1)), + ('active', models.BooleanField(default=True)), + ('notes', models.CharField(blank=True, max_length=200)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + ), + migrations.AddField( + model_name='question', + name='approval_required', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='question', + name='approved_by', + field=models.IntegerField(null=True), + ), + migrations.AddField( + model_name='question', + name='date_approved', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='question', + name='spam', + field=models.BooleanField(default=False), + ), + ] diff --git a/website/migrations/__init__.py b/website/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/website/models.py b/website/models.py index 799519f..44a8093 100755 --- a/website/models.py +++ b/website/models.py @@ -117,7 +117,7 @@ class SpamLog(models.Model): ("APPROVED", "Approved"), ] - #question_id = models.IntegerField() + question_id = models.IntegerField() user_id = models.IntegerField(null=True, blank=True) category = models.CharField(max_length=200, blank=True) title = models.CharField(max_length=200, blank=True) From b22dfffecc9fee2d25ee5f7272728ad6847abe53 Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi Date: Thu, 18 Sep 2025 19:43:52 +0530 Subject: [PATCH 7/9] Delete question & user button --- website/helpers.py | 2 ++ website/views.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/website/helpers.py b/website/helpers.py index a3e410c..ecc6064 100755 --- a/website/helpers.py +++ b/website/helpers.py @@ -213,6 +213,8 @@ def handle_spam(question, user, delete_on_high=True, save_question_metadata_befo spam_logger.info(f"MARK_INACTIVE: Question {question.id} by user {user.id} score={spam_score}") question.status = 0 question.save(update_fields=["status"]) + user.is_active = 0 + user.save(update_fields=["is_active"]) return 'AUTO_DELETE' else: # hide instead of delete diff --git a/website/views.py b/website/views.py index bff1bef..e6a36ca 100755 --- a/website/views.py +++ b/website/views.py @@ -17,7 +17,7 @@ from website.templatetags.permission_tags import can_edit, can_hide_delete from spoken_auth.models import FossCategory from .sortable import SortableHeader, get_sorted_list, get_field_index - +from forums.views import user_logout User = get_user_model() categories = [] @@ -335,7 +335,8 @@ def new_question(request): action = handle_spam(question, request.user) if action == "AUTO_DELETE": - messages.error(request, " Your question was removed because it looks like spam.") + messages.error(request, " Your question is being marked as spam and your account has been deactivated.") + user_logout(request) return HttpResponseRedirect('/') elif action == "FLAGGED": From 23c42b6acdec3c16a9fbc4ee5fd2083cfe53d5d1 Mon Sep 17 00:00:00 2001 From: Bhavishya Chaturvedi Date: Sat, 4 Oct 2025 01:40:17 +0530 Subject: [PATCH 8/9] Spam tab --- static/website/templates/index.html | 42 ++++++++++++++++++++++++++++- website/views.py | 15 +++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/static/website/templates/index.html b/static/website/templates/index.html index 71554a1..89c7b0b 100755 --- a/static/website/templates/index.html +++ b/static/website/templates/index.html @@ -93,6 +93,7 @@

Answers

@@ -279,7 +280,46 @@

Answers

- +
+ + + + + + + + + + + + + + + {% for question in spam_questions %} + + + + + + + + + + + {% empty %} + + {% endfor %} + +
FOSSTutorialMinSecQuestionDateUserActions
{{ question.category|truncatechars:12 }}{{ question.tutorial|truncatechars:12 }}{{ question.minute_range }}{{ question.second_range }} + + {{ question.title|truncatechars:40 }} + + {{ question.date_created|date:"d-m-y" }}{{ question.user|truncatechars:10 }} + + Approve + Reject +
No spam questions pending approval.
+
{% endblock %} diff --git a/website/views.py b/website/views.py index e6a36ca..a07635d 100755 --- a/website/views.py +++ b/website/views.py @@ -8,6 +8,7 @@ from django.core.mail import EmailMultiAlternatives from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib import messages from website.models import Question, Answer, Notification, AnswerComment from spoken_auth.models import TutorialDetails, TutorialResources @@ -18,6 +19,7 @@ from spoken_auth.models import FossCategory from .sortable import SortableHeader, get_sorted_list, get_field_index from forums.views import user_logout +from website.permissions import is_administrator User = get_user_model() categories = [] @@ -37,7 +39,14 @@ def home(request): slider_questions = Question.objects.filter( date_created=Subquery(subquery), status=1 ).order_by('category') - + + # Add spam questions only for admin users + spam_questions = [] + is_admin = False + if request.user.is_authenticated and is_administrator(request.user): + spam_questions = Question.objects.filter(status=2).order_by('-date_created') # status=2 for spam + is_admin = True + # Mapping of foss name as in spk db & its corresponding category name in forums db category_fosses = {val.replace(" ", "-") : val for val in categories} @@ -62,7 +71,9 @@ def home(request): context = { 'questions': questions, 'active_questions':active_questions, - 'category_question_map': category_question_map + 'category_question_map': category_question_map, + 'spam_questions': spam_questions, + 'is_admin': is_admin, } return render(request, "website/templates/index.html", context) From a04328c1bb2b75cef398a5007892f795ad96befa Mon Sep 17 00:00:00 2001 From: Naman Sharma Date: Wed, 24 Dec 2025 08:22:33 +0000 Subject: [PATCH 9/9] Add spam approval system for admin review --- static/website/templates/index.html | 44 +++++++++++++++++++++++--- website/helpers.py | 18 ++++++++--- website/urls.py | 2 ++ website/views.py | 49 ++++++++++++++++++++++++++++- 4 files changed, 104 insertions(+), 9 deletions(-) diff --git a/static/website/templates/index.html b/static/website/templates/index.html index 89c7b0b..c6e1ae0 100755 --- a/static/website/templates/index.html +++ b/static/website/templates/index.html @@ -115,6 +115,7 @@

Answers

{% for question in questions %} + {% if 'test-' not in question.category %} @@ -178,6 +179,7 @@

Answers

+ {% endif %} {% endfor %} @@ -296,7 +298,7 @@

Answers

{% for question in spam_questions %} - + {{ question.category|truncatechars:12 }} {{ question.tutorial|truncatechars:12 }} {{ question.minute_range }} @@ -309,9 +311,8 @@

Answers

{{ question.date_created|date:"d-m-y" }} {{ question.user|truncatechars:10 }} - - Approve - Reject + + {% empty %} @@ -364,6 +365,41 @@

Answers

} e.preventDefault(); }); + + // Spam approval handlers + $('.spam-approve').click(function() { + var qid = $(this).data('qid'); + var row = $('#spam-row-' + qid); + + $.post('/ajax-spam-approve/', { + question_id: qid, + csrfmiddlewaretoken: $('[name=csrfmiddlewaretoken]').val() + }, function(response) { + if(response.success) { + row.fadeOut(function() { $(this).remove(); }); + alert('Question approved!'); + } else { + alert('Error approving question: ' + (response.error || 'Unknown error')); + } + }, 'json'); + }); + + $('.spam-reject').click(function() { + var qid = $(this).data('qid'); + var row = $('#spam-row-' + qid); + + $.post('/ajax-spam-reject/', { + question_id: qid, + csrfmiddlewaretoken: $('[name=csrfmiddlewaretoken]').val() + }, function(response) { + if(response.success) { + row.fadeOut(function() { $(this).remove(); }); + alert('Question rejected as spam!'); + } else { + alert('Error rejecting question: ' + (response.error || 'Unknown error')); + } + }, 'json'); + }); }); $('span').tooltip(); diff --git a/website/helpers.py b/website/helpers.py index ecc6064..b9839dc 100755 --- a/website/helpers.py +++ b/website/helpers.py @@ -4,6 +4,7 @@ from datetime import datetime from typing import Dict, List, Tuple, Optional from website.models import Question, User +import nltk from nltk.corpus import stopwords from nltk.tokenize import word_tokenize from website.templatetags.permission_tags import can_edit, can_hide_delete @@ -14,7 +15,15 @@ import re from .models import SpamRule, SpamLog # assuming app is `forum` -sw = stopwords.words('english') +def _load_stopwords(): + try: + return stopwords.words('english') + except LookupError: + nltk.download('stopwords', quiet=True) + return stopwords.words('english') + + +sw = _load_stopwords() # Configure logging for spam detection import logging @@ -209,10 +218,11 @@ def handle_spam(question, user, delete_on_high=True, save_question_metadata_befo SpamLog.objects.create(**log_payload) if delete_on_high: - # delete after logging + # mark as spam (status=2) after logging spam_logger.info(f"MARK_INACTIVE: Question {question.id} by user {user.id} score={spam_score}") - question.status = 0 - question.save(update_fields=["status"]) + question.status = 2 + question.spam = True + question.save(update_fields=["status", "spam"]) user.is_active = 0 user.save(update_fields=["is_active"]) return 'AUTO_DELETE' diff --git a/website/urls.py b/website/urls.py index 08decec..3b7533b 100755 --- a/website/urls.py +++ b/website/urls.py @@ -38,4 +38,6 @@ url(r'^ajax-time-search/$', views.ajax_time_search, name='ajax_time_search'), url(r'^ajax-delete-question/$', views.ajax_delete_question, name='ajax_delete_question'), url(r'^ajax-hide-question/$', views.ajax_hide_question, name='ajax_hide_question'), + url(r'^ajax-spam-approve/$', views.ajax_spam_approve, name='ajax_spam_approve'), + url(r'^ajax-spam-reject/$', views.ajax_spam_reject, name='ajax_spam_reject'), ] diff --git a/website/views.py b/website/views.py index a07635d..b0e89a2 100755 --- a/website/views.py +++ b/website/views.py @@ -44,7 +44,10 @@ def home(request): spam_questions = [] is_admin = False if request.user.is_authenticated and is_administrator(request.user): - spam_questions = Question.objects.filter(status=2).order_by('-date_created') # status=2 for spam + # Show both: status=2 (auto-detected spam) and approval_required=True (flagged for review) + spam_questions = Question.objects.filter( + Q(status=2) | Q(approval_required=True) + ).order_by('-date_created') is_admin = True # Mapping of foss name as in spk db & its corresponding category name in forums db @@ -749,3 +752,47 @@ def unanswered_notification(request): if total_count: forums_mail(to, subject, message) return HttpResponse(message) + + +@login_required +def ajax_spam_approve(request): + """Admin approves a spam-flagged question""" + if request.method == "POST" and is_administrator(request.user): + question_id = request.POST.get('question_id') + try: + question = get_object_or_404(Question, pk=question_id) + question.spam = False + question.approval_required = False + question.status = 1 + question.save(update_fields=['spam', 'approval_required', 'status']) + + from website.models import SpamLog + SpamLog.objects.filter(question_id=question_id).update(action='APPROVED') + + return HttpResponse(json.dumps({'success': True}), content_type='application/json') + except Exception: + pass + + return HttpResponse(json.dumps({'success': False}), content_type='application/json') + + +@login_required +def ajax_spam_reject(request): + """Admin rejects a spam-flagged question""" + if request.method == "POST" and is_administrator(request.user): + question_id = request.POST.get('question_id') + try: + question = get_object_or_404(Question, pk=question_id) + question.spam = True + question.approval_required = False + question.status = 2 + question.save(update_fields=['spam', 'approval_required', 'status']) + + from website.models import SpamLog + SpamLog.objects.filter(question_id=question_id).update(action='AUTO_DELETE') + + return HttpResponse(json.dumps({'success': True}), content_type='application/json') + except Exception: + pass + + return HttpResponse(json.dumps({'success': False}), content_type='application/json')