diff --git a/home/admin.py b/home/admin.py index af14c67..e002753 100644 --- a/home/admin.py +++ b/home/admin.py @@ -8,8 +8,12 @@ from datetime import timedelta from django.contrib import admin +from django.contrib import messages from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME +from django.core.exceptions import ValidationError from django.http import HttpRequest, HttpResponse +from django.shortcuts import render +from django.urls import path from import_export.admin import ImportExportMixin, ImportExportModelAdmin from home.models import ( @@ -282,6 +286,7 @@ class about_Admin(admin.ModelAdmin): @admin.register(Student) class about_Admin(ImportExportMixin, admin.ModelAdmin): + change_list_template = "admin/home/student/change_list.html" resource_class = StudentResource model = Student search_fields = ("name", "roll_no", "hostel", "degree", "department", "email") @@ -308,6 +313,156 @@ class about_Admin(ImportExportMixin, admin.ModelAdmin): ) actions = ["export_as_csv", "generate_table", "disable_allocation"] + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + "import-csv/", + self.admin_site.admin_view(self.import_csv), + name="home_student_import_csv", + ), + ] + return custom_urls + urls + + def import_csv(self, request): + failures = [] + imported_count = 0 + + if request.method == "POST": + hostel = request.POST.get("hostel", "").strip() + uploaded_file = request.FILES.get("csv_file") + + if not hostel: + failures.append({"row": "-", "error": "Hostel name is required."}) + elif len(hostel) > Student._meta.get_field("hostel").max_length: + failures.append({"row": "-", "error": "Hostel name is too long."}) + + if uploaded_file is None: + failures.append({"row": "-", "error": "Please select a CSV file."}) + + if not failures: + try: + decoded_file = uploaded_file.read().decode("utf-8-sig") + sample = decoded_file[:4096] + 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: + raise ValueError("The uploaded file is empty.") + + def normalize(value): + return "".join( + character.lower() for character in value if character.isalnum() + ) + + header_aliases = { + "room_no": {"roomno", "roomnumber"}, + "roll_no": {"rollno", "rollnumber"}, + "name": {"name"}, + "degree": {"course", "degree"}, + "department": {"department", "dept"}, + "email": { + "instituteemailid", + "instituteemail", + "emailid", + "email", + }, + } + normalized_header = [normalize(value) for value in rows[0]] + column_indexes = {} + for field, aliases in header_aliases.items(): + matching_indexes = [ + index + for index, value in enumerate(normalized_header) + if value in aliases + ] + if not matching_indexes: + raise ValueError( + f"Missing required column: {field.replace('_', ' ')}." + ) + column_indexes[field] = matching_indexes[0] + + imported_emails = set() + imported_roll_numbers = set() + for row_number, row in enumerate(rows[1:], start=2): + if not any(value.strip() for value in row): + continue + try: + if len(row) <= max(column_indexes.values()): + raise ValueError( + "The row does not contain all required columns." + ) + + values = { + field: row[index].strip() + for field, index in column_indexes.items() + } + missing_fields = [ + field.replace("_", " ") + for field, value in values.items() + if not value + ] + if missing_fields: + raise ValueError( + "Missing value for: " + ", ".join(missing_fields) + "." + ) + if values["email"].lower() in imported_emails: + 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(): + raise ValueError("A student with this email already exists.") + if Student.objects.filter(roll_no=values["roll_no"]).exists(): + raise ValueError("A student with this roll number already exists.") + + student = Student( + hostel=hostel, + room_no=values["room_no"], + roll_no=values["roll_no"], + name=values["name"], + degree=values["degree"], + department=values["department"], + email=values["email"], + ) + student.full_clean() + student.save() + imported_count += 1 + imported_emails.add(values["email"].lower()) + imported_roll_numbers.add(values["roll_no"]) + except (ValidationError, ValueError, IndexError) as error: + if isinstance(error, ValidationError): + error_message = "; ".join(error.messages) + else: + error_message = str(error) + failures.append({"row": row_number, "error": error_message}) + except (UnicodeDecodeError, ValueError, csv.Error) as error: + failures.append({"row": "-", "error": str(error)}) + + if imported_count: + self.message_user( + request, + f"Successfully imported {imported_count} student(s).", + messages.SUCCESS, + ) + if failures: + self.message_user( + request, + f"{len(failures)} row(s) could not be imported.", + messages.WARNING, + ) + + context = { + **self.admin_site.each_context(request), + "opts": self.model._meta, + "title": "Import students from CSV", + "failures": failures, + "imported_count": imported_count, + } + return render(request, "admin/home/student/import_csv.html", context) + def generate_table(Student, request, queryset): """ Generate action available in the admin page 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/home/tests.py b/home/tests.py index a39b155..51014e9 100644 --- a/home/tests.py +++ b/home/tests.py @@ -1 +1,39 @@ -# Create your tests here. +from django.contrib.auth import get_user_model +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase +from django.urls import reverse + +from home.models import Student + + +class StudentCsvImportTests(TestCase): + def setUp(self): + user = get_user_model().objects.create_superuser( + username="admin", + email="admin@example.com", + password="test-password", + ) + self.client.force_login(user) + + def test_imports_valid_rows_and_reports_invalid_rows(self): + csv_content = ( + "S.No.,Unit No.,Room No,Roll No,Name,Course,Department,Institute Email ID\n" + "1,A,201,260003011,JOHN DOE,B.Tech,ME,me260003011@iiti.ac.in\n" + "2,B,202,,Missing Roll,B.Tech,ME,missing@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) + student = Student.objects.get() + self.assertEqual(student.hostel, "AG") + self.assertEqual(student.roll_no, "260003011") + self.assertContains(response, "Row") + self.assertContains(response, "Missing value for: roll no.") diff --git a/templates/admin/home/student/change_list.html b/templates/admin/home/student/change_list.html new file mode 100644 index 0000000..1bf14d5 --- /dev/null +++ b/templates/admin/home/student/change_list.html @@ -0,0 +1,8 @@ +{% extends "admin/change_list.html" %} + +{% block object-tools-items %} +
Enter the hostel that applies to every row, then upload a CSV or tab-separated file.
+ + + {% if failures %} +| Row | Reason |
|---|---|
| {{ failure.row }} | {{ failure.error }} |