From 984af494b50eefd4811177cac91428b768a557a2 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 01:35:17 +0100 Subject: [PATCH 01/32] Feat: unfinished exams --- Backend/settings.py | 12 ++- Backend/urls.py | 1 + exams/admin.py | 12 ++- exams/migrations/0001_initial.py | 60 +++++++++++++++ exams/models.py | 41 +++++++++- exams/serializers.py | 25 ++++++ exams/urls.py | 12 +++ exams/views.py | 77 ++++++++++++++++++- k8s/backend/backend-deployment.yaml | 3 +- k8s/config/configmaps/postgres-configmap.yaml | 8 ++ k8s/config/namespaces/labs-namespace.yaml | 7 ++ k8s/config/roles/bind-podmanager.yaml | 13 ++++ k8s/config/roles/podmanager-role.yaml | 18 +++++ k8s/config/secrets/postgres-secret.yaml | 7 ++ k8s/config/secrets/pull-secret.yaml | 8 ++ .../serviceaccounts/app-serviceaccount.yaml | 5 ++ labs/views.py | 3 - requirements.txt | 7 ++ setup-k8s.sh | 16 ++-- 19 files changed, 318 insertions(+), 17 deletions(-) create mode 100644 exams/migrations/0001_initial.py create mode 100644 exams/serializers.py create mode 100644 exams/urls.py create mode 100644 k8s/config/configmaps/postgres-configmap.yaml create mode 100644 k8s/config/namespaces/labs-namespace.yaml create mode 100644 k8s/config/roles/bind-podmanager.yaml create mode 100644 k8s/config/roles/podmanager-role.yaml create mode 100644 k8s/config/secrets/postgres-secret.yaml create mode 100644 k8s/config/secrets/pull-secret.yaml create mode 100644 k8s/config/serviceaccounts/app-serviceaccount.yaml diff --git a/Backend/settings.py b/Backend/settings.py index 9d4f5d1..4569eaa 100644 --- a/Backend/settings.py +++ b/Backend/settings.py @@ -51,7 +51,9 @@ 'chat', 'categories', 'certs', + 'exams', 'drf_yasg', + 'corsheaders', 'django_rest_passwordreset', # Swagger stuff for docs (TODO: comment out later) ] @@ -78,6 +80,7 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', + 'corsheaders.middleware.CorsMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -117,7 +120,8 @@ 'NAME': os.environ.get('DB_NAME','cybermaster-backend-db'), 'USER': os.environ.get('DB_USER','dbuser'), 'PASSWORD': os.environ.get('DB_PASSWORD','1234'), - 'HOST':os.environ.get('DB_HOST','cybermaster-postgres.default.svc.cluster.local'), + # 'HOST':os.environ.get('DB_HOST','cybermaster-postgres.default.svc.cluster.local'), + 'HOST':os.environ.get('DB_HOST','127.0.0.1'), 'PORT': os.environ.get('DB_PORT','5432'), } } @@ -178,12 +182,14 @@ DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' GOOGLE_API_KEY = 'AIzaSyB-TzEi633vh6CQy73MRi-_LS4v7mjoYVc' + SIMPLE_JWT = { 'ACCESS_TOKEN_LIFETIME':timedelta(minutes=90) } - - +CORS_ALLOWED_ORIGINS=[ + 'http://127.0.0.1' +] EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_HOST = "smtp.gmail.com" diff --git a/Backend/urls.py b/Backend/urls.py index 9245e80..afe554f 100644 --- a/Backend/urls.py +++ b/Backend/urls.py @@ -32,6 +32,7 @@ path('api/v1/chat/',include('chat.urls')), path('api/v1/certify/',include('certs.urls')), path('api/v1/category/',include('categories.urls')), + path('api/v1/exams/',include('exams.urls')), # Swagger stuff for docs (TODO: comment out in prod) path('swagger/', schema_view.without_ui(cache_timeout=0), name='schema-json'), diff --git a/exams/admin.py b/exams/admin.py index 8c38f3f..41e06a4 100644 --- a/exams/admin.py +++ b/exams/admin.py @@ -1,3 +1,13 @@ from django.contrib import admin +from .models import * -# Register your models here. +class ChoiceInline(admin.TabularInline): + model=MCQChoice + +class QuestionAdmin(admin.ModelAdmin): + inlines=[ChoiceInline] + +admin.site.register(Exam) +admin.site.register(ExamAttempt) +admin.site.register(MCQChoice) +admin.site.register(Question,QuestionAdmin) \ No newline at end of file diff --git a/exams/migrations/0001_initial.py b/exams/migrations/0001_initial.py new file mode 100644 index 0000000..52451e4 --- /dev/null +++ b/exams/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.1.6 on 2025-04-20 16:06 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('courses', '0004_enrollment_cert_ready'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Exam', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('duration', models.DurationField()), + ('passing_score', models.IntegerField(default=50)), + ('course', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='exams', to='courses.course')), + ], + ), + migrations.CreateModel( + name='ExamAttempt', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('score', models.IntegerField(default=0)), + ('started_at', models.DateTimeField()), + ('duration', models.DurationField()), + ('exam', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='runs', to='exams.exam')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exam_runs', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Question', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('prompt', models.CharField(max_length=255)), + ('is_mcq', models.BooleanField(default=True)), + ('correct_answer', models.CharField(max_length=255)), + ('order_index', models.IntegerField()), + ('exam', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='exams.exam')), + ], + ), + migrations.CreateModel( + name='MCQChoice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.CharField(max_length=255)), + ('is_correct', models.BooleanField(default=False)), + ('order_index', models.IntegerField()), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='choices', to='exams.question')), + ], + ), + ] diff --git a/exams/models.py b/exams/models.py index 71a8362..0011b6e 100644 --- a/exams/models.py +++ b/exams/models.py @@ -1,3 +1,42 @@ from django.db import models +from courses.models import Course +from users.models import CustomUser -# Create your models here. +class Exam(models.Model): + title=models.CharField(max_length=255) + course=models.OneToOneField(Course,related_name='exams',on_delete=models.CASCADE) + duration=models.DurationField() + passing_score=models.IntegerField(default=50) + + def __str__(self): + return self.title + +class Question(models.Model): + exam=models.ForeignKey(Exam,related_name="questions",on_delete=models.CASCADE) + prompt=models.CharField(max_length=255) + is_mcq=models.BooleanField(default=True) + correct_answer=models.CharField(max_length=255,blank=True,null=True) + order_index=models.IntegerField() + + def __str__(self): + return self.prompt + +class MCQChoice(models.Model): + question=models.ForeignKey(Question,related_name="choices",on_delete=models.CASCADE) + content=models.CharField(max_length=255) + is_correct=models.BooleanField(default=False) + order_index=models.IntegerField() + + def __str__(self): + return self.content + +class ExamAttempt(models.Model): + exam=models.ForeignKey(Exam,related_name="runs",on_delete=models.CASCADE) + user=models.ForeignKey(CustomUser,related_name="exam_runs",on_delete=models.CASCADE) + score=models.IntegerField(default=0) + started_at=models.DateTimeField(auto_now_add=True) + is_finished=models.BooleanField(default=False) + duration=models.DurationField(blank=True,null=True) + + # def __str__(self): + # return self.title \ No newline at end of file diff --git a/exams/serializers.py b/exams/serializers.py new file mode 100644 index 0000000..ebe5536 --- /dev/null +++ b/exams/serializers.py @@ -0,0 +1,25 @@ +from rest_framework import serializers +from .models import * + +class MCQChoiceSerializer(serializers.ModelSerializer): + class Meta: + model=MCQChoice + fields=['question','content','order_index'] + +class ExamAttemptSerializer(serializers.ModelSerializer): + class Meta: + model=ExamAttempt + fields=['exam','user','score','started_at','duration'] + +class QuestionSerializer(serializers.ModelSerializer): + choices=MCQChoiceSerializer(many=True) + class Meta: + model=Question + fields=['exam','prompt','is_mcq','correct_answer','choices','order_index'] + +class ExamSerializer(serializers.ModelSerializer): + questions=QuestionSerializer(many=True) + class Meta: + model=Exam + fields=['title','course','duration','passing_score','questions'] + diff --git a/exams/urls.py b/exams/urls.py new file mode 100644 index 0000000..2de53b1 --- /dev/null +++ b/exams/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from .views import * + +urlpatterns=[ + path('',ExamList.as_view(),name='list-exams'), + path('',GetExam.as_view(),name='get-exam'), + path('/questions',QuestionList.as_view(),name='get-questions'), + path('/questions/',GetQuestion.as_view(),name='get-question'), + path('/start',StartExam.as_view(),name='start-exam'), + path('/finish',FinishExam.as_view(),name='finish-exam'), + path('/answer',SubmitAnswer.as_view(),name='submit-answer'), +] \ No newline at end of file diff --git a/exams/views.py b/exams/views.py index 91ea44a..b075480 100644 --- a/exams/views.py +++ b/exams/views.py @@ -1,3 +1,76 @@ -from django.shortcuts import render +from datetime import datetime +from django.shortcuts import get_object_or_404 +from rest_framework.permissions import IsAuthenticated +from rest_framework import generics +from rest_framework.views import APIView +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework import status +from .models import * +from .serializers import * -# Create your views here. +class ExamList(generics.ListAPIView): + # permission_classes=[IsAuthenticated] + queryset=Exam.objects.all() + serializer_class=ExamSerializer + +class GetExam(generics.RetrieveAPIView): + # permission_classes=[IsAuthenticated] + queryset=Exam.objects.all() + serializer_class=ExamSerializer + +class QuestionList(generics.ListAPIView): + # permission_classes=[IsAuthenticated] + serializer_class=QuestionSerializer + + def get_queryset(self): + return get_object_or_404(Exam,pk=self.kwargs.get("pk")).questions.all() + +class GetQuestion(generics.RetrieveAPIView): + # permission_classes=[IsAuthenticated] + serializer_class=QuestionSerializer + lookup_field="order_index" + + def get_queryset(self): + exam=get_object_or_404(Exam,pk=self.kwargs.get("pk")) + return exam.questions.all() + +class StartExam(APIView): + def post(self,request,pk): + exam=get_object_or_404(Exam,pk=pk) + exam_attempt=ExamAttempt.objects.filter(user=self.request.user,exam=exam).first() + + if exam_attempt: + if not exam_attempt.is_finished: + raise ValidationError({'details':'an unfinished exam attempt already exists'}) + + serializer=ExamAttemptSerializer( + exam_attempt, + data=request.data, + partial=True + ) + + if serializer.is_valid(): + serializer.save(is_finished=False,duration=0,started_at=datetime.datetime.now(datetime.timezone.uct)) + return Response(serializer.data,status=status.HTTP_200_OK) + + + serializer=ExamAttemptSerializer( + data=request.data + ) + + if serializer.is_valid(): + serializer.save( + user=request.user, + exam=exam + ) + return Response(serializer.data,status=status.HTTP_201_CREATED) + + return Response(serializer.errors,status=status.HTTP_400_BAD_REQUEST) + + +class SubmitAnswer(generics.CreateAPIView): + pass + +class FinishExam(generics.UpdateAPIView): + pass \ No newline at end of file diff --git a/k8s/backend/backend-deployment.yaml b/k8s/backend/backend-deployment.yaml index ff14478..55cc196 100644 --- a/k8s/backend/backend-deployment.yaml +++ b/k8s/backend/backend-deployment.yaml @@ -17,10 +17,11 @@ spec: labels: app: cybermaster-backend spec: + serviceAccountName: django-app containers: - image: 1nitramfs/cybermaster-backend:latest name: cybermaster-backend - resources: {} + resources: {} imagePullSecrets: - name: pull-secret status: {} diff --git a/k8s/config/configmaps/postgres-configmap.yaml b/k8s/config/configmaps/postgres-configmap.yaml new file mode 100644 index 0000000..5ec5b88 --- /dev/null +++ b/k8s/config/configmaps/postgres-configmap.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +data: + POSTGRES_DB: cybermaster-backend-db + POSTGRES_USER: dbuser +kind: ConfigMap +metadata: + creationTimestamp: null + name: postgres-config diff --git a/k8s/config/namespaces/labs-namespace.yaml b/k8s/config/namespaces/labs-namespace.yaml new file mode 100644 index 0000000..7483033 --- /dev/null +++ b/k8s/config/namespaces/labs-namespace.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: Namespace +metadata: + creationTimestamp: null + name: lab-pods +spec: {} +status: {} diff --git a/k8s/config/roles/bind-podmanager.yaml b/k8s/config/roles/bind-podmanager.yaml new file mode 100644 index 0000000..3307ee9 --- /dev/null +++ b/k8s/config/roles/bind-podmanager.yaml @@ -0,0 +1,13 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + creationTimestamp: null + name: bind-pod-manager +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: pod-manager +subjects: +- kind: ServiceAccount + name: django-app + namespace: default \ No newline at end of file diff --git a/k8s/config/roles/podmanager-role.yaml b/k8s/config/roles/podmanager-role.yaml new file mode 100644 index 0000000..1814f74 --- /dev/null +++ b/k8s/config/roles/podmanager-role.yaml @@ -0,0 +1,18 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + creationTimestamp: null + namespace: lab-pods + name: pod-manager +rules: +- apiGroups: + - "" + resources: + - pods + verbs: + - create + - delete + - get + - list + - update + - watch \ No newline at end of file diff --git a/k8s/config/secrets/postgres-secret.yaml b/k8s/config/secrets/postgres-secret.yaml new file mode 100644 index 0000000..64c1cbe --- /dev/null +++ b/k8s/config/secrets/postgres-secret.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +data: + POSTGRES_PASSWORD: MTIzNA== +kind: Secret +metadata: + creationTimestamp: null + name: postgres-secret diff --git a/k8s/config/secrets/pull-secret.yaml b/k8s/config/secrets/pull-secret.yaml new file mode 100644 index 0000000..19128e7 --- /dev/null +++ b/k8s/config/secrets/pull-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +data: + .dockerconfigjson: ewoJImF1dGhzIjogewoJCSJodHRwczovL2luZGV4LmRvY2tlci5pby92MS8iOiB7CgkJCSJhdXRoIjogIk1XNXBkSEpoYldaek9tUmphM0pmY0dGMFgybHFhbDlXV1ZGelNXRmFORTR3VTA1VVVYWjVhREJxWkhOR2R3PT0iCgkJfSwKCQkiaHR0cHM6Ly9pbmRleC5kb2NrZXIuaW8vdjEvYWNjZXNzLXRva2VuIjogewoJCQkiYXV0aCI6ICJNVzVwZEhKaGJXWnpPbVY1U21oaVIyTnBUMmxLVTFWNlNURk9hVWx6U1c1U05XTkRTVFpKYTNCWVZrTkpjMGx0ZEhCYVEwazJTVzVvV1dFelFrTmtSRTU1VmpOTmVWSjVNVEZaYW14elkwVndibU5UU2prdVpYbEtiMlJJVW5kamVtOTJUREpvTVZscE5XdGlNazV5V2xoSmRWa3lPWFJKYW5BM1NXMVdkRmxYYkhOSmFtOXBWRk0xVGxwWGFITmlNMVp6VVVkV2VtRlRNWHBaYlVWMVdraHZhVXhEU25wYVdFNTZZVmM1ZFZneWJHdEphbTlwV2tSb2Exa3lUbWhOTWxGMFRtMVNhRnBwTURCUFYxVXpURmRKTlU5RVRYUk5Na3BxVFcxSk0wNUVUVFJhYWs1cFNXbDNhV015T1RGamJVNXNTV3B2YVZveWJEQmhTRlpwU1dsM2FXUllUbXhqYlRWb1lsZFZhVTlwU1hoaWJXd3dZMjFHZEZwdVRXbE1RMG94WkZkc2EwbHFiMmxQUkd4dFRVZEZkMXBFWjNSTmFrRjRUbE13TUZsVVNtbE1WMFpvVFcxTmRGa3lXVFJaYW1kNFRtcEZNRTFFUlRWSmJqQnpTVzFzZW1ONVNUWkpiV2d3WkVoQ2VrOXBPSFppUnpsdVlWYzBkVnBIT1dwaE1sWjVURzFPZG1KVE9HbE1RMHA2WkZkSmFVOXBTbTVoV0ZKdlpGZEtPRTFVVVRGT2VsVXdUa1JGTUVscGQybFpXRlpyU1dwd1lrbHRhREJrU0VKNlQyazRkbUZJVm1sTWJWSjJXVEowYkdOcE5XcGlNakJwVEVOS2IyUklVbmRqZW05MlRESlNkbGt5ZEd4amFURjNZMjA1YTB4dVZucE1iVVl4WkVkbmQweHRUblppVXpreFl6SldlV0ZYTlcxaWVVcGtURU5LY0ZsWVVXbFBha1V6VGtSUk1FNVVVVEZPVkZGelNXMVdOR05EU1RaTlZHTXdUa1JSTVU5RVJURk9RM2RwWXpKT2RtTkhWV2xQYVVwMlkwZFdkV0ZYVVdkaU1scHRZa2RzZFZwV09XaFpNazVzWXpOTmFVeERTbWhsYmtGcFQybEtUVTVJV1hkYVJ6RnpWR3RLZDFkV1ZuRlNNR1JvV1dwQ1JFMXJjREJhTVZKdVYwaEplRlZZYnpCYVEwbzVMbGRGUTJGaFdHVmxOWHBhVFZSRFpHMW9jbDlwY1daZmVVcHJkME5EUTE5alUyRnlMV2xOUVc5WlNtcEhVV3RIYkd4a1JsaHBTRVpSVEZSSFdXSkdhSHB5WXpWdmRHczRRbXBzY0hWR2RrdG9aVEIyYVd0R2VuQnJYMGxUZURscmJVRjRTWEJoV1hCelprMURUMFpJUnpVNGJ6UkpkMUl0YzNsRlRsZFRUMEUzWlhwUU1uRjRZalV3ZEVkVlMzRkZjWE5EWVdOUFJYazFSVFE0Y1dzdGR6YzVUazlQZGswMVlXUlJORFZTYTBsYVIwSTNTRmhxU0VkRk4waEVhekpXYURsVmVFVnZXRkpyVlZkSWJtNHhkV1EwTWxabFJrWnVSRTl6Y0ZrdGNFZEhZVEkxUkRaSlVuRlVSazlLWmtVNFkzWk9hRWhtYjA4MmJsSkhaRGhSUzFNeVQyeExlbU5NWTNoMFoxWlhValZNVFZaWWJWWjNUM2hGU0RaTVR6ZzVkVmhvVjNsV2IzUnRRMVpDY0ZKdVZVbElNV2MxUWtWVVFuYzRTeTF0VEhjd1oyUllTWE5mTFVSNFRXVlpUVU5LYm1WVlFtTm9VUT09IgoJCX0sCgkJImh0dHBzOi8vaW5kZXguZG9ja2VyLmlvL3YxL3JlZnJlc2gtdG9rZW4iOiB7CgkJCSJhdXRoIjogIk1XNXBkSEpoYldaek9uWXhMazFpUzJkVFRsRmxUR1pWVGpnMVVqTkxUa2xzU1c1SlluRTFTbkpDT1hRNFF6Qm9YeTFGZFVodlFscFhSR3RhYTJsSmVWQnRaR1l0VGtkUk9HMHRUMHRPU0V3NWN5MUlSVGh4T1ZOaU1Ua3hWV3gyUjFKRVZTNHVURFIyTUdSdGJFNUNjRmxWYWtkSFlXSXdRekpLZEdkVVoxaHlNVkY2TkdRPSIKCQl9Cgl9Cn0= +kind: Secret +metadata: + creationTimestamp: null + name: pull-secret +type: kubernetes.io/dockerconfigjson diff --git a/k8s/config/serviceaccounts/app-serviceaccount.yaml b/k8s/config/serviceaccounts/app-serviceaccount.yaml new file mode 100644 index 0000000..90a625a --- /dev/null +++ b/k8s/config/serviceaccounts/app-serviceaccount.yaml @@ -0,0 +1,5 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + creationTimestamp: null + name: django-app \ No newline at end of file diff --git a/labs/views.py b/labs/views.py index e55b89e..fe05ec8 100644 --- a/labs/views.py +++ b/labs/views.py @@ -10,9 +10,6 @@ from django.shortcuts import get_object_or_404 from .utils.percentage import calculate_solve_percentages - - - #lab views class LabList(APIView): def get(self, request, format=None): diff --git a/requirements.txt b/requirements.txt index 28f0611..8764964 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,12 +12,14 @@ cryptography==44.0.2 cssselect2==0.8.0 defusedxml==0.7.1 Django==5.1.6 +django-cors-headers==4.7.0 django-jazzmin==3.0.1 django-rest-framework==0.1.0 django-rest-passwordreset==1.5.0 djangorestframework==3.15.2 djangorestframework_simplejwt==5.5.0 drf-yasg==1.21.9 +durationpy==0.9 google-ai-generativelanguage==0.6.15 google-api-core==2.24.2 google-api-python-client==2.167.0 @@ -34,8 +36,10 @@ httplib2==0.22.0 idna==3.10 inflection==0.5.1 Jinja2==3.1.6 +kubernetes==32.0.1 lxml==5.3.2 MarkupSafe==3.0.2 +oauthlib==3.2.2 oscrypto==1.3.0 packaging==24.2 pillow==11.1.0 @@ -55,11 +59,13 @@ PyJWT==2.9.0 pyparsing==3.2.3 pypdf==5.4.0 python-bidi==0.6.6 +python-dateutil==2.9.0.post0 pytz==2025.1 PyYAML==6.0.2 qrcode==8.1 reportlab==4.3.1 requests==2.32.3 +requests-oauthlib==2.0.0 rsa==4.9.1 segno==1.6.6 six==1.17.0 @@ -74,4 +80,5 @@ uritemplate==4.1.1 uritools==4.0.3 urllib3==2.4.0 webencodings==0.5.1 +websocket-client==1.8.0 whitenoise==6.9.0 diff --git a/setup-k8s.sh b/setup-k8s.sh index 11c71ab..9aa80a5 100755 --- a/setup-k8s.sh +++ b/setup-k8s.sh @@ -6,21 +6,25 @@ mkdir -p ./k8s/config kubectl create configmap postgres-config \ --from-env-file=.env \ --dry-run=client \ - -o yaml > ./k8s/config/postgres-configmap.yaml + -o yaml > ./k8s/config/configmaps/postgres-configmap.yaml # Generate secret for db kubectl create secret generic postgres-secret \ --from-env-file=.env.secret \ --dry-run=client \ - -o yaml > ./k8s/config/postgres-secret.yaml + -o yaml > ./k8s/config/secrets/postgres-secret.yaml # Generate pull secrets kubectl create secret generic pull-secret \ --from-file=.dockerconfigjson=$HOME/.docker/config.json \ --type=kubernetes.io/dockerconfigjson \ - --dry-run=client -o yaml > ./k8s/config/pull-secret.yaml + --dry-run=client -o yaml > ./k8s/config/secrets/pull-secret.yaml # Aplly everything -kubectl apply -f k8s/config -kubectl apply -f k8s/backend -kubectl apply -f k8s/postgres \ No newline at end of file +kubectl apply -f k8s/config/secrets +kubectl apply -f k8s/config/configmaps +kubectl apply -f k8s/config/namespaces +kubectl apply -f k8s/config/serviceaccounts +kubectl apply -f k8s/config/roles +kubectl apply -f k8s/postgres +kubectl apply -f k8s/backend \ No newline at end of file From c1a3909aa09159a145c65825f7a660b8fd82dfac Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 02:06:18 +0100 Subject: [PATCH 02/32] idk what is going on at this point but we move onwards --- .github/workflows/build-container.yml | 4 +- ...hed_alter_examattempt_duration_and_more.py | 33 +++++++++++++++ labs/migrations/0001_initial.py | 42 ++++++++++++++++++- labs/migrations/0002_solvedlab.py | 28 ------------- labs/migrations/0003_badge.py | 25 ----------- labs/migrations/0004_remove_lab_lesson.py | 17 -------- ...05_rename_solved_at_solvedlab_solved_on.py | 18 -------- labs/models.py | 27 +++++++++--- labs/serializers.py | 14 +++++-- labs/urls.py | 2 +- labs/views.py | 23 +++++----- 11 files changed, 118 insertions(+), 115 deletions(-) create mode 100644 exams/migrations/0002_examattempt_is_finished_alter_examattempt_duration_and_more.py delete mode 100644 labs/migrations/0002_solvedlab.py delete mode 100644 labs/migrations/0003_badge.py delete mode 100644 labs/migrations/0004_remove_lab_lesson.py delete mode 100644 labs/migrations/0005_rename_solved_at_solvedlab_solved_on.py diff --git a/.github/workflows/build-container.yml b/.github/workflows/build-container.yml index 27e9c91..193f24d 100644 --- a/.github/workflows/build-container.yml +++ b/.github/workflows/build-container.yml @@ -6,9 +6,9 @@ name: CI on: # Triggers the workflow on push or pull request events but only for the "main" branch push: - branches: [ "experimental" ] + branches: [ "machines-exp" ] pull_request: - branches: [ "experimental" ] + branches: [ "machines-exp" ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/exams/migrations/0002_examattempt_is_finished_alter_examattempt_duration_and_more.py b/exams/migrations/0002_examattempt_is_finished_alter_examattempt_duration_and_more.py new file mode 100644 index 0000000..5200168 --- /dev/null +++ b/exams/migrations/0002_examattempt_is_finished_alter_examattempt_duration_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 5.1.6 on 2025-04-23 00:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('exams', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='examattempt', + name='is_finished', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='examattempt', + name='duration', + field=models.DurationField(blank=True, null=True), + ), + migrations.AlterField( + model_name='examattempt', + name='started_at', + field=models.DateTimeField(auto_now_add=True), + ), + migrations.AlterField( + model_name='question', + name='correct_answer', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/labs/migrations/0001_initial.py b/labs/migrations/0001_initial.py index d037aed..abf1dce 100644 --- a/labs/migrations/0001_initial.py +++ b/labs/migrations/0001_initial.py @@ -1,6 +1,7 @@ -# Generated by Django 5.1.6 on 2025-04-11 21:16 +# Generated by Django 5.1.6 on 2025-04-23 00:44 import django.db.models.deletion +from django.conf import settings from django.db import migrations, models @@ -10,10 +11,20 @@ class Migration(migrations.Migration): dependencies = [ ('categories', '0001_initial'), - ('courses', '0001_initial'), + ('courses', '0004_enrollment_cert_ready'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ + migrations.CreateModel( + name='Badge', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('badge_name', models.CharField(max_length=100)), + ('earned_on', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), migrations.CreateModel( name='Lab', fields=[ @@ -37,4 +48,31 @@ class Migration(migrations.Migration): ('resource', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='labs.lab')), ], ), + migrations.CreateModel( + name='Machine', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('description', models.TextField()), + ('points', models.IntegerField()), + ('author', models.CharField(max_length=255)), + ('image', models.TextField(blank=True, null=True)), + ('flag', models.CharField(blank=True, max_length=255, null=True)), + ('difficulty', models.CharField(choices=[('easy', 'Easy'), ('medium', 'Medium'), ('hard', 'Hard')], default='easy', max_length=50)), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='machines', to='categories.category')), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='machines', to='courses.lesson')), + ], + ), + migrations.CreateModel( + name='SolvedLab', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('solved_on', models.DateTimeField(auto_now_add=True)), + ('lab', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='labs.lab')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'lab')}, + }, + ), ] diff --git a/labs/migrations/0002_solvedlab.py b/labs/migrations/0002_solvedlab.py deleted file mode 100644 index 515b5bf..0000000 --- a/labs/migrations/0002_solvedlab.py +++ /dev/null @@ -1,28 +0,0 @@ -# Generated by Django 5.1.6 on 2025-04-15 15:54 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('labs', '0001_initial'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='SolvedLab', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('solved_at', models.DateTimeField(auto_now_add=True)), - ('lab', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='labs.lab')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - options={ - 'unique_together': {('user', 'lab')}, - }, - ), - ] diff --git a/labs/migrations/0003_badge.py b/labs/migrations/0003_badge.py deleted file mode 100644 index d2ac9bb..0000000 --- a/labs/migrations/0003_badge.py +++ /dev/null @@ -1,25 +0,0 @@ -# Generated by Django 5.1.6 on 2025-04-16 21:19 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('labs', '0002_solvedlab'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='Badge', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('badge_name', models.CharField(max_length=100)), - ('earned_on', models.DateTimeField(auto_now_add=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/labs/migrations/0004_remove_lab_lesson.py b/labs/migrations/0004_remove_lab_lesson.py deleted file mode 100644 index 3a352f6..0000000 --- a/labs/migrations/0004_remove_lab_lesson.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.1.6 on 2025-04-17 10:18 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('labs', '0003_badge'), - ] - - operations = [ - migrations.RemoveField( - model_name='lab', - name='lesson', - ), - ] diff --git a/labs/migrations/0005_rename_solved_at_solvedlab_solved_on.py b/labs/migrations/0005_rename_solved_at_solvedlab_solved_on.py deleted file mode 100644 index 0ec4589..0000000 --- a/labs/migrations/0005_rename_solved_at_solvedlab_solved_on.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.6 on 2025-04-17 10:43 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('labs', '0004_remove_lab_lesson'), - ] - - operations = [ - migrations.RenameField( - model_name='solvedlab', - old_name='solved_at', - new_name='solved_on', - ), - ] diff --git a/labs/models.py b/labs/models.py index 68e9ede..5831b39 100644 --- a/labs/models.py +++ b/labs/models.py @@ -18,7 +18,7 @@ class Lab(models.Model): points = models.IntegerField() author = models.CharField(max_length=255) category = models.ForeignKey(Category, related_name='labs', on_delete=models.CASCADE) - #lesson = models.ForeignKey(Lesson, related_name='labs', on_delete=models.CASCADE,) + lesson = models.ForeignKey(Lesson, related_name='labs', on_delete=models.CASCADE,) connection_info = models.TextField(blank=True, null=True) flag = models.CharField(max_length=255, blank=True, null=True) difficulty = models.CharField(max_length=50, choices=DIFFICULTY_CHOICES, default='easy') @@ -26,8 +26,6 @@ class Lab(models.Model): def __str__(self): return f"{self.title} ({self.difficulty})" - - class LabResourceFile(models.Model): resource = models.ForeignKey(Lab, related_name='files', on_delete=models.CASCADE) file = models.FileField(upload_to='lab_resources/', blank=True, null=True) @@ -38,9 +36,6 @@ def __str__(self): return f"Resource File: {self.file.name}" return "Resource File: [No file uploaded]" - - - class SolvedLab(models.Model): user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) lab = models.ForeignKey(Lab, on_delete=models.CASCADE) @@ -59,3 +54,23 @@ class Badge(models.Model): def __str__(self): return f"{self.badge_name} Earned By {self.user.username} on {self.earned_on}" + +class Machine(models.Model): + DIFFICULTY_CHOICES = [ + ('easy', 'Easy'), + ('medium', 'Medium'), + ('hard', 'Hard'), + ] + + title = models.CharField(max_length=255) + description = models.TextField() + points = models.IntegerField() + author = models.CharField(max_length=255) + category = models.ForeignKey(Category, related_name='machines', on_delete=models.CASCADE) + lesson = models.ForeignKey(Lesson, related_name='machines', on_delete=models.CASCADE,) + image = models.TextField(blank=True, null=True) + flag = models.CharField(max_length=255, blank=True, null=True) + difficulty = models.CharField(max_length=50, choices=DIFFICULTY_CHOICES, default='easy') + + def __str__(self): + return f"{self.title} ({self.difficulty})" \ No newline at end of file diff --git a/labs/serializers.py b/labs/serializers.py index d8e444f..65c6892 100644 --- a/labs/serializers.py +++ b/labs/serializers.py @@ -21,7 +21,7 @@ class LabSerializer(serializers.ModelSerializer): class Meta: model = Lab fields = [ - 'id', 'title', 'description', 'points', 'author', 'category', 'category_name', + 'id', 'title', 'description', 'points','lesson', 'author', 'category', 'category_name', 'connection_info', 'difficulty', 'files' ] @@ -42,9 +42,17 @@ class Meta: model = SolvedLab fields = ['id', 'user', 'lab_title', 'solved_on'] - - class BadgeSerializer(serializers.ModelSerializer): class Meta: model = Badge fields = ['id', 'user', 'badge_name', 'earned_on'] + +class MachineSerializer(serializers.ModelSerializer): + category_name = serializers.CharField(source='category.name', read_only=True) + + class Meta: + model = Machine + fields = [ + 'id', 'title', 'description', 'points','lesson', 'author', 'category', 'category_name', + 'image', 'difficulty' + ] \ No newline at end of file diff --git a/labs/urls.py b/labs/urls.py index 4879509..4a1d5f0 100644 --- a/labs/urls.py +++ b/labs/urls.py @@ -12,5 +12,5 @@ path('submit_flag//', SubmitFlag.as_view(), name='submit-flag'), path('badges/', BadgeList.as_view(), name='badge-list'), path('solved_labs/', SolvedLabList.as_view(), name='solved-lab-list'), - + path('spin/', CreateMachine.as_view(), name='spin'), ] \ No newline at end of file diff --git a/labs/views.py b/labs/views.py index fe05ec8..2d2ac63 100644 --- a/labs/views.py +++ b/labs/views.py @@ -9,6 +9,10 @@ from rest_framework.permissions import IsAuthenticated from django.shortcuts import get_object_or_404 from .utils.percentage import calculate_solve_percentages +from kubernetes import client,config + +config.load_incluster_config() +v1=client.CoreV1Api() #lab views class LabList(APIView): @@ -108,7 +112,6 @@ def post(self, request, lab_id, format=None): "badge_earned": badge_created, "badge_name": badge_name if badge_created else None }, status=status.HTTP_200_OK) - class SolveProgress(APIView): permission_classes = [IsAuthenticated] @@ -118,7 +121,6 @@ def get(self, request): progress = calculate_solve_percentages(user) return Response(progress, status=200) - class SolvedLabList(APIView): permission_classes = [IsAuthenticated] @@ -128,12 +130,6 @@ def get(self, request, format=None): serializer = SolvedLabSerializer(solved_labs, many=True) return Response(serializer.data) - - - - - - class BadgeList(APIView): permission_classes = [IsAuthenticated] @@ -142,11 +138,6 @@ def get(self, request, format=None): serializer = BadgeSerializer(badges, many=True) return Response(serializer.data) - - - - - class Search(APIView): def get(self,request): query = request.GET.get('query',None) @@ -157,3 +148,9 @@ def get(self,request): return Response(results) +class CreateMachine(APIView): + def post(self,request): + pod=client.V1Pod() + pod.spec=client.V1PodSpec(containers=[client.V1Container(image="python:slim")]) + v1.create_namespaced_pod(namespace="lab-pods",body=pod) + From 014d5003fe8902c84061db4ffb69c2e271748168 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 02:08:57 +0100 Subject: [PATCH 03/32] oh no, anyway --- Backend/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Backend/settings.py b/Backend/settings.py index 4569eaa..83f01df 100644 --- a/Backend/settings.py +++ b/Backend/settings.py @@ -120,8 +120,8 @@ 'NAME': os.environ.get('DB_NAME','cybermaster-backend-db'), 'USER': os.environ.get('DB_USER','dbuser'), 'PASSWORD': os.environ.get('DB_PASSWORD','1234'), - # 'HOST':os.environ.get('DB_HOST','cybermaster-postgres.default.svc.cluster.local'), - 'HOST':os.environ.get('DB_HOST','127.0.0.1'), + 'HOST':os.environ.get('DB_HOST','cybermaster-postgres.default.svc.cluster.local'), + # 'HOST':os.environ.get('DB_HOST','127.0.0.1'), 'PORT': os.environ.get('DB_PORT','5432'), } } From bfb03c07058101ff211066dd848c89bffac6fc2f Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 02:18:32 +0100 Subject: [PATCH 04/32] oh no, anyway^2 --- labs/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/labs/views.py b/labs/views.py index 2d2ac63..d6194ad 100644 --- a/labs/views.py +++ b/labs/views.py @@ -1,3 +1,4 @@ +import random from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status @@ -151,6 +152,6 @@ def get(self,request): class CreateMachine(APIView): def post(self,request): pod=client.V1Pod() - pod.spec=client.V1PodSpec(containers=[client.V1Container(image="python:slim")]) + pod.spec=client.V1PodSpec(containers=[client.V1Container(name="something"+str(random.randint(1,1000)),image="python:slim")]) v1.create_namespaced_pod(namespace="lab-pods",body=pod) From 0c4fed79bb59fc4be0e9d6fa1c0a581e73fedadc Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 02:38:01 +0100 Subject: [PATCH 05/32] jkjkjkafvjavavji --- k8s/config/roles/bind-podmanager.yaml | 1 + labs/views.py | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/k8s/config/roles/bind-podmanager.yaml b/k8s/config/roles/bind-podmanager.yaml index 3307ee9..f8e48e1 100644 --- a/k8s/config/roles/bind-podmanager.yaml +++ b/k8s/config/roles/bind-podmanager.yaml @@ -3,6 +3,7 @@ kind: RoleBinding metadata: creationTimestamp: null name: bind-pod-manager + namespace: lab-pods roleRef: apiGroup: rbac.authorization.k8s.io kind: Role diff --git a/labs/views.py b/labs/views.py index d6194ad..87c71f1 100644 --- a/labs/views.py +++ b/labs/views.py @@ -151,7 +151,25 @@ def get(self,request): class CreateMachine(APIView): def post(self,request): - pod=client.V1Pod() - pod.spec=client.V1PodSpec(containers=[client.V1Container(name="something"+str(random.randint(1,1000)),image="python:slim")]) + pod_name="something"+str(random.randint(1,1000)) + + pod=client.V1Pod( + api_version="v1", + kind="Pod" + ) + + container=client.V1Container( + name=pod_name, + image="python:slim" + ) + + pod.spec=client.V1PodSpec( + containers=[container] + ) + + pod.metadata=client.V1ObjectMeta( + name=pod_name + ) + v1.create_namespaced_pod(namespace="lab-pods",body=pod) From 518aafc44ab67688d7519e7c96022fe612e3c924 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 02:51:48 +0100 Subject: [PATCH 06/32] jiojefe;jioawefjo wooooooooooooo --- labs/urls.py | 2 +- labs/views.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/labs/urls.py b/labs/urls.py index 4a1d5f0..22e1204 100644 --- a/labs/urls.py +++ b/labs/urls.py @@ -12,5 +12,5 @@ path('submit_flag//', SubmitFlag.as_view(), name='submit-flag'), path('badges/', BadgeList.as_view(), name='badge-list'), path('solved_labs/', SolvedLabList.as_view(), name='solved-lab-list'), - path('spin/', CreateMachine.as_view(), name='spin'), + path('spin/', CreateMachine.as_view(), name='spin'), ] \ No newline at end of file diff --git a/labs/views.py b/labs/views.py index 87c71f1..cbf847d 100644 --- a/labs/views.py +++ b/labs/views.py @@ -150,8 +150,9 @@ def get(self,request): return Response(results) class CreateMachine(APIView): - def post(self,request): - pod_name="something"+str(random.randint(1,1000)) + def post(self,request,pk): + machnine=get_object_or_404(Machine,pk=pk) + pod_name=Machine.title+request.user.username pod=client.V1Pod( api_version="v1", @@ -160,7 +161,7 @@ def post(self,request): container=client.V1Container( name=pod_name, - image="python:slim" + image=machnine.image ) pod.spec=client.V1PodSpec( From 2b53b0ab25e2a1c6744f7dab90c90be3161677c6 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 02:56:10 +0100 Subject: [PATCH 07/32] jiojefe;jioawefjo wooooooooooooo^2 --- labs/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labs/admin.py b/labs/admin.py index 2dc8c52..d704dfe 100644 --- a/labs/admin.py +++ b/labs/admin.py @@ -1,8 +1,8 @@ from django.contrib import admin from .models import * - admin.site.register(Lab) +admin.site.register(Machine) admin.site.register(LabResourceFile) admin.site.register(SolvedLab) admin.site.register(Badge) From 5e8d4faf66563f75cef516476488b897f5ae5da1 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 03:28:01 +0100 Subject: [PATCH 08/32] I should really go to sleep(nah) --- labs/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labs/views.py b/labs/views.py index cbf847d..d70a633 100644 --- a/labs/views.py +++ b/labs/views.py @@ -152,7 +152,7 @@ def get(self,request): class CreateMachine(APIView): def post(self,request,pk): machnine=get_object_or_404(Machine,pk=pk) - pod_name=Machine.title+request.user.username + pod_name=str(Machine.title)+str(request.user.username) pod=client.V1Pod( api_version="v1", From a9fd26f3e5f4006d1c90403cfea0c26b19557634 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 03:35:12 +0100 Subject: [PATCH 09/32] I should really really REALLY go to sleep(nah) --- labs/views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/labs/views.py b/labs/views.py index d70a633..86285d5 100644 --- a/labs/views.py +++ b/labs/views.py @@ -151,8 +151,8 @@ def get(self,request): class CreateMachine(APIView): def post(self,request,pk): - machnine=get_object_or_404(Machine,pk=pk) - pod_name=str(Machine.title)+str(request.user.username) + machine=get_object_or_404(Machine,pk=pk) + pod_name=str(machine.title)+str(request.user.username) pod=client.V1Pod( api_version="v1", @@ -161,7 +161,7 @@ def post(self,request,pk): container=client.V1Container( name=pod_name, - image=machnine.image + image=machine.image ) pod.spec=client.V1PodSpec( From 99bdf748af462b7fbe2b2691f7510f476ec357f6 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 04:06:17 +0100 Subject: [PATCH 10/32] No signs of sleep just yet --- labs/views.py | 46 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/labs/views.py b/labs/views.py index 86285d5..0d8da63 100644 --- a/labs/views.py +++ b/labs/views.py @@ -154,23 +154,47 @@ def post(self,request,pk): machine=get_object_or_404(Machine,pk=pk) pod_name=str(machine.title)+str(request.user.username) - pod=client.V1Pod( - api_version="v1", - kind="Pod" - ) - container=client.V1Container( name=pod_name, - image=machine.image + image=machine.image, + ports=[client.V1ContainerPort(container_port=8443)] ) - pod.spec=client.V1PodSpec( - containers=[container] + pod=client.V1Pod( + api_version="v1", + kind="Pod", + metadata=client.V1ObjectMeta( + name=pod_name, + labels={'app':pod_name} + ), + spec=client.V1PodSpec( + containers=[container] + ) ) - pod.metadata=client.V1ObjectMeta( - name=pod_name + service=client.V1Service( + api_version="v1", + kind="Service", + metadata=client.V1ObjectMeta( + name=pod_name+'-service' + ), + spec=client.V1ServiceSpec( + selector={'app':pod_name}, + ports=[ + client.V1ServicePort( + port=9876, + target_port=8443 + ) + ], + type="NodePort" + ) ) v1.create_namespaced_pod(namespace="lab-pods",body=pod) - + v1.create_namespaced_service(namespace="lab-pods",body=service) + + return Response({ + 'pod_name':pod_name, + 'status':'created' + }) + \ No newline at end of file From 193ee6aecdf558679bad61a38e272db330b95060 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 04:21:03 +0100 Subject: [PATCH 11/32] gamble, but let's see --- labs/views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/labs/views.py b/labs/views.py index 0d8da63..7308007 100644 --- a/labs/views.py +++ b/labs/views.py @@ -182,7 +182,7 @@ def post(self,request,pk): selector={'app':pod_name}, ports=[ client.V1ServicePort( - port=9876, + port=8443, target_port=8443 ) ], @@ -192,9 +192,10 @@ def post(self,request,pk): v1.create_namespaced_pod(namespace="lab-pods",body=pod) v1.create_namespaced_service(namespace="lab-pods",body=service) - + node_port=v1.read_namespaced_service(name=pod_name+'-service',namespace='lab-pods').spec.ports[0].node_port return Response({ 'pod_name':pod_name, + 'port':node_port, 'status':'created' }) \ No newline at end of file From 1cbc55e917ce5e2e9b000f7cb694848e422fe73b Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 04:44:38 +0100 Subject: [PATCH 12/32] IT WORKS --- k8s/config/roles/podmanager-role.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/k8s/config/roles/podmanager-role.yaml b/k8s/config/roles/podmanager-role.yaml index 1814f74..bb3abfc 100644 --- a/k8s/config/roles/podmanager-role.yaml +++ b/k8s/config/roles/podmanager-role.yaml @@ -9,6 +9,7 @@ rules: - "" resources: - pods + - services verbs: - create - delete From c1fbf8c3b5a6ffc94ee6bce29aab5666fc4929cc Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 05:02:18 +0100 Subject: [PATCH 13/32] untested code --- labs/models.py | 24 ++--------- labs/serializers.py | 14 +------ labs/urls.py | 2 +- labs/views.py | 97 +++++++++++++++++++++++++-------------------- users/models.py | 12 +----- 5 files changed, 61 insertions(+), 88 deletions(-) diff --git a/labs/models.py b/labs/models.py index 5831b39..fa4fc51 100644 --- a/labs/models.py +++ b/labs/models.py @@ -13,11 +13,13 @@ class Lab(models.Model): ('hard', 'Hard'), ] + is_machine=models.BooleanField(default=False) title = models.CharField(max_length=255) description = models.TextField() points = models.IntegerField() author = models.CharField(max_length=255) category = models.ForeignKey(Category, related_name='labs', on_delete=models.CASCADE) + image = models.CharField(max_length=255) lesson = models.ForeignKey(Lesson, related_name='labs', on_delete=models.CASCADE,) connection_info = models.TextField(blank=True, null=True) flag = models.CharField(max_length=255, blank=True, null=True) @@ -53,24 +55,4 @@ class Badge(models.Model): earned_on = models.DateTimeField(auto_now_add=True) def __str__(self): - return f"{self.badge_name} Earned By {self.user.username} on {self.earned_on}" - -class Machine(models.Model): - DIFFICULTY_CHOICES = [ - ('easy', 'Easy'), - ('medium', 'Medium'), - ('hard', 'Hard'), - ] - - title = models.CharField(max_length=255) - description = models.TextField() - points = models.IntegerField() - author = models.CharField(max_length=255) - category = models.ForeignKey(Category, related_name='machines', on_delete=models.CASCADE) - lesson = models.ForeignKey(Lesson, related_name='machines', on_delete=models.CASCADE,) - image = models.TextField(blank=True, null=True) - flag = models.CharField(max_length=255, blank=True, null=True) - difficulty = models.CharField(max_length=50, choices=DIFFICULTY_CHOICES, default='easy') - - def __str__(self): - return f"{self.title} ({self.difficulty})" \ No newline at end of file + return f"{self.badge_name} Earned By {self.user.username} on {self.earned_on}" \ No newline at end of file diff --git a/labs/serializers.py b/labs/serializers.py index 65c6892..16420eb 100644 --- a/labs/serializers.py +++ b/labs/serializers.py @@ -21,7 +21,7 @@ class LabSerializer(serializers.ModelSerializer): class Meta: model = Lab fields = [ - 'id', 'title', 'description', 'points','lesson', 'author', 'category', 'category_name', + 'id', 'is_machine','title', 'description', 'points','lesson', 'author', 'category', 'category_name', 'connection_info', 'difficulty', 'files' ] @@ -45,14 +45,4 @@ class Meta: class BadgeSerializer(serializers.ModelSerializer): class Meta: model = Badge - fields = ['id', 'user', 'badge_name', 'earned_on'] - -class MachineSerializer(serializers.ModelSerializer): - category_name = serializers.CharField(source='category.name', read_only=True) - - class Meta: - model = Machine - fields = [ - 'id', 'title', 'description', 'points','lesson', 'author', 'category', 'category_name', - 'image', 'difficulty' - ] \ No newline at end of file + fields = ['id', 'user', 'badge_name', 'earned_on'] \ No newline at end of file diff --git a/labs/urls.py b/labs/urls.py index 22e1204..ba463d8 100644 --- a/labs/urls.py +++ b/labs/urls.py @@ -7,10 +7,10 @@ path('search', Search.as_view(), name='lab-search'), path('/files', LabResourceFileList.as_view(), name='lab-file-list-create'), + path('/start', CreateMachine.as_view(), name='spin'), path('files//', LabResourceFileDetail.as_view(), name='lab-file-detail'), path('progress/', SolveProgress.as_view(), name='solve-progress'), path('submit_flag//', SubmitFlag.as_view(), name='submit-flag'), path('badges/', BadgeList.as_view(), name='badge-list'), path('solved_labs/', SolvedLabList.as_view(), name='solved-lab-list'), - path('spin/', CreateMachine.as_view(), name='spin'), ] \ No newline at end of file diff --git a/labs/views.py b/labs/views.py index 7308007..345e575 100644 --- a/labs/views.py +++ b/labs/views.py @@ -11,6 +11,8 @@ from django.shortcuts import get_object_or_404 from .utils.percentage import calculate_solve_percentages from kubernetes import client,config +from kubernetes.client.rest import ApiException +import hashlib config.load_incluster_config() v1=client.CoreV1Api() @@ -151,51 +153,60 @@ def get(self,request): class CreateMachine(APIView): def post(self,request,pk): - machine=get_object_or_404(Machine,pk=pk) - pod_name=str(machine.title)+str(request.user.username) - - container=client.V1Container( - name=pod_name, - image=machine.image, - ports=[client.V1ContainerPort(container_port=8443)] - ) - - pod=client.V1Pod( - api_version="v1", - kind="Pod", - metadata=client.V1ObjectMeta( + machine=get_object_or_404(Lab,pk=pk) + pod_name=f"{machine.title}-{hashlib.md5(request.user.username).hexdigest()}" + try: + v1.read_namespaced_pod(name=pod_name,namespace='labs-pods') + node_port=v1.read_namespaced_service(name=pod_name+'-service',namespace='lab-pods').spec.ports[0].node_port + return Response({ + 'pod_name':pod_name, + 'port':node_port, + 'status':'running' + }) + except ApiException as e: + container=client.V1Container( name=pod_name, - labels={'app':pod_name} - ), - spec=client.V1PodSpec( - containers=[container] + image=machine.image, + ports=[client.V1ContainerPort(container_port=8443)] ) - ) - - service=client.V1Service( - api_version="v1", - kind="Service", - metadata=client.V1ObjectMeta( - name=pod_name+'-service' - ), - spec=client.V1ServiceSpec( - selector={'app':pod_name}, - ports=[ - client.V1ServicePort( - port=8443, - target_port=8443 - ) - ], - type="NodePort" + + pod=client.V1Pod( + api_version="v1", + kind="Pod", + metadata=client.V1ObjectMeta( + name=pod_name, + labels={'app':pod_name} + ), + spec=client.V1PodSpec( + containers=[container] + ) ) - ) - v1.create_namespaced_pod(namespace="lab-pods",body=pod) - v1.create_namespaced_service(namespace="lab-pods",body=service) - node_port=v1.read_namespaced_service(name=pod_name+'-service',namespace='lab-pods').spec.ports[0].node_port - return Response({ - 'pod_name':pod_name, - 'port':node_port, - 'status':'created' - }) + service=client.V1Service( + api_version="v1", + kind="Service", + metadata=client.V1ObjectMeta( + name=pod_name+'-service' + ), + spec=client.V1ServiceSpec( + selector={'app':pod_name}, + ports=[ + client.V1ServicePort( + port=8443, + target_port=8443 + ) + ], + type="NodePort" + ) + ) + + v1.create_namespaced_pod(namespace="lab-pods",body=pod) + v1.create_namespaced_service(namespace="lab-pods",body=service) + node_port=v1.read_namespaced_service(name=pod_name+'-service',namespace='lab-pods').spec.ports[0].node_port + + return Response({ + 'pod_name':pod_name, + 'port':node_port, + 'status':'created' + }) \ No newline at end of file diff --git a/users/models.py b/users/models.py index d0530bd..282314e 100644 --- a/users/models.py +++ b/users/models.py @@ -1,11 +1,7 @@ - from django.db import models from django.contrib.auth.models import AbstractUser from .managers import CustomUserManager - - - class CustomUser(AbstractUser): red_team_percent = models.PositiveIntegerField(default=0) blue_team_percent = models.PositiveIntegerField(default=0) @@ -14,8 +10,6 @@ class CustomUser(AbstractUser): def __str__(self): return self.username - - class Profile(models.Model): user = models.OneToOneField(CustomUser, on_delete=models.CASCADE, related_name='profile') bio = models.TextField(blank=True, null=True) @@ -45,8 +39,4 @@ def calculate_rank(self): def save(self, *args, **kwargs): self.rank = self.calculate_rank() #update rank before saving - super().save(*args, **kwargs) - - - - \ No newline at end of file + super().save(*args, **kwargs) \ No newline at end of file From 30dcabc864f301fc263cedeb26df4395472af5b1 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 05:07:10 +0100 Subject: [PATCH 14/32] more untested code --- labs/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labs/views.py b/labs/views.py index 345e575..21fe5d4 100644 --- a/labs/views.py +++ b/labs/views.py @@ -154,7 +154,7 @@ def get(self,request): class CreateMachine(APIView): def post(self,request,pk): machine=get_object_or_404(Lab,pk=pk) - pod_name=f"{machine.title}-{hashlib.md5(request.user.username).hexdigest()}" + pod_name=f"{machine.id}-{hashlib.md5(request.user.username).hexdigest()}" try: v1.read_namespaced_pod(name=pod_name,namespace='labs-pods') node_port=v1.read_namespaced_service(name=pod_name+'-service',namespace='lab-pods').spec.ports[0].node_port From 117b14e75256889c4ac541fa95a96f5772ea37a5 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 05:23:04 +0100 Subject: [PATCH 15/32] pod lifetimes --- labs/views.py | 104 ++++++++++++++++++++++++++++---------------------- 1 file changed, 58 insertions(+), 46 deletions(-) diff --git a/labs/views.py b/labs/views.py index 21fe5d4..3555d62 100644 --- a/labs/views.py +++ b/labs/views.py @@ -152,61 +152,73 @@ def get(self,request): return Response(results) class CreateMachine(APIView): + @staticmethod + def check_pod(pod_name,pod_namespace): + try: + v1.read_namespaced_pod(name=pod_name,namespace=pod_namespace) + return True + except ApiException as e: + if e.status == 404: + return False + raise + def post(self,request,pk): machine=get_object_or_404(Lab,pk=pk) - pod_name=f"{machine.id}-{hashlib.md5(request.user.username).hexdigest()}" - try: - v1.read_namespaced_pod(name=pod_name,namespace='labs-pods') + if not machine.is_machine: + return Response({'detail':'lab is not a machine'},status=status.HTTP_400_BAD_REQUEST) + + pod_name=f"{machine.id}-{hashlib.md5(request.user.username.encode()).hexdigest()}" + if self.check_pod(pod_name,'lab-pods'): node_port=v1.read_namespaced_service(name=pod_name+'-service',namespace='lab-pods').spec.ports[0].node_port return Response({ 'pod_name':pod_name, 'port':node_port, 'status':'running' }) - except ApiException as e: - container=client.V1Container( + + container=client.V1Container( + name=pod_name, + image=machine.image, + ports=[client.V1ContainerPort(container_port=8443)] + ) + + pod=client.V1Pod( + api_version="v1", + kind="Pod", + metadata=client.V1ObjectMeta( name=pod_name, - image=machine.image, - ports=[client.V1ContainerPort(container_port=8443)] + labels={'app':pod_name,'lab':str(machine.id),'user':request.user.username} + ), + spec=client.V1PodSpec( + containers=[container], + active_deadline_seconds=14400 ) - - pod=client.V1Pod( - api_version="v1", - kind="Pod", - metadata=client.V1ObjectMeta( - name=pod_name, - labels={'app':pod_name} - ), - spec=client.V1PodSpec( - containers=[container] - ) + ) + + service=client.V1Service( + api_version="v1", + kind="Service", + metadata=client.V1ObjectMeta( + name=pod_name+'-service' + ), + spec=client.V1ServiceSpec( + selector={'app':pod_name}, + ports=[ + client.V1ServicePort( + port=8443, + target_port=8443 + ) + ], + type="NodePort" ) + ) - service=client.V1Service( - api_version="v1", - kind="Service", - metadata=client.V1ObjectMeta( - name=pod_name+'-service' - ), - spec=client.V1ServiceSpec( - selector={'app':pod_name}, - ports=[ - client.V1ServicePort( - port=8443, - target_port=8443 - ) - ], - type="NodePort" - ) - ) - - v1.create_namespaced_pod(namespace="lab-pods",body=pod) - v1.create_namespaced_service(namespace="lab-pods",body=service) - node_port=v1.read_namespaced_service(name=pod_name+'-service',namespace='lab-pods').spec.ports[0].node_port - - return Response({ - 'pod_name':pod_name, - 'port':node_port, - 'status':'created' - }) - \ No newline at end of file + v1.create_namespaced_pod(namespace="lab-pods",body=pod) + v1.create_namespaced_service(namespace="lab-pods",body=service) + node_port=v1.read_namespaced_service(name=pod_name+'-service',namespace='lab-pods').spec.ports[0].node_port + + return Response({ + 'pod_name':pod_name, + 'port':node_port, + 'status':'created' + }) From ae1079e7064c159de2a92aa369e162c9b09e2907 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 05:27:30 +0100 Subject: [PATCH 16/32] tiny fix --- labs/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labs/models.py b/labs/models.py index fa4fc51..ace9f50 100644 --- a/labs/models.py +++ b/labs/models.py @@ -19,7 +19,7 @@ class Lab(models.Model): points = models.IntegerField() author = models.CharField(max_length=255) category = models.ForeignKey(Category, related_name='labs', on_delete=models.CASCADE) - image = models.CharField(max_length=255) + image = models.CharField(max_length=255,blank=True, null=True) lesson = models.ForeignKey(Lesson, related_name='labs', on_delete=models.CASCADE,) connection_info = models.TextField(blank=True, null=True) flag = models.CharField(max_length=255, blank=True, null=True) From 0d49d1a15e6f05a7cfaf009e340ba0c78b294428 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 05:31:53 +0100 Subject: [PATCH 17/32] goof --- labs/admin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/labs/admin.py b/labs/admin.py index d704dfe..b3c7928 100644 --- a/labs/admin.py +++ b/labs/admin.py @@ -2,7 +2,6 @@ from .models import * admin.site.register(Lab) -admin.site.register(Machine) admin.site.register(LabResourceFile) admin.site.register(SolvedLab) admin.site.register(Badge) From 37adf07d558311dbf33f860d62a995929a287dec Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 05:44:16 +0100 Subject: [PATCH 18/32] naming conventions strike again --- labs/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labs/views.py b/labs/views.py index 3555d62..76cc8c3 100644 --- a/labs/views.py +++ b/labs/views.py @@ -167,7 +167,7 @@ def post(self,request,pk): if not machine.is_machine: return Response({'detail':'lab is not a machine'},status=status.HTTP_400_BAD_REQUEST) - pod_name=f"{machine.id}-{hashlib.md5(request.user.username.encode()).hexdigest()}" + pod_name=f"machine-{machine.id}-{hashlib.md5(request.user.username.encode()).hexdigest()}" if self.check_pod(pod_name,'lab-pods'): node_port=v1.read_namespaced_service(name=pod_name+'-service',namespace='lab-pods').spec.ports[0].node_port return Response({ From 98adeb9f3e6df70837cf220bc5ff5fd9b9854fb8 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Wed, 23 Apr 2025 06:16:30 +0100 Subject: [PATCH 19/32] done done annnnnnnnnnd done --- labs/models.py | 1 + labs/views.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/labs/models.py b/labs/models.py index ace9f50..c76e298 100644 --- a/labs/models.py +++ b/labs/models.py @@ -20,6 +20,7 @@ class Lab(models.Model): author = models.CharField(max_length=255) category = models.ForeignKey(Category, related_name='labs', on_delete=models.CASCADE) image = models.CharField(max_length=255,blank=True, null=True) + port = models.IntegerField(default=8443) lesson = models.ForeignKey(Lesson, related_name='labs', on_delete=models.CASCADE,) connection_info = models.TextField(blank=True, null=True) flag = models.CharField(max_length=255, blank=True, null=True) diff --git a/labs/views.py b/labs/views.py index 76cc8c3..1832633 100644 --- a/labs/views.py +++ b/labs/views.py @@ -205,8 +205,8 @@ def post(self,request,pk): selector={'app':pod_name}, ports=[ client.V1ServicePort( - port=8443, - target_port=8443 + port=machine.port, + target_port=machine.port ) ], type="NodePort" From dce02f0b4a242bec7b7b0d6a4ec1ab618ef24545 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Thu, 24 Apr 2025 16:23:33 +0100 Subject: [PATCH 20/32] radom --- k8s/backend/backend-ingress.yaml | 19 +++++++++++++++++++ k8s/backend/backend-service.yaml | 2 +- labs/views.py | 2 +- 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 k8s/backend/backend-ingress.yaml diff --git a/k8s/backend/backend-ingress.yaml b/k8s/backend/backend-ingress.yaml new file mode 100644 index 0000000..077ef69 --- /dev/null +++ b/k8s/backend/backend-ingress.yaml @@ -0,0 +1,19 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + creationTimestamp: null + name: cybermaster-backend-ingress +spec: + rules: + - host: cybermaster.tech + http: + paths: + - backend: + service: + name: backend-backend + port: + number: 8000 + path: /api + pathType: Prefix +status: + loadBalancer: {} \ No newline at end of file diff --git a/k8s/backend/backend-service.yaml b/k8s/backend/backend-service.yaml index 8360a77..16ad4b1 100644 --- a/k8s/backend/backend-service.yaml +++ b/k8s/backend/backend-service.yaml @@ -13,6 +13,6 @@ spec: targetPort: 8000 selector: app: cybermaster-backend - type: NodePort + type: ClusterIP status: loadBalancer: {} diff --git a/labs/views.py b/labs/views.py index 1832633..0067568 100644 --- a/labs/views.py +++ b/labs/views.py @@ -209,7 +209,7 @@ def post(self,request,pk): target_port=machine.port ) ], - type="NodePort" + # type="NodePort" ) ) From a222a08daf38ebe9f21d4c8c2b6f0b3933e3cdb7 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Thu, 24 Apr 2025 18:42:06 +0100 Subject: [PATCH 21/32] thingie mc doodle --- k8s/backend/backend-ingress.yaml | 2 +- k8s/backend/backend-service.yaml | 4 ++-- labs/views.py | 40 +++++++++++++++++++++++++++----- 3 files changed, 37 insertions(+), 9 deletions(-) diff --git a/k8s/backend/backend-ingress.yaml b/k8s/backend/backend-ingress.yaml index 077ef69..3fad52b 100644 --- a/k8s/backend/backend-ingress.yaml +++ b/k8s/backend/backend-ingress.yaml @@ -10,7 +10,7 @@ spec: paths: - backend: service: - name: backend-backend + name: cybermaster-backend port: number: 8000 path: /api diff --git a/k8s/backend/backend-service.yaml b/k8s/backend/backend-service.yaml index 16ad4b1..71c97d6 100644 --- a/k8s/backend/backend-service.yaml +++ b/k8s/backend/backend-service.yaml @@ -7,8 +7,8 @@ metadata: name: cybermaster-backend spec: ports: - - name: 80-8000 - port: 80 + - name: 8000-8000 + port: 8000 protocol: TCP targetPort: 8000 selector: diff --git a/labs/views.py b/labs/views.py index 0067568..00036cd 100644 --- a/labs/views.py +++ b/labs/views.py @@ -169,10 +169,8 @@ def post(self,request,pk): pod_name=f"machine-{machine.id}-{hashlib.md5(request.user.username.encode()).hexdigest()}" if self.check_pod(pod_name,'lab-pods'): - node_port=v1.read_namespaced_service(name=pod_name+'-service',namespace='lab-pods').spec.ports[0].node_port return Response({ 'pod_name':pod_name, - 'port':node_port, 'status':'running' }) @@ -208,17 +206,47 @@ def post(self,request,pk): port=machine.port, target_port=machine.port ) - ], - # type="NodePort" + ] + ) + ) + + ingress=client.V1Ingress( + api_version="v1", + kind="Ingress", + metadata=client.V1ObjectMeta( + name=pod_name+'-ingress' + ), + spec=client.V1IngressSpec( + rules=[ + client.V1IngressRule( + host="cybermaster.tech", + http=client.V1HTTPIngressRuleValue( + paths=[ + client.V1HTTPIngressPath( + path=f"/{request.user.username}/{machine.id}", + path_type="Prefix", + backend=client.V1IngressBackend( + service=client.V1IngressServiceBackend( + name=pod_name+'-service', + port=client.V1ServiceBackendPort( + number=machine.port + ) + ) + ) + ) + ] + ) + ) + ] ) ) v1.create_namespaced_pod(namespace="lab-pods",body=pod) v1.create_namespaced_service(namespace="lab-pods",body=service) - node_port=v1.read_namespaced_service(name=pod_name+'-service',namespace='lab-pods').spec.ports[0].node_port + v1_api=client.NetworkingV1Api(client.ApiClient(client.Configuration())) + v1_api.create_namespaced_ingress(namespace="lab-pods",body=ingress) return Response({ 'pod_name':pod_name, - 'port':node_port, 'status':'created' }) From 61d4123969911ae0cfc718dc4a0fb5d5a65580ae Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Thu, 24 Apr 2025 21:18:21 +0100 Subject: [PATCH 22/32] something --- k8s/backend/backend-ingress.yaml | 16 +++++++++++++++- labs/views.py | 4 ++-- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/k8s/backend/backend-ingress.yaml b/k8s/backend/backend-ingress.yaml index 3fad52b..488c78b 100644 --- a/k8s/backend/backend-ingress.yaml +++ b/k8s/backend/backend-ingress.yaml @@ -15,5 +15,19 @@ spec: number: 8000 path: /api pathType: Prefix + - backend: + service: + name: cybermaster-backend + port: + number: 8000 + path: /admin + pathType: Prefix + - backend: + service: + name: cybermaster-backend + port: + number: 8000 + path: /static + pathType: Prefix status: - loadBalancer: {} \ No newline at end of file + loadBalancer: {} diff --git a/labs/views.py b/labs/views.py index 00036cd..b4d9096 100644 --- a/labs/views.py +++ b/labs/views.py @@ -206,7 +206,7 @@ def post(self,request,pk): port=machine.port, target_port=machine.port ) - ] + ], ) ) @@ -243,7 +243,7 @@ def post(self,request,pk): v1.create_namespaced_pod(namespace="lab-pods",body=pod) v1.create_namespaced_service(namespace="lab-pods",body=service) - v1_api=client.NetworkingV1Api(client.ApiClient(client.Configuration())) + v1_api=client.NetworkingV1Api(client.ApiClient()) v1_api.create_namespaced_ingress(namespace="lab-pods",body=ingress) return Response({ From ffcdcc7d265da971ddb65f2cf5cb8467bcd40e77 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Fri, 25 Apr 2025 17:15:39 +0100 Subject: [PATCH 23/32] things --- k8s/config/roles/podmanager-role.yaml | 11 +++++++++++ labs/views.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/k8s/config/roles/podmanager-role.yaml b/k8s/config/roles/podmanager-role.yaml index bb3abfc..780edd7 100644 --- a/k8s/config/roles/podmanager-role.yaml +++ b/k8s/config/roles/podmanager-role.yaml @@ -16,4 +16,15 @@ rules: - get - list - update + - watch +- apiGroups: + - "networking.k8s.io" + resources: + - ingresses + verbs: + - create + - delete + - get + - list + - update - watch \ No newline at end of file diff --git a/labs/views.py b/labs/views.py index b4d9096..ae8b9d1 100644 --- a/labs/views.py +++ b/labs/views.py @@ -211,7 +211,7 @@ def post(self,request,pk): ) ingress=client.V1Ingress( - api_version="v1", + api_version="networking.k8s.io/v1", kind="Ingress", metadata=client.V1ObjectMeta( name=pod_name+'-ingress' From cea78196fdc76f17d7157ec415fdb381c0164096 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Fri, 25 Apr 2025 17:32:34 +0100 Subject: [PATCH 24/32] more --- labs/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labs/views.py b/labs/views.py index ae8b9d1..bbe6308 100644 --- a/labs/views.py +++ b/labs/views.py @@ -223,7 +223,7 @@ def post(self,request,pk): http=client.V1HTTPIngressRuleValue( paths=[ client.V1HTTPIngressPath( - path=f"/{request.user.username}/{machine.id}", + path=f"/{request.user.username.strip('/')}/{machine.id}", path_type="Prefix", backend=client.V1IngressBackend( service=client.V1IngressServiceBackend( From 27b01f85f8cb2cae5314cbcd7a17d510765f0957 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Fri, 25 Apr 2025 17:57:46 +0100 Subject: [PATCH 25/32] orisdfgvnios --- labs/views.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/labs/views.py b/labs/views.py index bbe6308..17e68cf 100644 --- a/labs/views.py +++ b/labs/views.py @@ -152,6 +152,8 @@ def get(self,request): return Response(results) class CreateMachine(APIView): + permission_classes = [IsAuthenticated] + @staticmethod def check_pod(pod_name,pod_namespace): try: @@ -223,7 +225,7 @@ def post(self,request,pk): http=client.V1HTTPIngressRuleValue( paths=[ client.V1HTTPIngressPath( - path=f"/{request.user.username.strip('/')}/{machine.id}", + path=f"/{hashlib.md5(request.user.username.encode()).hexdigest()}/{machine.id}", path_type="Prefix", backend=client.V1IngressBackend( service=client.V1IngressServiceBackend( From 6eeeffaf0847d6516d60f9c07e447bfbe1f0bfde Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Sat, 26 Apr 2025 18:49:45 +0100 Subject: [PATCH 26/32] Should be working --- k8s/config/middleware/stripprefix.yaml | 9 +++++++++ labs/views.py | 7 +++++-- setup-k8s.sh | 1 + 3 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 k8s/config/middleware/stripprefix.yaml diff --git a/k8s/config/middleware/stripprefix.yaml b/k8s/config/middleware/stripprefix.yaml new file mode 100644 index 0000000..56b9caa --- /dev/null +++ b/k8s/config/middleware/stripprefix.yaml @@ -0,0 +1,9 @@ +apiVersion: traefik.io/v1alpha1 +kind: Middleware +metadata: + name: stripprefix + namespace: lab-pods +spec: + stripPrefixRegex: + regex: + - "/[a-z0-9]+/[0-9]+/" diff --git a/labs/views.py b/labs/views.py index 17e68cf..b6f63f5 100644 --- a/labs/views.py +++ b/labs/views.py @@ -216,7 +216,10 @@ def post(self,request,pk): api_version="networking.k8s.io/v1", kind="Ingress", metadata=client.V1ObjectMeta( - name=pod_name+'-ingress' + name=pod_name+'-ingress', + annotations={ + "traefik.ingress.kubernetes.io/router.middlewares": "lab-pods-stripprefix@kubernetescrd" + } ), spec=client.V1IngressSpec( rules=[ @@ -225,7 +228,7 @@ def post(self,request,pk): http=client.V1HTTPIngressRuleValue( paths=[ client.V1HTTPIngressPath( - path=f"/{hashlib.md5(request.user.username.encode()).hexdigest()}/{machine.id}", + path=f"/{request.user.username}/{machine.id}", path_type="Prefix", backend=client.V1IngressBackend( service=client.V1IngressServiceBackend( diff --git a/setup-k8s.sh b/setup-k8s.sh index 9aa80a5..49fa255 100755 --- a/setup-k8s.sh +++ b/setup-k8s.sh @@ -26,5 +26,6 @@ kubectl apply -f k8s/config/configmaps kubectl apply -f k8s/config/namespaces kubectl apply -f k8s/config/serviceaccounts kubectl apply -f k8s/config/roles +kubectl apply -f k8s/config/middleware kubectl apply -f k8s/postgres kubectl apply -f k8s/backend \ No newline at end of file From 21e5c92399a1580d8921495fd81bf906e5117d19 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Sat, 26 Apr 2025 21:01:59 +0100 Subject: [PATCH 27/32] things --- k8s/backend/backend-deployment.yaml | 1 + labs/views.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/k8s/backend/backend-deployment.yaml b/k8s/backend/backend-deployment.yaml index 55cc196..efac8bb 100644 --- a/k8s/backend/backend-deployment.yaml +++ b/k8s/backend/backend-deployment.yaml @@ -21,6 +21,7 @@ spec: containers: - image: 1nitramfs/cybermaster-backend:latest name: cybermaster-backend + imagePullPolicy: Never resources: {} imagePullSecrets: - name: pull-secret diff --git a/labs/views.py b/labs/views.py index b6f63f5..3feb117 100644 --- a/labs/views.py +++ b/labs/views.py @@ -191,7 +191,7 @@ def post(self,request,pk): ), spec=client.V1PodSpec( containers=[container], - active_deadline_seconds=14400 + active_deadline_seconds=120#14400 ) ) From cf74e397a8158d587e5c75abc45ea48ac6631710 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Sat, 26 Apr 2025 21:30:09 +0100 Subject: [PATCH 28/32] Machine feature finished --- k8s/config/roles/podmanager-role.yaml | 11 +++++++ labs/views.py | 47 ++++++++++++++++++++------- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/k8s/config/roles/podmanager-role.yaml b/k8s/config/roles/podmanager-role.yaml index 780edd7..a38108c 100644 --- a/k8s/config/roles/podmanager-role.yaml +++ b/k8s/config/roles/podmanager-role.yaml @@ -27,4 +27,15 @@ rules: - get - list - update + - watch +- apiGroups: + - "batch" + resources: + - jobs + verbs: + - create + - delete + - get + - list + - update - watch \ No newline at end of file diff --git a/labs/views.py b/labs/views.py index 3feb117..269afa0 100644 --- a/labs/views.py +++ b/labs/views.py @@ -16,6 +16,8 @@ config.load_incluster_config() v1=client.CoreV1Api() +batch_v1=client.BatchV1Api() +net_v1=client.NetworkingV1Api(client.ApiClient()) #lab views class LabList(APIView): @@ -173,6 +175,7 @@ def post(self,request,pk): if self.check_pod(pod_name,'lab-pods'): return Response({ 'pod_name':pod_name, + 'link':request.build_absolute_uri(f"/{request.user.username}/{machine.id}/"), 'status':'running' }) @@ -182,24 +185,46 @@ def post(self,request,pk): ports=[client.V1ContainerPort(container_port=8443)] ) - pod=client.V1Pod( - api_version="v1", - kind="Pod", + job=client.V1Job( + api_version="batch/v1", + kind="Job", metadata=client.V1ObjectMeta( name=pod_name, - labels={'app':pod_name,'lab':str(machine.id),'user':request.user.username} ), - spec=client.V1PodSpec( - containers=[container], - active_deadline_seconds=120#14400 + spec=client.V1JobSpec( + ttl_seconds_after_finished=1, + active_deadline_seconds=120, + template=client.V1PodTemplateSpec( + metadata=client.V1ObjectMeta( + name=pod_name, + labels={'app':pod_name,'lab':str(machine.id),'user':request.user.username} + ), + spec=client.V1PodSpec( + containers=[container], + restart_policy="Never" + ) + ) ) ) + created_job=batch_v1.create_namespaced_job(namespace="lab-pods",body=job) + + owner_refrences=[ + client.V1OwnerReference( + api_version="batch/v1", + kind="Job", + name=pod_name, + uid=created_job.metadata.uid, + controller=True, + block_owner_deletion=True + ) + ] service=client.V1Service( api_version="v1", kind="Service", metadata=client.V1ObjectMeta( - name=pod_name+'-service' + name=pod_name+'-service', + owner_references=owner_refrences ), spec=client.V1ServiceSpec( selector={'app':pod_name}, @@ -217,6 +242,7 @@ def post(self,request,pk): kind="Ingress", metadata=client.V1ObjectMeta( name=pod_name+'-ingress', + owner_references=owner_refrences, annotations={ "traefik.ingress.kubernetes.io/router.middlewares": "lab-pods-stripprefix@kubernetescrd" } @@ -246,12 +272,11 @@ def post(self,request,pk): ) ) - v1.create_namespaced_pod(namespace="lab-pods",body=pod) v1.create_namespaced_service(namespace="lab-pods",body=service) - v1_api=client.NetworkingV1Api(client.ApiClient()) - v1_api.create_namespaced_ingress(namespace="lab-pods",body=ingress) + net_v1.create_namespaced_ingress(namespace="lab-pods",body=ingress) return Response({ 'pod_name':pod_name, + 'link':request.build_absolute_uri(f"/{request.user.username}/{machine.id}/"), 'status':'created' }) From b54bacc41c0eae017d6cdfd073a5fffb98388c7b Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Sat, 26 Apr 2025 22:42:37 +0100 Subject: [PATCH 29/32] Features ready! --- certs/views.py | 9 +- exams/admin.py | 33 +- ..._answer_examattempt_cert_ready_and_more.py | 43 ++ exams/migrations/0004_alter_exam_duration.py | 18 + exams/models.py | 96 ++++- exams/serializers.py | 43 +- exams/views.py | 371 +++++++++++++++--- k8s/backend/backend-deployment.yaml | 1 - k8s/backend/backend-ingress.yaml | 9 +- ..._lab_is_machine_lab_port_delete_machine.py | 31 ++ 10 files changed, 569 insertions(+), 85 deletions(-) create mode 100644 exams/migrations/0003_answer_examattempt_cert_ready_and_more.py create mode 100644 exams/migrations/0004_alter_exam_duration.py create mode 100644 labs/migrations/0002_lab_image_lab_is_machine_lab_port_delete_machine.py diff --git a/certs/views.py b/certs/views.py index 7420cef..15366f8 100644 --- a/certs/views.py +++ b/certs/views.py @@ -9,6 +9,7 @@ from certs.serializers import CertificationSerializer from .models import * from courses.models import Enrollment +from exams.models import * import segno from playwright.sync_api import sync_playwright @@ -34,8 +35,14 @@ class GetCert(APIView): def get(self,request,pk): course = get_object_or_404(Course,pk=pk) enrollment=get_object_or_404(Enrollment,user=request.user,course=course) + exam=get_object_or_404(Exam,course=course) + passing_attempt=ExamAttempt.objects.filter( + user=request.user, + exam=exam, + cert_ready=True + ).first() - if enrollment.cert_ready: + if passing_attempt: cert, _ = Certification.objects.get_or_create( user=request.user, course = course, diff --git a/exams/admin.py b/exams/admin.py index 41e06a4..e3cec98 100644 --- a/exams/admin.py +++ b/exams/admin.py @@ -1,13 +1,36 @@ from django.contrib import admin +from django import forms from .models import * +class ExamAdminForm(forms.ModelForm): + class Meta: + model = Exam + fields = '__all__' + help_texts = { + 'duration': 'Format: HH:MM:SS (e.g., 01:30:00 for 1 hour and 30 minutes)', + } + class ChoiceInline(admin.TabularInline): - model=MCQChoice + model = MCQChoice class QuestionAdmin(admin.ModelAdmin): - inlines=[ChoiceInline] + inlines = [ChoiceInline] + +class ExamAdmin(admin.ModelAdmin): + form = ExamAdminForm + list_display = ('title', 'course', 'duration', 'passing_score') + +class ExamAttemptAdmin(admin.ModelAdmin): + list_display = ('user', 'exam', 'score', 'started_at', 'is_finished', 'cert_ready') + list_filter = ('is_finished', 'cert_ready') + search_fields = ('user__username', 'exam__title') + +class AnswerAdmin(admin.ModelAdmin): + list_display = ('exam_attempt', 'question', 'is_correct') + list_filter = ('is_correct',) -admin.site.register(Exam) -admin.site.register(ExamAttempt) +admin.site.register(Exam, ExamAdmin) +admin.site.register(ExamAttempt, ExamAttemptAdmin) admin.site.register(MCQChoice) -admin.site.register(Question,QuestionAdmin) \ No newline at end of file +admin.site.register(Question, QuestionAdmin) +admin.site.register(Answer, AnswerAdmin) \ No newline at end of file diff --git a/exams/migrations/0003_answer_examattempt_cert_ready_and_more.py b/exams/migrations/0003_answer_examattempt_cert_ready_and_more.py new file mode 100644 index 0000000..8eaf9e6 --- /dev/null +++ b/exams/migrations/0003_answer_examattempt_cert_ready_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.6 on 2025-04-26 20:57 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('exams', '0002_examattempt_is_finished_alter_examattempt_duration_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Answer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('submitted_answer', models.CharField(max_length=255)), + ('is_correct', models.BooleanField(default=False)), + ], + ), + migrations.AddField( + model_name='examattempt', + name='cert_ready', + field=models.BooleanField(default=False), + ), + migrations.AddConstraint( + model_name='examattempt', + constraint=models.UniqueConstraint(condition=models.Q(('cert_ready', True)), fields=('user', 'exam', 'cert_ready'), name='unique_passing_attempt'), + ), + migrations.AddField( + model_name='answer', + name='exam_attempt', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='exams.examattempt'), + ), + migrations.AddField( + model_name='answer', + name='question', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='student_answers', to='exams.question'), + ), + ] diff --git a/exams/migrations/0004_alter_exam_duration.py b/exams/migrations/0004_alter_exam_duration.py new file mode 100644 index 0000000..8934e33 --- /dev/null +++ b/exams/migrations/0004_alter_exam_duration.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.6 on 2025-04-26 21:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('exams', '0003_answer_examattempt_cert_ready_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='exam', + name='duration', + field=models.DurationField(help_text='Format: HH:MM:SS (e.g., 01:30:00 for 1 hour and 30 minutes)'), + ), + ] diff --git a/exams/models.py b/exams/models.py index 0011b6e..8dc26c3 100644 --- a/exams/models.py +++ b/exams/models.py @@ -1,42 +1,96 @@ from django.db import models from courses.models import Course from users.models import CustomUser +from datetime import timedelta +from django.utils import timezone class Exam(models.Model): - title=models.CharField(max_length=255) - course=models.OneToOneField(Course,related_name='exams',on_delete=models.CASCADE) - duration=models.DurationField() - passing_score=models.IntegerField(default=50) + title = models.CharField(max_length=255) + course = models.OneToOneField(Course, related_name='exams', on_delete=models.CASCADE) + duration = models.DurationField(help_text="Format: HH:MM:SS (e.g., 01:30:00 for 1 hour and 30 minutes)") + passing_score = models.IntegerField(default=50) def __str__(self): return self.title + @staticmethod + def parse_duration(duration_str): + """ + Helper method to parse duration string into timedelta + Usage: duration = Exam.parse_duration("01:30:00") + """ + if not duration_str: + return None + + try: + parts = duration_str.split(':') + if len(parts) == 3: + hours, minutes, seconds = map(int, parts) + return timedelta(hours=hours, minutes=minutes, seconds=seconds) + elif len(parts) == 2: + minutes, seconds = map(int, parts) + return timedelta(minutes=minutes, seconds=seconds) + else: + seconds = int(parts[0]) + return timedelta(seconds=seconds) + except (ValueError, TypeError): + return None + class Question(models.Model): - exam=models.ForeignKey(Exam,related_name="questions",on_delete=models.CASCADE) - prompt=models.CharField(max_length=255) - is_mcq=models.BooleanField(default=True) - correct_answer=models.CharField(max_length=255,blank=True,null=True) - order_index=models.IntegerField() + exam = models.ForeignKey(Exam, related_name="questions", on_delete=models.CASCADE) + prompt = models.CharField(max_length=255) + is_mcq = models.BooleanField(default=True) + correct_answer = models.CharField(max_length=255, blank=True, null=True) + order_index = models.IntegerField() def __str__(self): return self.prompt class MCQChoice(models.Model): - question=models.ForeignKey(Question,related_name="choices",on_delete=models.CASCADE) - content=models.CharField(max_length=255) - is_correct=models.BooleanField(default=False) - order_index=models.IntegerField() + question = models.ForeignKey(Question, related_name="choices", on_delete=models.CASCADE) + content = models.CharField(max_length=255) + is_correct = models.BooleanField(default=False) + order_index = models.IntegerField() def __str__(self): return self.content class ExamAttempt(models.Model): - exam=models.ForeignKey(Exam,related_name="runs",on_delete=models.CASCADE) - user=models.ForeignKey(CustomUser,related_name="exam_runs",on_delete=models.CASCADE) - score=models.IntegerField(default=0) - started_at=models.DateTimeField(auto_now_add=True) - is_finished=models.BooleanField(default=False) - duration=models.DurationField(blank=True,null=True) + exam = models.ForeignKey(Exam, related_name="runs", on_delete=models.CASCADE) + user = models.ForeignKey(CustomUser, related_name="exam_runs", on_delete=models.CASCADE) + score = models.IntegerField(default=0) + started_at = models.DateTimeField(auto_now_add=True) + is_finished = models.BooleanField(default=False) + duration = models.DurationField(blank=True, null=True) + cert_ready = models.BooleanField(default=False) + + def __str__(self): + return f"{self.user.username} - {self.exam.title}" + + def has_timed_out(self): + """Check if the exam attempt has exceeded the allowed duration""" + if not self.exam.duration: + return False + + now = timezone.now() + elapsed_time = now - self.started_at + return elapsed_time > self.exam.duration + + class Meta: + # Add unique constraint to ensure one passing attempt per user per exam + constraints = [ + models.UniqueConstraint( + fields=['user', 'exam', 'cert_ready'], + condition=models.Q(cert_ready=True), + name='unique_passing_attempt' + ) + ] - # def __str__(self): - # return self.title \ No newline at end of file +class Answer(models.Model): + exam_attempt = models.ForeignKey(ExamAttempt, related_name="answers", on_delete=models.CASCADE) + question = models.ForeignKey(Question, related_name="student_answers", on_delete=models.CASCADE) + submitted_answer = models.CharField(max_length=255) + is_correct = models.BooleanField(default=False) + + def __str__(self): + return f"Answer to {self.question.prompt[:20]}..." \ No newline at end of file diff --git a/exams/serializers.py b/exams/serializers.py index ebe5536..e6c0ae2 100644 --- a/exams/serializers.py +++ b/exams/serializers.py @@ -3,23 +3,44 @@ class MCQChoiceSerializer(serializers.ModelSerializer): class Meta: - model=MCQChoice - fields=['question','content','order_index'] + model = MCQChoice + fields = ['id', 'question', 'content', 'order_index'] + +class AnswerSerializer(serializers.ModelSerializer): + class Meta: + model = Answer + fields = ['exam_attempt', 'question', 'submitted_answer', 'is_correct'] class ExamAttemptSerializer(serializers.ModelSerializer): class Meta: - model=ExamAttempt - fields=['exam','user','score','started_at','duration'] + model = ExamAttempt + fields = ['id', 'exam', 'user', 'score', 'started_at', 'duration', 'is_finished', 'cert_ready'] class QuestionSerializer(serializers.ModelSerializer): - choices=MCQChoiceSerializer(many=True) + choices = MCQChoiceSerializer(many=True, read_only=True) + class Meta: - model=Question - fields=['exam','prompt','is_mcq','correct_answer','choices','order_index'] + model = Question + fields = ['id', 'exam', 'prompt', 'is_mcq', 'order_index', 'choices'] + # Don't expose correct_answer in the API for security class ExamSerializer(serializers.ModelSerializer): - questions=QuestionSerializer(many=True) + questions_count = serializers.SerializerMethodField() + user_has_passed = serializers.SerializerMethodField() + class Meta: - model=Exam - fields=['title','course','duration','passing_score','questions'] - + model = Exam + fields = ['id', 'title', 'course', 'duration', 'passing_score', 'questions_count', 'user_has_passed'] + + def get_questions_count(self, obj): + return obj.questions.count() + + def get_user_has_passed(self, obj): + request = self.context.get('request') + if request and hasattr(request, 'user') and request.user.is_authenticated: + return ExamAttempt.objects.filter( + user=request.user, + exam=obj, + cert_ready=True + ).exists() + return False \ No newline at end of file diff --git a/exams/views.py b/exams/views.py index b075480..e35197a 100644 --- a/exams/views.py +++ b/exams/views.py @@ -1,5 +1,7 @@ -from datetime import datetime +from datetime import datetime, timezone from django.shortcuts import get_object_or_404 +from django.utils import timezone as django_timezone +from django.db import IntegrityError from rest_framework.permissions import IsAuthenticated from rest_framework import generics from rest_framework.views import APIView @@ -10,67 +12,346 @@ from .serializers import * class ExamList(generics.ListAPIView): - # permission_classes=[IsAuthenticated] - queryset=Exam.objects.all() - serializer_class=ExamSerializer + permission_classes = [IsAuthenticated] + queryset = Exam.objects.all() + serializer_class = ExamSerializer + + def get_serializer_context(self): + context = super().get_serializer_context() + return context class GetExam(generics.RetrieveAPIView): - # permission_classes=[IsAuthenticated] - queryset=Exam.objects.all() - serializer_class=ExamSerializer + permission_classes = [IsAuthenticated] + queryset = Exam.objects.all() + serializer_class = ExamSerializer + + def get_serializer_context(self): + context = super().get_serializer_context() + return context class QuestionList(generics.ListAPIView): - # permission_classes=[IsAuthenticated] - serializer_class=QuestionSerializer + permission_classes = [IsAuthenticated] + serializer_class = QuestionSerializer def get_queryset(self): - return get_object_or_404(Exam,pk=self.kwargs.get("pk")).questions.all() + exam = get_object_or_404(Exam, pk=self.kwargs.get("pk")) + + # Check if user has an active exam attempt + active_attempt = ExamAttempt.objects.filter( + user=self.request.user, + exam=exam, + is_finished=False + ).exists() + + if not active_attempt: + return Question.objects.none() # Return empty queryset if no active attempt + + return exam.questions.all().order_by('order_index') + + def list(self, request, *args, **kwargs): + exam = get_object_or_404(Exam, pk=self.kwargs.get("pk")) + + # Check for active attempt + try: + exam_attempt = get_object_or_404( + ExamAttempt, + user=request.user, + exam=exam, + is_finished=False + ) + + # Check if the exam attempt has timed out + if exam_attempt.has_timed_out(): + # Automatically finish the exam + now = django_timezone.now() + duration = now - exam_attempt.started_at + + # Update exam attempt + exam_attempt.is_finished = True + exam_attempt.duration = duration + exam_attempt.cert_ready = exam_attempt.score >= exam.passing_score + exam_attempt.save() + + return Response({ + 'detail': 'Exam time limit exceeded. Your attempt has been automatically submitted.', + 'final_score': exam_attempt.score, + 'passed': exam_attempt.cert_ready + }, status=status.HTTP_403_FORBIDDEN) + + except: + return Response({ + "detail": "You must start the exam before accessing questions." + }, status=status.HTTP_403_FORBIDDEN) + + queryset = self.get_queryset() + + if not queryset.exists(): + return Response({ + "detail": "You must start the exam before accessing questions." + }, status=status.HTTP_403_FORBIDDEN) + + return super().list(request, *args, **kwargs) class GetQuestion(generics.RetrieveAPIView): - # permission_classes=[IsAuthenticated] - serializer_class=QuestionSerializer - lookup_field="order_index" + permission_classes = [IsAuthenticated] + serializer_class = QuestionSerializer + lookup_field = "order_index" def get_queryset(self): - exam=get_object_or_404(Exam,pk=self.kwargs.get("pk")) + exam = get_object_or_404(Exam, pk=self.kwargs.get("pk")) + + # Check if user has an active exam attempt + active_attempt = ExamAttempt.objects.filter( + user=self.request.user, + exam=exam, + is_finished=False + ).exists() + + if not active_attempt: + return Question.objects.none() # Return empty queryset if no active attempt + return exam.questions.all() - -class StartExam(APIView): - def post(self,request,pk): - exam=get_object_or_404(Exam,pk=pk) - exam_attempt=ExamAttempt.objects.filter(user=self.request.user,exam=exam).first() - if exam_attempt: - if not exam_attempt.is_finished: - raise ValidationError({'details':'an unfinished exam attempt already exists'}) + def retrieve(self, request, *args, **kwargs): + try: + # Get the exam + exam = get_object_or_404(Exam, pk=self.kwargs.get("pk")) - serializer=ExamAttemptSerializer( - exam_attempt, - data=request.data, - partial=True + # Get the active attempt + exam_attempt = get_object_or_404( + ExamAttempt, + user=request.user, + exam=exam, + is_finished=False ) + + # Check if the exam attempt has timed out + if exam_attempt.has_timed_out(): + # Automatically finish the exam + now = django_timezone.now() + duration = now - exam_attempt.started_at + + # Update exam attempt + exam_attempt.is_finished = True + exam_attempt.duration = duration + exam_attempt.cert_ready = exam_attempt.score >= exam.passing_score + exam_attempt.save() + + return Response({ + 'detail': 'Exam time limit exceeded. Your attempt has been automatically submitted.', + 'final_score': exam_attempt.score, + 'passed': exam_attempt.cert_ready + }, status=status.HTTP_403_FORBIDDEN) + + instance = self.get_object() + return super().retrieve(request, *args, **kwargs) + except: + return Response({ + "detail": "You must start the exam before accessing questions." + }, status=status.HTTP_403_FORBIDDEN) - if serializer.is_valid(): - serializer.save(is_finished=False,duration=0,started_at=datetime.datetime.now(datetime.timezone.uct)) - return Response(serializer.data,status=status.HTTP_200_OK) +class StartExam(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + exam = get_object_or_404(Exam, pk=pk) - - serializer=ExamAttemptSerializer( - data=request.data + # Check if user has already passed this exam + already_passed = ExamAttempt.objects.filter( + user=request.user, + exam=exam, + cert_ready=True + ).exists() + + if already_passed: + return Response({ + 'detail': 'You have already passed this exam and cannot make new attempts.', + 'cert_ready': True + }, status=status.HTTP_400_BAD_REQUEST) + + # Check for unfinished attempts + exam_attempt = ExamAttempt.objects.filter( + user=request.user, + exam=exam, + is_finished=False + ).first() + + if exam_attempt: + # Check if the exam attempt has timed out + if exam_attempt.has_timed_out(): + # Automatically finish the exam + now = django_timezone.now() + duration = now - exam_attempt.started_at + + # Update exam attempt + exam_attempt.is_finished = True + exam_attempt.duration = duration + exam_attempt.cert_ready = exam_attempt.score >= exam.passing_score + exam_attempt.save() + + # Create new attempt after the timed-out one is closed + new_attempt = ExamAttempt.objects.create( + user=request.user, + exam=exam, + score=0, + is_finished=False, + cert_ready=False + ) + + serializer = ExamAttemptSerializer(new_attempt) + return Response(serializer.data, status=status.HTTP_201_CREATED) + else: + return Response({ + 'detail': 'An unfinished exam attempt already exists', + 'attempt_id': exam_attempt.id + }, status=status.HTTP_400_BAD_REQUEST) + + # Create new attempt + exam_attempt = ExamAttempt.objects.create( + user=request.user, + exam=exam, + score=0, + is_finished=False, + cert_ready=False ) - - if serializer.is_valid(): - serializer.save( - user=request.user, - exam=exam - ) - return Response(serializer.data,status=status.HTTP_201_CREATED) - return Response(serializer.errors,status=status.HTTP_400_BAD_REQUEST) + serializer = ExamAttemptSerializer(exam_attempt) + return Response(serializer.data, status=status.HTTP_201_CREATED) -class SubmitAnswer(generics.CreateAPIView): - pass - -class FinishExam(generics.UpdateAPIView): - pass \ No newline at end of file +class SubmitAnswer(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + exam = get_object_or_404(Exam, pk=pk) + + # Validate request data + if 'question_id' not in request.data: + return Response({'detail': 'question_id is required'}, status=status.HTTP_400_BAD_REQUEST) + + if 'answer' not in request.data: + return Response({'detail': 'answer is required'}, status=status.HTTP_400_BAD_REQUEST) + + # Get current exam attempt + exam_attempt = get_object_or_404( + ExamAttempt, + user=request.user, + exam=exam, + is_finished=False + ) + + # Check if the exam attempt has timed out + if exam_attempt.has_timed_out(): + # Automatically finish the exam + now = django_timezone.now() + duration = now - exam_attempt.started_at + + # Update exam attempt + exam_attempt.is_finished = True + exam_attempt.duration = duration + exam_attempt.cert_ready = exam_attempt.score >= exam.passing_score + exam_attempt.save() + + return Response({ + 'detail': 'Exam time limit exceeded. Your attempt has been automatically submitted.', + 'final_score': exam_attempt.score, + 'passed': exam_attempt.cert_ready + }, status=status.HTTP_403_FORBIDDEN) + + # Get question + question_id = request.data.get('question_id') + question = get_object_or_404(Question, id=question_id, exam=exam) + + # Check if question has already been answered + existing_answer = Answer.objects.filter( + exam_attempt=exam_attempt, + question=question + ).first() + + if existing_answer: + return Response({ + 'detail': 'This question has already been answered.', + 'is_correct': existing_answer.is_correct, + 'current_score': exam_attempt.score + }, status=status.HTTP_400_BAD_REQUEST) + + # Check answer + is_correct = False + user_answer = request.data.get('answer') + + if question.is_mcq: + # For MCQ, answer should be the choice id + try: + selected_choice = MCQChoice.objects.get(id=user_answer, question=question) + is_correct = selected_choice.is_correct + # Store the choice ID as the submitted answer + submitted_answer = str(selected_choice.id) + except MCQChoice.DoesNotExist: + return Response({'detail': 'Invalid choice'}, status=status.HTTP_400_BAD_REQUEST) + else: + # For text answers, compare with correct_answer + is_correct = user_answer.lower().strip() == question.correct_answer.lower().strip() + submitted_answer = user_answer + + # Create answer (no update since we're preventing re-answers) + answer = Answer.objects.create( + exam_attempt=exam_attempt, + question=question, + submitted_answer=submitted_answer, + is_correct=is_correct + ) + + # Update score + # Calculate total score based on all correct answers + correct_answers = Answer.objects.filter(exam_attempt=exam_attempt, is_correct=True).count() + total_questions = Question.objects.filter(exam=exam).count() + + if total_questions > 0: # Avoid division by zero + new_score = (correct_answers / total_questions) * 100 + exam_attempt.score = new_score + exam_attempt.save() + + return Response({ + 'is_correct': is_correct, + 'current_score': exam_attempt.score + }, status=status.HTTP_200_OK) + +class FinishExam(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + exam = get_object_or_404(Exam, pk=pk) + exam_attempt = get_object_or_404( + ExamAttempt, + user=request.user, + exam=exam, + is_finished=False + ) + + # Calculate duration + now = django_timezone.now() + duration = now - exam_attempt.started_at + + # Determine if passed + passed = exam_attempt.score >= exam.passing_score + + # Update exam attempt + exam_attempt.is_finished = True + exam_attempt.duration = duration + exam_attempt.cert_ready = passed + + try: + exam_attempt.save() + except IntegrityError: + # This would only happen if someone else marked a passing attempt + # in the time between our check and save (highly unlikely) + return Response({ + 'detail': 'Another passing attempt was recorded. This attempt cannot be saved.' + }, status=status.HTTP_400_BAD_REQUEST) + + # Return results + serializer = ExamAttemptSerializer(exam_attempt) + data = serializer.data + data['passed'] = passed + + return Response(data, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/k8s/backend/backend-deployment.yaml b/k8s/backend/backend-deployment.yaml index efac8bb..55cc196 100644 --- a/k8s/backend/backend-deployment.yaml +++ b/k8s/backend/backend-deployment.yaml @@ -21,7 +21,6 @@ spec: containers: - image: 1nitramfs/cybermaster-backend:latest name: cybermaster-backend - imagePullPolicy: Never resources: {} imagePullSecrets: - name: pull-secret diff --git a/k8s/backend/backend-ingress.yaml b/k8s/backend/backend-ingress.yaml index 488c78b..192f4de 100644 --- a/k8s/backend/backend-ingress.yaml +++ b/k8s/backend/backend-ingress.yaml @@ -1,4 +1,4 @@ -apiVersion: networking.k8s.io/v1 +apiVersion: networking.k8s.inetworking.k8s.ioo/v1 kind: Ingress metadata: creationTimestamp: null @@ -29,5 +29,12 @@ spec: number: 8000 path: /static pathType: Prefix + - backend: + service: + name: cybermaster-backend + port: + number: 8000 + path: /media + pathType: Prefix status: loadBalancer: {} diff --git a/labs/migrations/0002_lab_image_lab_is_machine_lab_port_delete_machine.py b/labs/migrations/0002_lab_image_lab_is_machine_lab_port_delete_machine.py new file mode 100644 index 0000000..847ec32 --- /dev/null +++ b/labs/migrations/0002_lab_image_lab_is_machine_lab_port_delete_machine.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.6 on 2025-04-26 20:57 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('labs', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='lab', + name='image', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name='lab', + name='is_machine', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='lab', + name='port', + field=models.IntegerField(default=8443), + ), + migrations.DeleteModel( + name='Machine', + ), + ] From 0a6378a1878f82f7d2a2bce026df96a7f5306508 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Sun, 27 Apr 2025 00:06:42 +0100 Subject: [PATCH 30/32] Feat: Add swagger docs --- categories/serializers.py | 8 +++- categories/views.py | 21 ++++++++++ certs/views.py | 44 +++++++++++++++++++++ chat/views.py | 41 +++++++++++++++++++- courses/serializers.py | 16 ++++++-- courses/views.py | 80 +++++++++++++++++++++++++++++++++------ exams/views.py | 63 ++++++++++++++++++++++++++++-- labs/serializers.py | 4 +- labs/views.py | 78 +++++++++++++++++++++++++++++++++++++- ranking/views.py | 7 ++++ 10 files changed, 337 insertions(+), 25 deletions(-) diff --git a/categories/serializers.py b/categories/serializers.py index f0a97d1..f587a87 100644 --- a/categories/serializers.py +++ b/categories/serializers.py @@ -13,7 +13,9 @@ class Meta: def get_labs(self,obj): request = self.context.get('request') - expand = request.query_params.get('expand','').split(',') + expand=[] + if request: + expand = request.query_params.get('expand','').split(',') if 'labs' in expand: return LabSerializer(obj.labs.all(),many=True, read_only=True,context={'request':request}).data @@ -22,7 +24,9 @@ def get_labs(self,obj): def get_courses(self,obj): request = self.context.get('request') - expand = request.query_params.get('expand','').split(',') + expand=[] + if request: + expand = request.query_params.get('expand','').split(',') if 'courses' in expand: return CourseSerializer(obj.courses.all(),many=True, read_only=True,context={'request':request}).data diff --git a/categories/views.py b/categories/views.py index 498c8a2..2569397 100644 --- a/categories/views.py +++ b/categories/views.py @@ -3,8 +3,24 @@ from .models import * from .serializers import * from django.shortcuts import get_object_or_404 +from drf_yasg.utils import swagger_auto_schema +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi class CategoryListCreate(APIView): + @swagger_auto_schema( + operation_description="List Categories", + manual_parameters=[ + openapi.Parameter( + 'name', + openapi.IN_QUERY, + description="Filter categories by name (optional)", + type=openapi.TYPE_STRING + ) + ], + responses={200: CategorySerializer(many=True)} + ) + def get(self, request, format=None): name = request.query_params.get('name', None) categories = Category.objects.all() @@ -16,6 +32,11 @@ def get(self, request, format=None): return Response(serializer.data) class CategoryDetail(APIView): + @swagger_auto_schema( + operation_description="View Category Details", + responses={200: CategorySerializer} + ) + def get(self, request, pk, format=None): category = get_object_or_404(Category,pk=pk) serializer = CategorySerializer(category, context={'request': request}) diff --git a/certs/views.py b/certs/views.py index 15366f8..d41dc9c 100644 --- a/certs/views.py +++ b/certs/views.py @@ -12,6 +12,8 @@ from exams.models import * import segno from playwright.sync_api import sync_playwright +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi def render_pdf(html, buffer): with sync_playwright() as p: @@ -32,6 +34,22 @@ def render_pdf(html, buffer): class GetCert(APIView): permission_classes=[IsAuthenticated] + @swagger_auto_schema( + operation_description="Get or create a certification PDF for a user who passed the exam", + responses={ + 200: CertificationSerializer, + 400: 'Certification not ready' + }, + manual_parameters=[ + openapi.Parameter( + 'pk', + openapi.IN_PATH, + description="Primary key of the course", + type=openapi.TYPE_INTEGER + ) + ] + ) + def get(self,request,pk): course = get_object_or_404(Course,pk=pk) enrollment=get_object_or_404(Enrollment,user=request.user,course=course) @@ -82,6 +100,32 @@ def get(self,request,pk): }, status=400) class Validate(APIView): + @swagger_auto_schema( + operation_description="Validate a certification by its certificate ID", + responses={ + 200: openapi.Response( + description="Validation result", + examples={ + "application/json": { + "status": "Valid", + "cert_id": "123456", + "username": "user1", + "course": "Python Basics", + "date": "2025-04-20" + } + } + ), + 404: 'Certificate not found' + }, + manual_parameters=[ + openapi.Parameter( + 'id', + openapi.IN_PATH, + description="Certificate ID to validate", + type=openapi.TYPE_STRING + ) + ] + ) def get(self,request,id): cert = Certification.objects.filter(cert_id=id).first() if not cert: diff --git a/chat/views.py b/chat/views.py index b1b9044..26da7cd 100644 --- a/chat/views.py +++ b/chat/views.py @@ -6,12 +6,19 @@ from .models import Conversation, Message from .serializers import ConversationSerializer, MessageSerializer from .utils.gemini import generate_with_gemini +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi User = get_user_model() class GeminiAssistantAPI(APIView): permission_classes = [IsAuthenticated] - + @swagger_auto_schema( + operation_summary="Get or Create Active Conversation", + operation_description="Retrieves the user's active conversation or creates one if none exists.", + responses={200: ConversationSerializer()} + ) + def get(self, request): # Get or create active conversation conversation, created = Conversation.objects.get_or_create( @@ -27,6 +34,25 @@ def get(self, request): serializer = ConversationSerializer(conversation) return Response(serializer.data) + @swagger_auto_schema( + operation_summary="Send Message to Gemini Assistant", + operation_description="Sends a user message to the Gemini assistant and receives a generated response.", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'message': openapi.Schema(type=openapi.TYPE_STRING, description='User message to the assistant'), + 'max_tokens': openapi.Schema(type=openapi.TYPE_INTEGER, description='Maximum tokens for the model response', default=300), + }, + required=['message'] + ), + responses={200: openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'response': openapi.Schema(type=openapi.TYPE_STRING, description='Assistant\'s reply'), + 'conversation_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID of the active conversation'), + } + )} + ) def post(self, request): user_message = request.data.get('message', '') max_tokens = request.data.get('max_tokens', 300) @@ -77,7 +103,18 @@ def post(self, request): }) class ResetConversationAPI(APIView): permission_classes = [IsAuthenticated] - + @swagger_auto_schema( + operation_summary="Reset Conversation", + operation_description="Deletes all active conversations and associated messages for the authenticated user.", + responses={200: openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'status': openapi.Schema(type=openapi.TYPE_STRING, example='success'), + 'message': openapi.Schema(type=openapi.TYPE_STRING, example='Conversation and messages deleted successfully'), + } + )} + ) + def post(self, request): conversations = Conversation.objects.filter(user=request.user, is_active=True) diff --git a/courses/serializers.py b/courses/serializers.py index f99361c..95432db 100644 --- a/courses/serializers.py +++ b/courses/serializers.py @@ -13,7 +13,9 @@ class Meta: def get_fields(self): request=self.context.get('request') - expand=request.query_params.get('expand','').split(',') + expand=[] + if request: + expand=request.query_params.get('expand','').split(',') fields=super().get_fields() if 'link' not in expand: @@ -57,7 +59,9 @@ def get_markdown(self,obj): def get_labs(self,obj): request=self.context.get('request') - expand=request.query_params.get('expand','').split(',') + expand=[] + if request: + expand=request.query_params.get('expand','').split(',') if 'labs' in expand: return LabSerializer(obj.labs.all(),many=True,read_only=True,context={'request':request}).data @@ -79,7 +83,9 @@ class Meta: def get_lessons(self,obj): request=self.context.get('request') - expand=request.query_params.get('expand','').split(',') + expand=[] + if request: + expand=request.query_params.get('expand','').split(',') if 'lessons' in expand: return LessonSerializer(obj.lessons.all(),many=True,read_only=True,context={'request':request}).data @@ -97,7 +103,9 @@ class Meta: def get_chapters(self,obj): request=self.context.get('request') - expand=request.query_params.get('expand','').split(',') + expand=[] + if request: + expand=request.query_params.get('expand','').split(',') if 'chapters' in expand: return ChapterSerializer(obj.chapters.all(),many=True,read_only=True,context={'request':request}).data diff --git a/courses/views.py b/courses/views.py index 2ced1d9..505a8f7 100644 --- a/courses/views.py +++ b/courses/views.py @@ -8,33 +8,51 @@ from rest_framework.permissions import IsAuthenticated from .models import * from .serializers import * +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi class CourseList(APIView): - # permission_classes=[IsAuthenticated] + permission_classes=[IsAuthenticated] + @swagger_auto_schema( + operation_description="List all available courses", + responses={200: CourseSerializer(many=True)} + ) def get(self,request): course_list=Course.objects.all() return Response(CourseSerializer(course_list,many=True,context={'request':request}).data) class GetCourse(APIView): - # permission_classes=[IsAuthenticated] - + permission_classes=[IsAuthenticated] + @swagger_auto_schema( + operation_description="Retrieve a single course by its ID", + responses={200: CourseSerializer} + ) + def get(self,request,pk): course=get_object_or_404(Course,pk=pk) serializer=CourseSerializer(course,context={'request':request}) return Response(serializer.data) class ChapterList(APIView): - # permission_classes=[IsAuthenticated] - + permission_classes=[IsAuthenticated] + @swagger_auto_schema( + operation_description="List all chapters for a given course", + responses={200: ChapterSerializer(many=True)} + ) + def get(self,request,pk): course=get_object_or_404(Course,pk=pk) chapters=course.chapters.all() return Response(ChapterSerializer(chapters,many=True,context={'request':request}).data) class GetChapter(APIView): - # permission_classes=[IsAuthenticated] - + permission_classes=[IsAuthenticated] + @swagger_auto_schema( + operation_description="Retrieve a specific chapter by course and chapter index", + responses={200: ChapterSerializer} + ) + def get(self,request,pk,index): course=get_object_or_404(Course,pk=pk) try: @@ -45,8 +63,12 @@ def get(self,request,pk,index): return Response(serializer.data) class LessonList(APIView): - # permission_classes=[IsAuthenticated] - + permission_classes=[IsAuthenticated] + @swagger_auto_schema( + operation_description="List all lessons for a given chapter of a course", + responses={200: LessonSerializer(many=True)} + ) + def get(self,request,pk,index): course=get_object_or_404(Course,pk=pk) try: @@ -57,8 +79,12 @@ def get(self,request,pk,index): return Response(LessonSerializer(lessons,many=True,context={'request':request}).data) class GetLesson(APIView): - # permission_classes=[IsAuthenticated] - + permission_classes=[IsAuthenticated] + @swagger_auto_schema( + operation_description="Retrieve a specific lesson by course, chapter index, and lesson index", + responses={200: LessonSerializer} + ) + def get(self,request,pk,index,lessonindex): course=get_object_or_404(Course,pk=pk) try: @@ -77,6 +103,11 @@ def get(self,request,pk,index,lessonindex): class Enroll(generics.CreateAPIView): serializer_class=EnrollmentsSerializer permission_classes=[IsAuthenticated] + @swagger_auto_schema( + operation_description="Enroll in a course", + request_body=EnrollmentsSerializer, + responses={201: EnrollmentsSerializer} + ) def perform_create(self, serializer): course = get_object_or_404(Course,pk=self.kwargs['pk']) @@ -90,6 +121,11 @@ def perform_create(self, serializer): class CompleteLesson(generics.UpdateAPIView): serializer_class=EnrollmentsSerializer permission_classes=[IsAuthenticated] + @swagger_auto_schema( + operation_description="Mark a lesson as completed and update progress", + request_body=EnrollmentsSerializer, + responses={200: EnrollmentsSerializer} + ) def get_queryset(self): return Enrollment.objects.filter(user=self.request.user,course__pk=self.kwargs['pk']) @@ -128,6 +164,28 @@ def perform_update(self, serializer): ) class Search(APIView): + @swagger_auto_schema( + operation_description="Search courses, chapters, and lessons by query string", + manual_parameters=[ + openapi.Parameter( + 'query', + openapi.IN_QUERY, + description="Search query", + type=openapi.TYPE_STRING + ) + ], + responses={200: openapi.Response( + description="Search results", + examples={ + "application/json": { + "courses": [{"id": 1, "title": "Course 1", "description": "desc", "category": 1, "category__name": "Cat 1"}], + "chapters": [{"id": 1, "title": "Chapter 1", "description": "desc"}], + "lessons": [{"id": 1, "title": "Lesson 1", "description": "desc"}] + } + } + )} + ) + def get(self,request): query = request.GET.get('query',None) if not query: diff --git a/exams/views.py b/exams/views.py index e35197a..282fef0 100644 --- a/exams/views.py +++ b/exams/views.py @@ -10,11 +10,18 @@ from rest_framework import status from .models import * from .serializers import * +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi class ExamList(generics.ListAPIView): permission_classes = [IsAuthenticated] queryset = Exam.objects.all() serializer_class = ExamSerializer + @swagger_auto_schema( + operation_summary="List all available exams", + operation_description="Returns a list of exams that the authenticated user can attempt.", + responses={200: ExamSerializer(many=True)} + ) def get_serializer_context(self): context = super().get_serializer_context() @@ -24,7 +31,12 @@ class GetExam(generics.RetrieveAPIView): permission_classes = [IsAuthenticated] queryset = Exam.objects.all() serializer_class = ExamSerializer - + @swagger_auto_schema( + operation_summary="Retrieve exam details", + operation_description="Fetches detailed information about a specific exam.", + responses={200: ExamSerializer()} + ) + def get_serializer_context(self): context = super().get_serializer_context() return context @@ -32,6 +44,11 @@ def get_serializer_context(self): class QuestionList(generics.ListAPIView): permission_classes = [IsAuthenticated] serializer_class = QuestionSerializer + @swagger_auto_schema( + operation_summary="List questions for an exam attempt", + operation_description="Returns the list of questions for an active exam attempt. If no active attempt exists, returns 403.", + responses={200: QuestionSerializer(many=True), 403: 'No active attempt or exam timeout'} + ) def get_queryset(self): exam = get_object_or_404(Exam, pk=self.kwargs.get("pk")) @@ -96,6 +113,11 @@ class GetQuestion(generics.RetrieveAPIView): permission_classes = [IsAuthenticated] serializer_class = QuestionSerializer lookup_field = "order_index" + @swagger_auto_schema( + operation_summary="Retrieve a specific question by order index", + operation_description="Fetch a single question during an active exam attempt, using its order index within the exam.", + responses={200: QuestionSerializer(), 403: 'No active attempt or exam timeout'} + ) def get_queryset(self): exam = get_object_or_404(Exam, pk=self.kwargs.get("pk")) @@ -152,7 +174,15 @@ def retrieve(self, request, *args, **kwargs): class StartExam(APIView): permission_classes = [IsAuthenticated] - + @swagger_auto_schema( + operation_summary="Start a new exam attempt", + operation_description="Starts a new exam attempt for the user, unless an active attempt already exists or the user already passed.", + responses={ + 201: ExamAttemptSerializer(), + 400: 'Already passed or unfinished attempt exists' + } + ) + def post(self, request, pk): exam = get_object_or_404(Exam, pk=pk) @@ -221,7 +251,24 @@ def post(self, request, pk): class SubmitAnswer(APIView): permission_classes = [IsAuthenticated] - + @swagger_auto_schema( + operation_summary="Submit an answer to a question", + operation_description="Submits an answer for a specific question during an active exam attempt.", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=["question_id", "answer"], + properties={ + "question_id": openapi.Schema(type=openapi.TYPE_INTEGER), + "answer": openapi.Schema(type=openapi.TYPE_STRING) + } + ), + responses={ + 200: 'Answer submitted successfully', + 400: 'Invalid input or already answered', + 403: 'Exam timed out' + } + ) + def post(self, request, pk): exam = get_object_or_404(Exam, pk=pk) @@ -318,7 +365,15 @@ def post(self, request, pk): class FinishExam(APIView): permission_classes = [IsAuthenticated] - + @swagger_auto_schema( + operation_summary="Finish an exam attempt", + operation_description="Manually finishes the current active exam attempt and calculates the final score.", + responses={ + 200: ExamAttemptSerializer(), + 400: 'Another passing attempt was recorded' + } + ) + def post(self, request, pk): exam = get_object_or_404(Exam, pk=pk) exam_attempt = get_object_or_404( diff --git a/labs/serializers.py b/labs/serializers.py index 16420eb..ee7613b 100644 --- a/labs/serializers.py +++ b/labs/serializers.py @@ -27,7 +27,9 @@ class Meta: def get_files(self,obj): request=self.context.get('request') - expand=request.query_params.get('expand','').split(',') + expand=[] + if request: + expand=request.query_params.get('expand','').split(',') if 'files' in expand: return LabResourceFileSerializer(obj.files.all(),many=True,context={'request':request}).data diff --git a/labs/views.py b/labs/views.py index 269afa0..3e9a21a 100644 --- a/labs/views.py +++ b/labs/views.py @@ -13,6 +13,8 @@ from kubernetes import client,config from kubernetes.client.rest import ApiException import hashlib +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi config.load_incluster_config() v1=client.CoreV1Api() @@ -21,6 +23,12 @@ #lab views class LabList(APIView): + @swagger_auto_schema( + operation_summary="List all labs", + operation_description="Returns a list of all labs. Can be filtered by difficulty via query parameter.", + responses={200: LabSerializer(many=True)} + ) + def get(self, request, format=None): difficulty = request.query_params.get('difficulty', None) labs = Lab.objects.all() @@ -32,6 +40,12 @@ def get(self, request, format=None): return Response(serializer.data) class LabDetail(APIView): + @swagger_auto_schema( + operation_summary="Retrieve lab details", + operation_description="Fetch detailed information for a specific lab.", + responses={200: LabSerializer()} + ) + def get(self, request, pk, format=None): lab = get_object_or_404(Lab,pk=pk) serializer = LabSerializer(lab, context={'request': request}) @@ -40,6 +54,11 @@ def get(self, request, pk, format=None): #labresources views class LabResourceFileList(APIView): parser_classes = [MultiPartParser, FormParser] # Allow file uploads + @swagger_auto_schema( + operation_summary="List resource files for a lab", + operation_description="Returns all resource files attached to a specific lab.", + responses={200: LabResourceFileSerializer(many=True)} + ) def get(self, request, lab_id, format=None): lab_files = LabResourceFile.objects.filter(resource_id=lab_id) @@ -47,6 +66,12 @@ def get(self, request, lab_id, format=None): return Response(serializer.data) class LabResourceFileDetail(APIView): + @swagger_auto_schema( + operation_summary="Retrieve a lab resource file", + operation_description="Fetch details for a single resource file attached to a lab.", + responses={200: LabResourceFileSerializer()} + ) + def get(self, request, pk, format=None): lab_file = get_object_or_404(LabResourceFile,pk=pk) serializer = LabResourceFileSerializer(lab_file, context={'request': request}) @@ -55,6 +80,23 @@ def get(self, request, pk, format=None): class SubmitFlag(APIView): permission_classes = [IsAuthenticated] + @swagger_auto_schema( + operation_summary="Submit a flag for a lab", + operation_description="Submit a flag to solve a lab. If correct, points and badges are awarded.", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=["flag"], + properties={ + "flag": openapi.Schema(type=openapi.TYPE_STRING) + } + ), + responses={ + 200: 'Flag submission result with earned badges and progress', + 400: 'Incorrect flag or bad input', + 404: 'Lab not found' + } + ) + def post(self, request, lab_id, format=None): try: lab = Lab.objects.get(id=lab_id) @@ -120,6 +162,11 @@ def post(self, request, lab_id, format=None): class SolveProgress(APIView): permission_classes = [IsAuthenticated] + @swagger_auto_schema( + operation_summary="Get user's solve progress", + operation_description="Returns the user's current solve progress across categories (offensive and defensive).", + responses={200: 'Progress percentages'} + ) def get(self, request): user = request.user @@ -129,6 +176,11 @@ def get(self, request): class SolvedLabList(APIView): permission_classes = [IsAuthenticated] + @swagger_auto_schema( + operation_summary="List solved labs", + operation_description="Returns the list of labs that the authenticated user has solved.", + responses={200: SolvedLabSerializer(many=True)} + ) def get(self, request, format=None): solved_labs = SolvedLab.objects.filter(user=request.user) @@ -137,6 +189,11 @@ def get(self, request, format=None): class BadgeList(APIView): permission_classes = [IsAuthenticated] + @swagger_auto_schema( + operation_summary="List earned badges", + operation_description="Returns all badges earned by the authenticated user.", + responses={200: BadgeSerializer(many=True)} + ) def get(self, request, format=None): badges = Badge.objects.filter(user=request.user) @@ -144,6 +201,15 @@ def get(self, request, format=None): return Response(serializer.data) class Search(APIView): + @swagger_auto_schema( + operation_summary="Search labs", + operation_description="Search for labs by title, description, author, or category name.", + manual_parameters=[ + openapi.Parameter('query', openapi.IN_QUERY, description="Search keyword", type=openapi.TYPE_STRING) + ], + responses={200: 'Search results list', 400: 'Query parameter missing'} + ) + def get(self,request): query = request.GET.get('query',None) if not query: @@ -165,6 +231,16 @@ def check_pod(pod_name,pod_namespace): if e.status == 404: return False raise + @swagger_auto_schema( + operation_summary="Create a machine instance for a lab", + operation_description="Creates a machine instance (Kubernetes pod) for the user to work on a machine-type lab.", + responses={ + 200: 'Machine already running', + 201: 'Machine created successfully', + 400: 'Lab is not a machine', + 404: 'Lab not found' + } + ) def post(self,request,pk): machine=get_object_or_404(Lab,pk=pk) @@ -279,4 +355,4 @@ def post(self,request,pk): 'pod_name':pod_name, 'link':request.build_absolute_uri(f"/{request.user.username}/{machine.id}/"), 'status':'created' - }) + },status=status.HTTP_201_CREATED) diff --git a/ranking/views.py b/ranking/views.py index aa092fa..3094316 100644 --- a/ranking/views.py +++ b/ranking/views.py @@ -2,8 +2,15 @@ from rest_framework.response import Response from users.models import Profile from users.serializers import * +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi class Leaderboard(APIView): + @swagger_auto_schema( + operation_summary="Leaderboard", + operation_description="Get a list of user profiles ordered by points.", + responses={200: ProfileSerializer(many=True)} + ) def get(self, request, format=None): profiles = Profile.objects.all().order_by('-points') serializer = ProfileSerializer(profiles, many=True, context={'request': request}) From 15db0fb71b1e097fd3a6269256ed7e4ea26b6cd2 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Sun, 27 Apr 2025 00:10:33 +0100 Subject: [PATCH 31/32] small fix --- categories/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/categories/views.py b/categories/views.py index 2569397..41801f6 100644 --- a/categories/views.py +++ b/categories/views.py @@ -4,7 +4,6 @@ from .serializers import * from django.shortcuts import get_object_or_404 from drf_yasg.utils import swagger_auto_schema -from drf_yasg.utils import swagger_auto_schema from drf_yasg import openapi class CategoryListCreate(APIView): From 860f01a850ccb31e5200699207dae63a3f74aba4 Mon Sep 17 00:00:00 2001 From: Mehloul Mohamed Date: Sun, 27 Apr 2025 00:19:09 +0100 Subject: [PATCH 32/32] Final touch --- labs/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/labs/views.py b/labs/views.py index 3e9a21a..823d3de 100644 --- a/labs/views.py +++ b/labs/views.py @@ -269,7 +269,7 @@ def post(self,request,pk): ), spec=client.V1JobSpec( ttl_seconds_after_finished=1, - active_deadline_seconds=120, + active_deadline_seconds=600, template=client.V1PodTemplateSpec( metadata=client.V1ObjectMeta( name=pod_name,