From c24f75b56753d08f411f0878a8a864215b55c106 Mon Sep 17 00:00:00 2001 From: Prince Kumar Date: Wed, 19 Aug 2026 16:33:22 +0530 Subject: [PATCH 1/4] Create migration 0012_merge_20260819_1633 for merging previous migrations --- home/migrations/0012_merge_20260819_1633.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 home/migrations/0012_merge_20260819_1633.py diff --git a/home/migrations/0012_merge_20260819_1633.py b/home/migrations/0012_merge_20260819_1633.py new file mode 100644 index 0000000..1a3a628 --- /dev/null +++ b/home/migrations/0012_merge_20260819_1633.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.14 on 2026-08-19 11:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0010_alter_allocation_first_pref_and_more'), + ('home', '0011_alter_globalconstants_short_rebate_period_limit_and_more'), + ] + + operations = [ + ] From 6c15ff39c6b3f87d4f23450afb551ecb264fffc6 Mon Sep 17 00:00:00 2001 From: Prince Kumar Date: Sat, 22 Aug 2026 00:26:43 +0530 Subject: [PATCH 2/4] Increase max_length of hostel field to 5 in Student model and update corresponding form input --- home/migrations/0013_alter_student_hostel.py | 25 ++++++++++++++++++++ home/models/students.py | 2 +- templates/admin/home/student/import_csv.html | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 home/migrations/0013_alter_student_hostel.py diff --git a/home/migrations/0013_alter_student_hostel.py b/home/migrations/0013_alter_student_hostel.py new file mode 100644 index 0000000..29a9abb --- /dev/null +++ b/home/migrations/0013_alter_student_hostel.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.14 on 2026-08-22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("home", "0012_merge_20260819_1633"), + ] + + operations = [ + migrations.AlterField( + model_name="student", + name="hostel", + field=models.CharField( + blank=True, + default="", + help_text="This contains the hostel of the Student", + max_length=5, + null=True, + verbose_name="Hostel of Student", + ), + ), + ] \ No newline at end of file diff --git a/home/models/students.py b/home/models/students.py index 28008d5..b980148 100644 --- a/home/models/students.py +++ b/home/models/students.py @@ -51,7 +51,7 @@ class Student(models.Model): ) hostel = models.CharField( _("Hostel of Student"), - max_length=3, + max_length=5, help_text="This contains the hostel of the Student", null=True, default="", diff --git a/templates/admin/home/student/import_csv.html b/templates/admin/home/student/import_csv.html index 21579ba..ef0155a 100644 --- a/templates/admin/home/student/import_csv.html +++ b/templates/admin/home/student/import_csv.html @@ -9,7 +9,7 @@

Import students from CSV

