Skip to content

Commit 5c5221e

Browse files
authored
feat: allow students to be uploaded in bulk (#18)
1 parent af7e3c8 commit 5c5221e

File tree

4 files changed

+150
-10
lines changed

4 files changed

+150
-10
lines changed

Diff for: apps/common/management/commands/load_students.py

+7-7
Original file line numberDiff line numberDiff line change
@@ -50,16 +50,16 @@ def handle(self, *args, **options):
5050

5151
for entry in data:
5252
try:
53-
with transaction.atomic():
54-
admission_date = self.parse_date(entry["admission_date"])
55-
graduation_date = self.parse_date(entry["graduation_date"])
53+
admission_date = self.parse_date(entry["admission_date"])
54+
graduation_date = self.parse_date(entry["graduation_date"])
5655

57-
first_name = entry["first_name"]
58-
middle_name = entry["middle_name"]
59-
last_name = entry["last_name"]
56+
first_name = entry["first_name"]
57+
middle_name = entry["middle_name"]
58+
last_name = entry["last_name"]
6059

61-
password = generate_password(last_name)
60+
password = generate_password(last_name)
6261

62+
with transaction.atomic():
6363
user = User.objects.create_user(
6464
first_name=first_name,
6565
middle_name=middle_name,

Diff for: apps/entities/urls.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django.urls import include, path
22
from rest_framework.routers import DefaultRouter
33

4-
from .views.admin_views import EntityViewSet
4+
from .views.admin_views import EntityViewSet, UploadStudents
55
from .views.faculty_view import FacultyViewSet
66

77
router = DefaultRouter()
@@ -11,5 +11,6 @@
1111
app_name = "entities"
1212

1313
urlpatterns = [
14+
path("upload_students/", UploadStudents.as_view(), name="upload_students"),
1415
path("", include(router.urls)),
1516
]

Diff for: apps/entities/views/admin_views.py

+139-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
1+
from datetime import datetime
2+
3+
import pandas as pd
14
from django.contrib.auth import get_user_model
5+
from django.db import transaction
26
from django_filters import rest_framework as django_filters
37
from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema
4-
from rest_framework import filters, viewsets
8+
from rest_framework import filters, status, viewsets
59
from rest_framework.permissions import IsAuthenticated
10+
from rest_framework.response import Response
11+
from rest_framework.views import APIView
612

13+
from apps.entities.models.faculty_models import Department, Faculty
14+
from apps.entities.models.student_models import Student
715
from apps.entities.serializers.entity_serializers import EntitySerializer
16+
from apps.entities.utils import generate_password
17+
from apps.rbac.models.role_models import Role, UserRole
818
from apps.rbac.permissions import IsUserSuperAdmin, IsUserSuperAdminOrFacultyAdmin
919

1020
User = get_user_model()
@@ -38,7 +48,11 @@ class EntityViewSet(viewsets.ModelViewSet):
3848

3949
permission_classes = (IsAuthenticated,)
4050
serializer_class = EntitySerializer
41-
filter_backends = (django_filters.DjangoFilterBackend, filters.SearchFilter, filters.OrderingFilter)
51+
filter_backends = (
52+
django_filters.DjangoFilterBackend,
53+
filters.SearchFilter,
54+
filters.OrderingFilter,
55+
)
4256
filterset_fields = (
4357
"student__faculty",
4458
"student__department",
@@ -106,3 +120,126 @@ def get_serializer_context(self):
106120
entity_type = self.kwargs.get("entity_type")
107121
context.update({"entity_type": entity_type})
108122
return context
123+
124+
125+
class UploadStudents(APIView):
126+
def parse_date(self, date_str):
127+
try:
128+
return datetime.strptime(date_str, "%d-%m-%Y").strftime("%Y-%m-%d")
129+
except ValueError:
130+
return None
131+
132+
def post(self, request, *args, **kwargs):
133+
file_obj = request.FILES.get("file")
134+
135+
if not file_obj:
136+
return Response({"error": "No file provided"}, status=status.HTTP_400_BAD_REQUEST)
137+
138+
file_extension = file_obj.name.split(".")[-1].lower()
139+
if file_extension not in ["xlsx", "xls", "csv"]:
140+
return Response(
141+
{"error": "Unsupported file type. Only Excel and CSV files are accepted."},
142+
status=status.HTTP_400_BAD_REQUEST,
143+
)
144+
145+
try:
146+
if file_extension in ["xlsx", "xls"]:
147+
df = pd.read_excel(file_obj)
148+
elif file_extension == "csv":
149+
df = pd.read_csv(file_obj)
150+
151+
required_columns = {
152+
"username",
153+
"first_name",
154+
"middle_name",
155+
"last_name",
156+
"gender",
157+
"faculty",
158+
"department",
159+
"year_in_school",
160+
"admission_date",
161+
"graduation_date",
162+
}
163+
if not required_columns.issubset(df.columns):
164+
print(df.columns)
165+
return Response(
166+
{"error": "All fields are required in the file."},
167+
status=status.HTTP_400_BAD_REQUEST,
168+
)
169+
170+
errors = []
171+
success_count = 0
172+
173+
# TODO This should be a background task (Use Celery)
174+
for index, entry in df.iterrows():
175+
error = {}
176+
if not all([entry[col] for col in required_columns]):
177+
error["row"] = index + 1
178+
error["message"] = "Missing required fields"
179+
errors.append(error)
180+
continue
181+
182+
if User.objects.filter(username=entry["username"]).exists():
183+
error["row"] = index + 1
184+
error["message"] = "Username already exists"
185+
errors.append(error)
186+
continue
187+
188+
admission_date = self.parse_date(entry["admission_date"])
189+
graduation_date = self.parse_date(entry["graduation_date"])
190+
191+
if not admission_date or not graduation_date:
192+
error["row"] = index + 1
193+
error["message"] = "Invalid date format"
194+
errors.append(error)
195+
continue
196+
197+
first_name = entry["first_name"]
198+
middle_name = entry["middle_name"]
199+
last_name = entry["last_name"]
200+
201+
password = generate_password(last_name)
202+
203+
with transaction.atomic():
204+
try:
205+
user = User.objects.create_user(
206+
first_name=first_name,
207+
middle_name=middle_name,
208+
last_name=last_name,
209+
gender=entry["gender"],
210+
username=entry["username"],
211+
password=password,
212+
is_first_time_login=True,
213+
)
214+
faculty = Faculty.objects.get(name=entry["faculty"])
215+
department = Department.objects.get(name=entry["department"])
216+
Student.objects.create(
217+
user=user,
218+
faculty=faculty,
219+
department=department,
220+
year_in_school=entry["year_in_school"],
221+
admission_date=admission_date,
222+
graduation_date=graduation_date,
223+
)
224+
role = Role.objects.get(name="Student")
225+
UserRole.objects.create(user=user, role=role)
226+
success_count += 1
227+
except Exception:
228+
error["row"] = index + 1
229+
error["message"] = "Something went wrong."
230+
errors.append(error)
231+
232+
# TODO Allow users to download error log
233+
234+
return Response(
235+
{
236+
"message": f"{success_count} students created successfully. {len(errors)} entries failed.",
237+
},
238+
status=status.HTTP_201_CREATED,
239+
)
240+
241+
except Exception:
242+
return Response(
243+
{"error": "Something went wrong. Please try again later."},
244+
status=status.HTTP_400_BAD_REQUEST,
245+
)

Diff for: requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ boto3==1.34.93
1616
pillow==10.3.0
1717
django-phonenumber-field==7.3.0
1818
phonenumberslite==8.13.36
19+
pandas==2.2.2
20+
openpyxl==3.1.5
1921

2022
# Database
2123
# ------------------------------------------------------------------------------

0 commit comments

Comments
 (0)