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/Backend/settings.py b/Backend/settings.py index 9d4f5d1..83f01df 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', @@ -118,6 +121,7 @@ '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'), '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/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..41801f6 100644 --- a/categories/views.py +++ b/categories/views.py @@ -3,8 +3,23 @@ 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 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 +31,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 7420cef..d41dc9c 100644 --- a/certs/views.py +++ b/certs/views.py @@ -9,8 +9,11 @@ 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 +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi def render_pdf(html, buffer): with sync_playwright() as p: @@ -31,11 +34,33 @@ 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) + 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, @@ -75,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/admin.py b/exams/admin.py index 8c38f3f..e3cec98 100644 --- a/exams/admin.py +++ b/exams/admin.py @@ -1,3 +1,36 @@ from django.contrib import admin +from django import forms +from .models import * -# Register your models here. +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 + +class QuestionAdmin(admin.ModelAdmin): + 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, ExamAdmin) +admin.site.register(ExamAttempt, ExamAttemptAdmin) +admin.site.register(MCQChoice) +admin.site.register(Question, QuestionAdmin) +admin.site.register(Answer, AnswerAdmin) \ 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/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/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 71a8362..8dc26c3 100644 --- a/exams/models.py +++ b/exams/models.py @@ -1,3 +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 -# 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(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() + + 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) + 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' + ) + ] + +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 new file mode 100644 index 0000000..e6c0ae2 --- /dev/null +++ b/exams/serializers.py @@ -0,0 +1,46 @@ +from rest_framework import serializers +from .models import * + +class MCQChoiceSerializer(serializers.ModelSerializer): + class Meta: + 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 = ['id', 'exam', 'user', 'score', 'started_at', 'duration', 'is_finished', 'cert_ready'] + +class QuestionSerializer(serializers.ModelSerializer): + choices = MCQChoiceSerializer(many=True, read_only=True) + + class Meta: + 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_count = serializers.SerializerMethodField() + user_has_passed = serializers.SerializerMethodField() + + class Meta: + 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/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..282fef0 100644 --- a/exams/views.py +++ b/exams/views.py @@ -1,3 +1,412 @@ -from django.shortcuts import render +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 +from rest_framework.exceptions import ValidationError +from rest_framework.response import Response +from rest_framework import status +from .models import * +from .serializers import * +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi -# Create your views here. +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() + return context + +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 + +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")) + + # 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" + @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")) + + # 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() + + def retrieve(self, request, *args, **kwargs): + try: + # Get the exam + exam = get_object_or_404(Exam, pk=self.kwargs.get("pk")) + + # 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) + +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) + + # 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 + ) + + serializer = ExamAttemptSerializer(exam_attempt) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + +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) + + # 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] + @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( + 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 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/backend/backend-ingress.yaml b/k8s/backend/backend-ingress.yaml new file mode 100644 index 0000000..192f4de --- /dev/null +++ b/k8s/backend/backend-ingress.yaml @@ -0,0 +1,40 @@ +apiVersion: networking.k8s.inetworking.k8s.ioo/v1 +kind: Ingress +metadata: + creationTimestamp: null + name: cybermaster-backend-ingress +spec: + rules: + - host: cybermaster.tech + http: + paths: + - backend: + service: + name: cybermaster-backend + port: + 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 + - backend: + service: + name: cybermaster-backend + port: + number: 8000 + path: /media + pathType: Prefix +status: + loadBalancer: {} diff --git a/k8s/backend/backend-service.yaml b/k8s/backend/backend-service.yaml index 8360a77..71c97d6 100644 --- a/k8s/backend/backend-service.yaml +++ b/k8s/backend/backend-service.yaml @@ -7,12 +7,12 @@ metadata: name: cybermaster-backend spec: ports: - - name: 80-8000 - port: 80 + - name: 8000-8000 + port: 8000 protocol: TCP targetPort: 8000 selector: app: cybermaster-backend - type: NodePort + type: ClusterIP status: loadBalancer: {} 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/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/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..f8e48e1 --- /dev/null +++ b/k8s/config/roles/bind-podmanager.yaml @@ -0,0 +1,14 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + creationTimestamp: null + name: bind-pod-manager + namespace: lab-pods +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..a38108c --- /dev/null +++ b/k8s/config/roles/podmanager-role.yaml @@ -0,0 +1,41 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + creationTimestamp: null + namespace: lab-pods + name: pod-manager +rules: +- apiGroups: + - "" + resources: + - pods + - services + verbs: + - create + - delete + - get + - list + - update + - watch +- apiGroups: + - "networking.k8s.io" + resources: + - ingresses + verbs: + - create + - delete + - get + - list + - update + - watch +- apiGroups: + - "batch" + resources: + - jobs + 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/admin.py b/labs/admin.py index 2dc8c52..b3c7928 100644 --- a/labs/admin.py +++ b/labs/admin.py @@ -1,7 +1,6 @@ from django.contrib import admin from .models import * - admin.site.register(Lab) admin.site.register(LabResourceFile) admin.site.register(SolvedLab) 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_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', + ), + ] 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..c76e298 100644 --- a/labs/models.py +++ b/labs/models.py @@ -13,12 +13,15 @@ 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) - #lesson = models.ForeignKey(Lesson, 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) difficulty = models.CharField(max_length=50, choices=DIFFICULTY_CHOICES, default='easy') @@ -26,8 +29,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 +39,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) @@ -58,4 +56,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}" + 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 d8e444f..ee7613b 100644 --- a/labs/serializers.py +++ b/labs/serializers.py @@ -21,13 +21,15 @@ class LabSerializer(serializers.ModelSerializer): class Meta: model = Lab fields = [ - 'id', 'title', 'description', 'points', 'author', 'category', 'category_name', + 'id', 'is_machine','title', 'description', 'points','lesson', 'author', 'category', 'category_name', 'connection_info', 'difficulty', 'files' ] 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 @@ -42,9 +44,7 @@ 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'] + fields = ['id', 'user', 'badge_name', 'earned_on'] \ No newline at end of file diff --git a/labs/urls.py b/labs/urls.py index 4879509..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'), - ] \ No newline at end of file diff --git a/labs/views.py b/labs/views.py index e55b89e..823d3de 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 @@ -9,12 +10,25 @@ 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 +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() +batch_v1=client.BatchV1Api() +net_v1=client.NetworkingV1Api(client.ApiClient()) - - #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() @@ -26,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}) @@ -34,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) @@ -41,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}) @@ -49,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) @@ -111,46 +159,57 @@ 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] + @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 progress = calculate_solve_percentages(user) return Response(progress, status=200) - 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) serializer = SolvedLabSerializer(solved_labs, many=True) return Response(serializer.data) - - - - - - 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) serializer = BadgeSerializer(badges, many=True) 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: @@ -160,3 +219,140 @@ def get(self,request): return Response(results) +class CreateMachine(APIView): + permission_classes = [IsAuthenticated] + + @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 + @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) + if not machine.is_machine: + return Response({'detail':'lab is not a machine'},status=status.HTTP_400_BAD_REQUEST) + + pod_name=f"machine-{machine.id}-{hashlib.md5(request.user.username.encode()).hexdigest()}" + 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' + }) + + container=client.V1Container( + name=pod_name, + image=machine.image, + ports=[client.V1ContainerPort(container_port=8443)] + ) + + job=client.V1Job( + api_version="batch/v1", + kind="Job", + metadata=client.V1ObjectMeta( + name=pod_name, + ), + spec=client.V1JobSpec( + ttl_seconds_after_finished=1, + active_deadline_seconds=600, + 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', + owner_references=owner_refrences + ), + spec=client.V1ServiceSpec( + selector={'app':pod_name}, + ports=[ + client.V1ServicePort( + port=machine.port, + target_port=machine.port + ) + ], + ) + ) + + ingress=client.V1Ingress( + api_version="networking.k8s.io/v1", + kind="Ingress", + metadata=client.V1ObjectMeta( + name=pod_name+'-ingress', + owner_references=owner_refrences, + annotations={ + "traefik.ingress.kubernetes.io/router.middlewares": "lab-pods-stripprefix@kubernetescrd" + } + ), + 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_service(namespace="lab-pods",body=service) + 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' + },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}) 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..49fa255 100755 --- a/setup-k8s.sh +++ b/setup-k8s.sh @@ -6,21 +6,26 @@ 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/config/middleware +kubectl apply -f k8s/postgres +kubectl apply -f k8s/backend \ 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