- +
From 5e44ec58c78517299e13e6ddc7f5be3263380968 Mon Sep 17 00:00:00 2001 From: Prince Kumar Date: Sat, 22 Aug 2026 01:29:54 +0530 Subject: [PATCH 3/4] Refactor CSV import logic in admin panel to improve performance and error handling --- .dockerignore | 3 ++ home/admin.py | 62 +++++++++++++++++++++------ home/tests.py | 103 +++++++++++++++++++++++++++++++++++++++++++++ nginx/default.conf | 6 +++ 4 files changed, 161 insertions(+), 13 deletions(-) diff --git a/.dockerignore b/.dockerignore index 57eae8c..dbdf8bd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,6 @@ __pycache__ migrations db.sqlite3 +media +venv +.venv diff --git a/home/admin.py b/home/admin.py index e002753..368138a 100644 --- a/home/admin.py +++ b/home/admin.py @@ -5,6 +5,7 @@ """ import csv +import io from datetime import timedelta from django.contrib import admin @@ -14,6 +15,7 @@ from django.http import HttpRequest, HttpResponse from django.shortcuts import render from django.urls import path +from django.db.models.functions import Lower from import_export.admin import ImportExportMixin, ImportExportModelAdmin from home.models import ( @@ -342,15 +344,19 @@ def import_csv(self, request): if not failures: try: - decoded_file = uploaded_file.read().decode("utf-8-sig") - sample = decoded_file[:4096] + text_stream = io.TextIOWrapper( + uploaded_file.file, encoding="utf-8-sig", newline="" + ) + sample = text_stream.read(4096) + text_stream.seek(0) try: dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") except csv.Error: dialect = csv.excel - rows = list(csv.reader(decoded_file.splitlines(), dialect)) - if not rows: + csv_reader = csv.reader(text_stream, dialect) + header = next(csv_reader, None) + if not header: raise ValueError("The uploaded file is empty.") def normalize(value): @@ -371,7 +377,7 @@ def normalize(value): "email", }, } - normalized_header = [normalize(value) for value in rows[0]] + normalized_header = [normalize(value) for value in header] column_indexes = {} for field, aliases in header_aliases.items(): matching_indexes = [ @@ -385,13 +391,40 @@ def normalize(value): ) column_indexes[field] = matching_indexes[0] - imported_emails = set() - imported_roll_numbers = set() - for row_number, row in enumerate(rows[1:], start=2): + max_column_index = max(column_indexes.values()) + pending_rows = [] + uploaded_emails = set() + uploaded_roll_numbers = set() + + for row_number, row in enumerate(csv_reader, start=2): if not any(value.strip() for value in row): continue + pending_rows.append((row_number, row)) + if len(row) > column_indexes["email"]: + email_value = row[column_indexes["email"]].strip().lower() + if email_value: + uploaded_emails.add(email_value) + if len(row) > column_indexes["roll_no"]: + roll_value = row[column_indexes["roll_no"]].strip() + if roll_value: + uploaded_roll_numbers.add(roll_value) + + existing_emails = set( + Student.objects.annotate(email_lower=Lower("email")) + .filter(email_lower__in=list(uploaded_emails)) + .values_list("email_lower", flat=True) + ) + existing_roll_numbers = set( + Student.objects.filter(roll_no__in=list(uploaded_roll_numbers)) + .values_list("roll_no", flat=True) + ) + + imported_emails = set() + imported_roll_numbers = set() + students_to_create = [] + for row_number, row in pending_rows: try: - if len(row) <= max(column_indexes.values()): + if len(row) <= max_column_index: raise ValueError( "The row does not contain all required columns." ) @@ -413,9 +446,9 @@ def normalize(value): raise ValueError("Duplicate email in the uploaded file.") if values["roll_no"] in imported_roll_numbers: raise ValueError("Duplicate roll number in the uploaded file.") - if Student.objects.filter(email__iexact=values["email"]).exists(): + if values["email"].lower() in existing_emails: raise ValueError("A student with this email already exists.") - if Student.objects.filter(roll_no=values["roll_no"]).exists(): + if values["roll_no"] in existing_roll_numbers: raise ValueError("A student with this roll number already exists.") student = Student( @@ -428,8 +461,7 @@ def normalize(value): email=values["email"], ) student.full_clean() - student.save() - imported_count += 1 + students_to_create.append(student) imported_emails.add(values["email"].lower()) imported_roll_numbers.add(values["roll_no"]) except (ValidationError, ValueError, IndexError) as error: @@ -438,6 +470,10 @@ def normalize(value): else: error_message = str(error) failures.append({"row": row_number, "error": error_message}) + + if students_to_create: + Student.objects.bulk_create(students_to_create, batch_size=500) + imported_count = len(students_to_create) except (UnicodeDecodeError, ValueError, csv.Error) as error: failures.append({"row": "-", "error": str(error)}) diff --git a/home/tests.py b/home/tests.py index 51014e9..c86271e 100644 --- a/home/tests.py +++ b/home/tests.py @@ -37,3 +37,106 @@ def test_imports_valid_rows_and_reports_invalid_rows(self): self.assertEqual(student.roll_no, "260003011") self.assertContains(response, "Row") self.assertContains(response, "Missing value for: roll no.") + + def test_rejects_hostel_name_longer_than_five_characters(self): + csv_content = ( + "Room No,Roll No,Name,Course,Department,Institute Email ID\n" + "201,260003012,JANE DOE,B.Tech,ME,me260003012@iiti.ac.in\n" + ) + upload = SimpleUploadedFile( + "students.csv", csv_content.encode("utf-8"), content_type="text/csv" + ) + + response = self.client.post( + reverse("admin:home_student_import_csv"), + {"hostel": "ABCDE1", "csv_file": upload}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(Student.objects.count(), 0) + self.assertContains(response, "Hostel name is too long") + + def test_imports_tab_separated_file_with_bom(self): + tsv_content = ( + "\ufeffRoom No\tRoll No\tName\tCourse\tDepartment\tInstitute Email ID\n" + "203\t260003013\tTAB STUDENT\tB.Tech\tME\tme260003013@iiti.ac.in\n" + ) + upload = SimpleUploadedFile( + "students.csv", tsv_content.encode("utf-8"), content_type="text/csv" + ) + + response = self.client.post( + reverse("admin:home_student_import_csv"), + {"hostel": "AG", "csv_file": upload}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(Student.objects.count(), 1) + self.assertTrue( + Student.objects.filter(email="me260003013@iiti.ac.in").exists() + ) + + def test_reports_missing_required_header(self): + csv_content = ( + "Room No,Name,Course,Department,Institute Email ID\n" + "201,NO ROLL,B.Tech,ME,no-roll@example.com\n" + ) + upload = SimpleUploadedFile( + "students.csv", csv_content.encode("utf-8"), content_type="text/csv" + ) + + response = self.client.post( + reverse("admin:home_student_import_csv"), + {"hostel": "AG", "csv_file": upload}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(Student.objects.count(), 0) + self.assertContains(response, "Missing required column: roll no.") + + def test_rejects_duplicates_within_file(self): + csv_content = ( + "Room No,Roll No,Name,Course,Department,Institute Email ID\n" + "201,260003014,ONE,B.Tech,ME,dup@example.com\n" + "202,260003015,TWO,B.Tech,ME,dup@example.com\n" + ) + upload = SimpleUploadedFile( + "students.csv", csv_content.encode("utf-8"), content_type="text/csv" + ) + + response = self.client.post( + reverse("admin:home_student_import_csv"), + {"hostel": "AG", "csv_file": upload}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(Student.objects.count(), 1) + self.assertContains(response, "Duplicate email in the uploaded file.") + + def test_rejects_existing_email_case_insensitively(self): + Student.objects.create( + hostel="AG", + room_no="200", + roll_no="260003099", + name="Existing", + degree="B.Tech", + department="ME", + email="Case@Test.com", + ) + + csv_content = ( + "Room No,Roll No,Name,Course,Department,Institute Email ID\n" + "201,260003016,NEW,B.Tech,ME,case@test.com\n" + ) + upload = SimpleUploadedFile( + "students.csv", csv_content.encode("utf-8"), content_type="text/csv" + ) + + response = self.client.post( + reverse("admin:home_student_import_csv"), + {"hostel": "AG", "csv_file": upload}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(Student.objects.count(), 1) + self.assertContains(response, "A student with this email already exists.") diff --git a/nginx/default.conf b/nginx/default.conf index 7001f5c..52495e3 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -11,8 +11,14 @@ server{ ssl_certificate_key /etc/letsencrypt/live/diningfee.iiti.ac.in/privkey.pem; client_max_body_size 20M; + client_body_timeout 600s; + send_timeout 600s; + proxy_set_header HOST $host; location /{ proxy_pass http://django; + proxy_connect_timeout 60s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; } } From 66cf6dec6e2871ce3f50acfcf47d3fd0fab6da39 Mon Sep 17 00:00:00 2001 From: Prince Kumar Date: Mon, 24 Aug 2026 23:29:22 +0530 Subject: [PATCH 4/4] Add admin action to create missing user accounts and update tests --- home/admin.py | 95 +++++++++++++++++++++++++++++++++++++++++++++++++-- home/tests.py | 27 +++++++++++++++ 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/home/admin.py b/home/admin.py index 368138a..9ff859d 100644 --- a/home/admin.py +++ b/home/admin.py @@ -9,9 +9,13 @@ from datetime import timedelta from django.contrib import admin +from django.contrib.auth import get_user_model from django.contrib import messages from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.db.models import Q +from django.db.models.functions import Lower from django.http import HttpRequest, HttpResponse from django.shortcuts import render from django.urls import path @@ -60,6 +64,8 @@ from .utils.month import fill_periods, map_periods_to_long_rebate from .utils.rebate_bills_saver import fix_all_bills, save_long_bill +User = get_user_model() + # Customising the heading and title of the admin page admin.site.site_header = "Dining Website Admin Page" admin.site.site_title = "Admin Page" @@ -313,7 +319,59 @@ class about_Admin(ImportExportMixin, admin.ModelAdmin): }, ), ) - actions = ["export_as_csv", "generate_table", "disable_allocation"] + actions = [ + "export_as_csv", + "generate_table", + "disable_allocation", + "create_user_accounts", + ] + + @admin.action(description="Create missing user accounts") + def create_user_accounts(self, request, queryset): + created_count = 0 + existing_count = 0 + failures = [] + + for student in queryset: + email = student.email.strip() + if not email: + failures.append(f"Student #{student.pk} has no email address.") + continue + + if User.objects.filter( + Q(email__iexact=email) | Q(username__iexact=email) + ).exists(): + existing_count += 1 + continue + + try: + user = User(username=email, email=email) + user.set_unusable_password() + user.save() + created_count += 1 + except IntegrityError: + failures.append( + f"Could not create an account for {email}; the username may already exist." + ) + + if created_count: + self.message_user( + request, + f"Created {created_count} user account(s).", + messages.SUCCESS, + ) + if existing_count: + self.message_user( + request, + f"Skipped {existing_count} student(s) with existing accounts.", + messages.INFO, + ) + if failures: + self.message_user( + request, + "Could not create accounts: " + " ".join(failures), + messages.ERROR, + ) def get_urls(self): urls = super().get_urls() @@ -418,6 +476,16 @@ def normalize(value): Student.objects.filter(roll_no__in=list(uploaded_roll_numbers)) .values_list("roll_no", flat=True) ) + existing_user_emails = set( + User.objects.annotate(email_lower=Lower("email")) + .filter(email_lower__in=list(uploaded_emails)) + .values_list("email_lower", flat=True) + ) + existing_usernames = set( + User.objects.annotate(username_lower=Lower("username")) + .filter(username_lower__in=list(uploaded_emails)) + .values_list("username_lower", flat=True) + ) imported_emails = set() imported_roll_numbers = set() @@ -450,6 +518,11 @@ def normalize(value): raise ValueError("A student with this email already exists.") if values["roll_no"] in existing_roll_numbers: raise ValueError("A student with this roll number already exists.") + if ( + values["email"].lower() in existing_usernames + and values["email"].lower() not in existing_user_emails + ): + raise ValueError("A user with this username already exists.") student = Student( hostel=hostel, @@ -472,7 +545,25 @@ def normalize(value): failures.append({"row": row_number, "error": error_message}) if students_to_create: - Student.objects.bulk_create(students_to_create, batch_size=500) + with transaction.atomic(): + Student.objects.bulk_create(students_to_create, batch_size=500) + users_to_create = [] + for student in students_to_create: + if student.email.lower() not in existing_user_emails: + user = User( + username=student.email, + email=student.email, + ) + user.set_unusable_password() + users_to_create.append(user) + if users_to_create: + User.objects.bulk_create(users_to_create, batch_size=500) + + semester = Semester.objects.filter().last() + StudentBills.objects.bulk_create( + [StudentBills(email=student, semester=semester) for student in students_to_create], + batch_size=500, + ) imported_count = len(students_to_create) except (UnicodeDecodeError, ValueError, csv.Error) as error: failures.append({"row": "-", "error": str(error)}) diff --git a/home/tests.py b/home/tests.py index c86271e..d8cfc0a 100644 --- a/home/tests.py +++ b/home/tests.py @@ -35,6 +35,9 @@ def test_imports_valid_rows_and_reports_invalid_rows(self): student = Student.objects.get() self.assertEqual(student.hostel, "AG") self.assertEqual(student.roll_no, "260003011") + imported_user = get_user_model().objects.get(email=student.email) + self.assertEqual(imported_user.username, student.email) + self.assertTrue(imported_user.has_usable_password() is False) self.assertContains(response, "Row") self.assertContains(response, "Missing value for: roll no.") @@ -140,3 +143,27 @@ def test_rejects_existing_email_case_insensitively(self): self.assertEqual(response.status_code, 200) self.assertEqual(Student.objects.count(), 1) self.assertContains(response, "A student with this email already exists.") + + def test_admin_action_creates_missing_user_account(self): + student = Student.objects.create( + hostel="AG", + room_no="204", + roll_no="260003017", + name="Missing Account", + degree="B.Tech", + department="ME", + email="missing.account@iiti.ac.in", + ) + + response = self.client.post( + reverse("admin:home_student_changelist"), + { + "action": "create_user_accounts", + "_selected_action": [student.pk], + }, + ) + + self.assertEqual(response.status_code, 302) + user = get_user_model().objects.get(email=student.email) + self.assertEqual(user.username, student.email) + self.assertFalse(user.has_usable_password())