diff --git a/.env.example b/.env.example index 9cccd1ee..2a7aaa4f 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,35 @@ # ============================= # Email config -# For development or quick testing use console as the email backend +# This line is the "magic" fix. It tells Django to print emails to the terminal +# instead of trying to send them through a real server. EMAIL_BACKEND="django.core.mail.backends.console.EmailBackend" -# For production use smtp, uncomment the below variable -# EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend" + EMAIL_HOST="smtp.gmail.com" EMAIL_PORT=587 EMAIL_USE_TLS=True EMAIL_FROM_ADDRESS="SkyLearn " -EMAIL_HOST_USER="" -EMAIL_HOST_PASSWORD="" + +# These two must exist for your config('NAME') calls to work +EMAIL_HOST_USER="youremail@example.com" +EMAIL_HOST_PASSWORD="yourpassword" # ============================= -# Other +# Database Neon - PostgresSQL +DB_ENGINE=django.db.backends.postgresql +DB_NAME=neondb +DB_USER=neondb_owner +DB_PASSWORD=npg_B0Hujnsh8zEv +DB_HOST=ep-gentle-shape-a1unb4h2-pooler.ap-southeast-1.aws.neon.tech +DB_PORT=5432 +# ============================= +# Cloudinary Storage - Cloudinary +CLOUDINARY_CLOUD_NAME=ddr6xe1vn +CLOUDINARY_API_KEY=781526684325383 +CLOUDINARY_API_SECRET=F1IcPCOHWkItagYigXGm-3j6VHc + +# ============================= +# Other DEBUG=True -SECRET_KEY="" \ No newline at end of file +SECRET_KEY="any-random-string-here" diff --git a/accounts/forms.py b/accounts/forms.py index 3958ee0d..63fd264c 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -191,15 +191,6 @@ class StudentAddForm(UserCreationForm): ), ) - level = forms.CharField( - widget=forms.Select( - choices=LEVEL, - attrs={ - "class": "browser-default custom-select form-control", - }, - ), - ) - program = forms.ModelChoiceField( queryset=Program.objects.all(), widget=forms.Select( @@ -266,7 +257,7 @@ def save(self, commit=True): user.save() Student.objects.create( student=user, - level=self.cleaned_data.get("level"), + level="Bachelor", program=self.cleaned_data.get("program"), ) diff --git a/accounts/views.py b/accounts/views.py index 910df567..f44b3a8f 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -96,7 +96,7 @@ def profile(request): student = get_object_or_404(Student, student__pk=request.user.id) parent = Parent.objects.filter(student=student).first() courses = TakenCourse.objects.filter( - student__student__id=request.user.id, course__level=student.level + student__student__id=request.user.id ) context.update( { @@ -146,7 +146,7 @@ def profile_single(request, user_id): elif user.is_student: student = get_object_or_404(Student, student__pk=user_id) courses = TakenCourse.objects.filter( - student__student__id=user_id, course__level=student.level + student__student__id=user_id ) context.update( { diff --git a/ai_core/__init__.py b/ai_core/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ai_core/admin.py b/ai_core/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/ai_core/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/ai_core/apps.py b/ai_core/apps.py new file mode 100644 index 00000000..c327b0d7 --- /dev/null +++ b/ai_core/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AiCoreConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'ai_core' diff --git a/ai_core/migrations/0001_initial.py b/ai_core/migrations/0001_initial.py new file mode 100644 index 00000000..84a01780 --- /dev/null +++ b/ai_core/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 4.0.8 on 2026-02-20 17:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='AIGeneration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('topic', models.CharField(blank=True, max_length=255, null=True)), + ('prompt', models.TextField(blank=True, null=True)), + ('document_file', models.FileField(blank=True, null=True, upload_to='ai_docs/')), + ('html_content', models.TextField(blank=True, null=True)), + ('status', models.CharField(choices=[('PENDING', 'Pending'), ('PROCESSING', 'Processing'), ('SUCCESS', 'Success'), ('FAILED', 'Failed')], default='PENDING', max_length=20)), + ('error_message', models.TextField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + ] diff --git a/ai_core/migrations/0002_documentchunk.py b/ai_core/migrations/0002_documentchunk.py new file mode 100644 index 00000000..81cc1243 --- /dev/null +++ b/ai_core/migrations/0002_documentchunk.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.28 on 2026-03-01 09:04 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("course", "0006_remove_course_level"), + ("ai_core", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="DocumentChunk", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "chunk_index", + models.IntegerField( + help_text="Zero-based position of this chunk within the document." + ), + ), + ( + "content", + models.TextField(help_text="Plain text content of this chunk."), + ), + ( + "embedding", + models.JSONField( + blank=True, + help_text="Embedding vector as a JSON list of floats.", + null=True, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "upload", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="chunks", + to="course.upload", + ), + ), + ], + options={ + "ordering": ["upload", "chunk_index"], + "unique_together": {("upload", "chunk_index")}, + }, + ), + ] diff --git a/ai_core/migrations/__init__.py b/ai_core/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/ai_core/models.py b/ai_core/models.py new file mode 100644 index 00000000..ebe67ce3 --- /dev/null +++ b/ai_core/models.py @@ -0,0 +1,58 @@ +from django.db import models + +# Create your models here. +class AIGeneration(models.Model): + topic = models.CharField(max_length=255, null=True, blank=True) + prompt = models.TextField(null=True, blank=True) + + document_file = models.FileField(upload_to="ai_docs/", null=True, blank=True) + + html_content = models.TextField(null=True, blank=True) + + status = models.CharField( + max_length=20, + choices=[ + ("PENDING", "Pending"), + ("PROCESSING", "Processing"), + ("SUCCESS", "Success"), + ("FAILED", "Failed"), + ], + default="PENDING" + ) + + error_message = models.TextField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + +class DocumentChunk(models.Model): + """Cached text chunk with embedding vector from an uploaded document. + + Each Upload can have many chunks. Chunks are created lazily when a + document is first selected as RAG context for lesson generation. + Deleting the parent Upload cascades to remove all its chunks. + """ + + upload = models.ForeignKey( + "course.Upload", + on_delete=models.CASCADE, + related_name="chunks", + ) + chunk_index = models.IntegerField( + help_text="Zero-based position of this chunk within the document." + ) + content = models.TextField( + help_text="Plain text content of this chunk." + ) + embedding = models.JSONField( + null=True, + blank=True, + help_text="Embedding vector as a JSON list of floats.", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["upload", "chunk_index"] + unique_together = ["upload", "chunk_index"] + + def __str__(self) -> str: + return f"Chunk {self.chunk_index} of Upload #{self.upload_id}" \ No newline at end of file diff --git a/ai_core/services/__init__.py b/ai_core/services/__init__.py new file mode 100644 index 00000000..37b7f8dc --- /dev/null +++ b/ai_core/services/__init__.py @@ -0,0 +1,7 @@ +from .base import BaseLLMService +from .gemini import GeminiService +from .lesson import LessonService +from .quiz import QuizGenerationError, QuizService +from .chatbot import ChatbotService +from .document_parser import extract_text, chunk_text +from .rag import embed_and_store_chunks, search_chunks diff --git a/ai_core/services/base.py b/ai_core/services/base.py new file mode 100644 index 00000000..89025b7d --- /dev/null +++ b/ai_core/services/base.py @@ -0,0 +1,32 @@ +"""Base LLM service interface.""" +import logging +from abc import ABC, abstractmethod +from langchain_core.messages import HumanMessage, SystemMessage + +logger = logging.getLogger("ai_core") + + +class BaseLLMService(ABC): + """Abstract base for all LLM providers.""" + + @abstractmethod + def _build_llm(self): + """Return a LangChain chat model instance.""" + pass + + def __init__(self): + self.llm = self._build_llm() + logger.info(f"{self.__class__.__name__} initialized") + + def chat(self, message: str, system_prompt: str = None) -> str: + """Send a message and return the response text.""" + messages = [] + if system_prompt: + messages.append(SystemMessage(content=system_prompt)) + messages.append(HumanMessage(content=message)) + + logger.info(f"[REQUEST] {message[:100]}...") + response = self.llm.invoke(messages) + logger.info(f"[RESPONSE] {response.content[:200]}...") + + return response.content \ No newline at end of file diff --git a/ai_core/services/chatbot.py b/ai_core/services/chatbot.py new file mode 100644 index 00000000..74f5f2eb --- /dev/null +++ b/ai_core/services/chatbot.py @@ -0,0 +1,63 @@ +"""Chatbot service – conversational AI assistant for the LMS.""" +import logging +from .base import BaseLLMService + +logger = logging.getLogger("ai_core") + +CHATBOT_SYSTEM_PROMPT = ( + "You are SkyLearn AI Assistant, a friendly and knowledgeable virtual tutor " + "for an online learning management system called SkyLearn.\n\n" + "RULES:\n" + "1. Answer in the SAME language the user writes in.\n" + "2. Be concise but helpful. Use short paragraphs.\n" + "3. You can help with:\n" + " - Explaining academic concepts across all subjects\n" + " - Answering questions about courses, quizzes, grades\n" + " - Giving study tips and learning strategies\n" + " - Helping with assignment or homework guidance (do NOT give direct answers)\n" + " - General questions about using the SkyLearn platform\n" + "4. Format responses in plain text. Use bullet points or numbered lists when helpful.\n" + "5. If the user asks something harmful, unethical, or completely unrelated to " + "education, politely decline.\n" + "6. Keep responses under 300 words unless more detail is explicitly requested.\n" + "7. Be encouraging and supportive in tone." +) + + +class ChatbotService: + """Conversational assistant powered by an LLM provider.""" + + def __init__(self, llm_service: BaseLLMService): + self.llm_service = llm_service + + def reply(self, message: str, history: list[dict] | None = None) -> str: + """Generate a chatbot reply. + + Args: + message: The user's latest message. + history: Optional list of previous messages, each with + ``{"role": "user"|"assistant", "content": "..."}``. + Used to build multi-turn context. + + Returns: + The assistant's reply as plain text. + """ + prompt = self._build_prompt(message, history) + return self.llm_service.chat(prompt, system_prompt=CHATBOT_SYSTEM_PROMPT) + + # ------------------------------------------------------------------ + + @staticmethod + def _build_prompt(message: str, history: list[dict] | None) -> str: + """Build the user message with optional conversation history.""" + if not history: + return message + + # Include recent history (last 10 turns) to stay within token limits + recent = history[-10:] + parts = [] + for turn in recent: + role = "User" if turn["role"] == "user" else "Assistant" + parts.append(f"{role}: {turn['content']}") + parts.append(f"User: {message}") + return "\n".join(parts) diff --git a/ai_core/services/document_parser.py b/ai_core/services/document_parser.py new file mode 100644 index 00000000..3fca5d0f --- /dev/null +++ b/ai_core/services/document_parser.py @@ -0,0 +1,124 @@ +"""Document text extraction and chunking utilities. + +Supports PDF, DOCX, and PPTX file formats. Extracted text is split into +overlapping chunks suitable for embedding and semantic search. +""" + +import logging +from pathlib import Path + +logger = logging.getLogger("ai_core") + + +def extract_text(file_path: str) -> str: + """Extract plain text content from a document file. + + Args: + file_path: Absolute path to the document file. + + Returns: + Extracted text as a single string. Returns empty string + if the file format is unsupported or extraction fails. + """ + ext = Path(file_path).suffix.lower() + + try: + if ext == ".pdf": + return _extract_pdf(file_path) + elif ext in (".doc", ".docx"): + return _extract_docx(file_path) + elif ext == ".pptx": + return _extract_pptx(file_path) + else: + logger.warning("Unsupported file format for text extraction: %s", ext) + return "" + except Exception as e: + logger.error("Failed to extract text from %s: %s", file_path, e) + return "" + + +def chunk_text( + text: str, chunk_size: int = 1000, overlap: int = 50 +) -> list[str]: + """Split text into overlapping chunks for embedding. + + Args: + text: The full text to split. + chunk_size: Maximum number of characters per chunk. + overlap: Number of overlapping characters between consecutive chunks. + + Returns: + A list of text chunks. Returns empty list if input text is blank. + """ + if not text or not text.strip(): + return [] + + chunks: list[str] = [] + start = 0 + text_length = len(text) + + while start < text_length: + end = min(start + chunk_size, text_length) + chunk = text[start:end].strip() + if chunk: + chunks.append(chunk) + start += chunk_size - overlap + + logger.info("Split text into %d chunks (size=%d, overlap=%d)", len(chunks), chunk_size, overlap) + return chunks + + +# --------------------------------------------------------------------------- +# Private extraction helpers +# --------------------------------------------------------------------------- + + +def _extract_pdf(file_path: str) -> str: + """Extract text from a PDF file using PyPDF2.""" + from PyPDF2 import PdfReader + + reader = PdfReader(file_path) + pages: list[str] = [] + for page in reader.pages: + page_text = page.extract_text() + if page_text: + pages.append(page_text) + + text = "\n".join(pages) + logger.info("Extracted %d characters from PDF (%d pages)", len(text), len(reader.pages)) + return text + + +def _extract_docx(file_path: str) -> str: + """Extract text from a DOCX file using python-docx.""" + from docx import Document + + doc = Document(file_path) + paragraphs = [p.text for p in doc.paragraphs if p.text.strip()] + + text = "\n".join(paragraphs) + logger.info("Extracted %d characters from DOCX (%d paragraphs)", len(text), len(paragraphs)) + return text + + +def _extract_pptx(file_path: str) -> str: + """Extract text from a PPTX file using python-pptx.""" + from pptx import Presentation + + prs = Presentation(file_path) + slides_text: list[str] = [] + + for slide in prs.slides: + slide_parts: list[str] = [] + for shape in slide.shapes: + if shape.has_text_frame: + for paragraph in shape.text_frame.paragraphs: + paragraph_text = paragraph.text.strip() + if paragraph_text: + slide_parts.append(paragraph_text) + if slide_parts: + slides_text.append("\n".join(slide_parts)) + + text = "\n\n".join(slides_text) + logger.info("Extracted %d characters from PPTX (%d slides)", len(text), len(prs.slides)) + return text diff --git a/ai_core/services/gemini.py b/ai_core/services/gemini.py new file mode 100644 index 00000000..6be70d7a --- /dev/null +++ b/ai_core/services/gemini.py @@ -0,0 +1,14 @@ +"""Gemini LLM provider.""" +from decouple import config +from langchain_google_genai import ChatGoogleGenerativeAI +from .base import BaseLLMService + + +class GeminiService(BaseLLMService): + """Google Gemini provider.""" + + def _build_llm(self): + return ChatGoogleGenerativeAI( + google_api_key=config("GOOGLE_API_KEY"), + model="gemini-3-flash-preview", + ) \ No newline at end of file diff --git a/ai_core/services/lesson.py b/ai_core/services/lesson.py new file mode 100644 index 00000000..0cc4a0c4 --- /dev/null +++ b/ai_core/services/lesson.py @@ -0,0 +1,44 @@ +"""Lesson generation service.""" +import logging +from .base import BaseLLMService + +logger = logging.getLogger("ai_core") + +LESSON_SYSTEM_PROMPT = ( + "You are a professional university lecturer. " + "Your task is to create detailed lessons in the same language as the topic or context provided. " + "Return pure HTML only (no , , or tags). " + "Use the following tags to present content clearly and structurally: " + "

,

,

,

    ,
      ,
    1. , , ,
      , . " + "Do not use markdown or any format other than HTML. " + "Do not include any inline CSS style attributes, especially font-family or font-size." +) + + +class LessonService: + """Generate lesson content using a given LLM provider.""" + + def __init__(self, llm_service: BaseLLMService): + self.llm_service = llm_service + + def generate(self, topic: str, requirements: str = None, context: str = None) -> str: + """Generate an HTML lesson for the given topic. + + Args: + topic: The lesson topic. + requirements: User-specified requirements for the lesson (optional). + context: Additional context from RAG chunks (optional, for future use). + """ + message = f"Create a detailed lesson on the topic: {topic}" + + if requirements: + message += f"\n\nRequirements:\n{requirements}" + + if context: + message += ( + "\n\nBelow is reference material retrieved from the knowledge base. " + "Integrate this content into the lesson where appropriate:\n\n" + f"{context}" + ) + + return self.llm_service.chat(message, system_prompt=LESSON_SYSTEM_PROMPT) \ No newline at end of file diff --git a/ai_core/services/quiz.py b/ai_core/services/quiz.py new file mode 100644 index 00000000..d9c49957 --- /dev/null +++ b/ai_core/services/quiz.py @@ -0,0 +1,173 @@ +"""Quiz generation service.""" +import json +import logging +import re + +from .base import BaseLLMService + +logger = logging.getLogger("ai_core") + +QUIZ_SYSTEM_PROMPT = ( + "You are an expert educator who creates multiple-choice quiz questions. " + "Based on the lesson content provided, create detailed quizzes in the same language as the topic or context provided..\n\n" + "RULES:\n" + "1. Return ONLY a valid JSON array, no markdown, no explanation outside JSON.\n" + "2. Each element must have exactly these fields:\n" + ' - "q": (string) The question text.\n' + ' - "options": (array of 4 strings) Choices A, B, C, D.\n' + ' - "correct": (integer 0-3) Index of the correct answer.\n' + ' - "explain": (string) Brief explanation of the correct answer.\n' + "3. Vary question difficulty (recall, comprehension, application).\n" + "4. Distribute correct answers randomly across A, B, C, D.\n" + "5. Make distractors plausible and non-trivial to eliminate.\n" + "6. Start your response with [ and end with ], nothing else." +) + +DEFAULT_NUM_QUESTIONS = 5 +MAX_RETRIES = 2 + + +class QuizGenerationError(Exception): + """Raised when quiz generation fails after all retries.""" + + +class QuizService: + """Generate multiple-choice questions from lesson content.""" + + def __init__(self, llm_service: BaseLLMService): + self.llm_service = llm_service + + def generate(self, lesson_content: str, num_questions: int = DEFAULT_NUM_QUESTIONS, + difficulty: str = None) -> list: + """Generate quiz questions from the given lesson content. + + Args: + lesson_content: The lesson text (plain text or HTML). + num_questions: Number of questions to generate (1-20, default 5). + difficulty: Optional level – "easy", "medium", or "hard". + + Returns: + A list of question dicts matching the expected schema. + + Raises: + QuizGenerationError: If generation or parsing fails after retries. + """ + message = self._build_message(lesson_content, num_questions, difficulty) + + last_error = None + for attempt in range(1, MAX_RETRIES + 1): + try: + raw = self.llm_service.chat(message, system_prompt=QUIZ_SYSTEM_PROMPT) + questions = self._parse_response(raw) + self._validate(questions) + logger.info(f"Quiz generated: {len(questions)} questions (attempt {attempt})") + return questions + except (json.JSONDecodeError, ValueError) as exc: + last_error = exc + logger.warning(f"Quiz parse attempt {attempt}/{MAX_RETRIES} failed: {exc}") + # Prepend a correction hint for the next attempt + message = ( + "Your previous response was not valid JSON. " + "Please respond ONLY with a valid JSON array.\n\n" + message + ) + + raise QuizGenerationError( + f"Failed to generate quiz after {MAX_RETRIES} attempts: {last_error}" + ) + + # -- Private helpers -------------------------------------------------- + + @staticmethod + def _build_message(lesson_content: str, num_questions: int, + difficulty: str = None) -> str: + """Build the user message sent to the LLM.""" + difficulty_hint = "" + if difficulty: + levels = { + "easy": "easy (recall and recognition)", + "medium": "medium (comprehension and low application)", + "hard": "hard (high application and analysis)", + } + difficulty_hint = f"\nDifficulty level: {levels.get(difficulty, difficulty)}." + + return ( + f"Based on the lesson content below, create exactly {num_questions} " + f"multiple-choice questions. Each question must have 4 choices " + f"A, B, C, D and indicate the correct answer.{difficulty_hint}\n\n" + f"=== LESSON CONTENT ===\n" + f"{lesson_content}\n" + f"=== END OF CONTENT ===\n\n" + f"Respond with a JSON array only (no markdown or explanation)." + ) + + @staticmethod + def _parse_response(raw: str) -> list: + """Extract and parse a JSON array from the LLM response. + + Handles three cases: + 1. Clean JSON – parse directly. + 2. Wrapped in markdown code block – strip fences then parse. + 3. Embedded in extra text – locate outermost [ ] and parse. + """ + text = raw.strip() + + # Case 1: starts with [ + if text.startswith("["): + try: + return json.loads(text) + except json.JSONDecodeError: + pass + + # Case 2: markdown code block ```json ... ``` + match = re.search(r"```(?:json)?\s*\n?(.*?)\n?\s*```", text, re.DOTALL) + if match: + try: + return json.loads(match.group(1).strip()) + except json.JSONDecodeError: + pass + + # Case 3: find outermost [ ... ] + start = text.find("[") + end = text.rfind("]") + if start != -1 and end > start: + try: + return json.loads(text[start:end + 1]) + except json.JSONDecodeError: + pass + + raise json.JSONDecodeError( + "Could not extract JSON array from response", text, 0 + ) + + @staticmethod + def _validate(questions: list) -> None: + """Validate the structure of each question dict. + + Raises: + ValueError: If any question does not match the expected schema. + """ + if not isinstance(questions, list) or not questions: + raise ValueError("Response is not a non-empty list") + + for i, item in enumerate(questions): + label = f"Question {i + 1}" + + if not isinstance(item, dict): + raise ValueError(f"{label}: not an object") + + if not item.get("q") or not isinstance(item["q"], str): + raise ValueError(f"{label}: missing or invalid 'q'") + + opts = item.get("options") + if not isinstance(opts, list) or len(opts) != 4: + raise ValueError(f"{label}: 'options' must be an array of 4 strings") + + if not all(isinstance(o, str) and o.strip() for o in opts): + raise ValueError(f"{label}: each option must be a non-empty string") + + correct = item.get("correct") + if not isinstance(correct, int) or correct not in (0, 1, 2, 3): + raise ValueError(f"{label}: 'correct' must be an integer 0-3") + + if "explain" in item and not isinstance(item["explain"], str): + raise ValueError(f"{label}: 'explain' must be a string") diff --git a/ai_core/services/rag.py b/ai_core/services/rag.py new file mode 100644 index 00000000..48f37285 --- /dev/null +++ b/ai_core/services/rag.py @@ -0,0 +1,186 @@ +"""Lightweight RAG service using Gemini Embedding API. + +Provides semantic search over document chunks stored in the database. +Uses cosine similarity with NumPy for fast, CPU-only retrieval — no +external vector database required. +""" + +import logging +from typing import Optional + +import numpy as np +from decouple import config +from langchain_google_genai import GoogleGenerativeAIEmbeddings + +from ai_core.models import DocumentChunk +from ai_core.services.document_parser import chunk_text, extract_text +from course.models import Upload + +logger = logging.getLogger("ai_core") + +# --------------------------------------------------------------------------- +# Embedding helper +# --------------------------------------------------------------------------- + +_embeddings_model: Optional[GoogleGenerativeAIEmbeddings] = None + + +def _get_embeddings_model() -> GoogleGenerativeAIEmbeddings: + """Return a cached instance of the Gemini embeddings model.""" + global _embeddings_model + if _embeddings_model is None: + _embeddings_model = GoogleGenerativeAIEmbeddings( + google_api_key=config("GOOGLE_API_KEY"), + model="models/gemini-embedding-001", + ) + logger.info("Initialized Gemini gemini-embedding-001") + return _embeddings_model + + +def embed_text(text: str) -> list[float]: + """Generate an embedding vector for a single text string. + + Args: + text: The text to embed. + + Returns: + A list of floats representing the embedding vector. + """ + model = _get_embeddings_model() + return model.embed_query(text) + + +def embed_texts(texts: list[str]) -> list[list[float]]: + """Generate embedding vectors for multiple texts in a single batch. + + Args: + texts: List of text strings to embed. + + Returns: + A list of embedding vectors, one per input text. + """ + model = _get_embeddings_model() + return model.embed_documents(texts) + + +# --------------------------------------------------------------------------- +# Indexing +# --------------------------------------------------------------------------- + + +def embed_and_store_chunks(upload_id: int) -> int: + """Parse a document, chunk it, embed the chunks, and store in the database. + + This function is idempotent — if chunks already exist for the given + upload, it skips processing entirely (lazy indexing). + + Args: + upload_id: Primary key of the Upload record to process. + + Returns: + The number of chunks created (0 if already indexed). + """ + if DocumentChunk.objects.filter(upload_id=upload_id).exists(): + logger.info("Upload %d already indexed, skipping", upload_id) + return 0 + + upload = Upload.objects.get(pk=upload_id) + file_path = upload.file.path + + # Extract and chunk text + raw_text = extract_text(file_path) + if not raw_text: + logger.warning("No text extracted from upload %d (%s)", upload_id, file_path) + return 0 + + chunks = chunk_text(raw_text) + if not chunks: + logger.warning("No chunks generated from upload %d", upload_id) + return 0 + + # Batch embed all chunks in one API call + logger.info("Embedding %d chunks for upload %d", len(chunks), upload_id) + vectors = embed_texts(chunks) + + # Bulk create chunk records + chunk_objects = [ + DocumentChunk( + upload_id=upload_id, + chunk_index=i, + content=content, + embedding=vector, + ) + for i, (content, vector) in enumerate(zip(chunks, vectors)) + ] + DocumentChunk.objects.bulk_create(chunk_objects) + logger.info("Stored %d chunks for upload %d", len(chunk_objects), upload_id) + + return len(chunk_objects) + + +# --------------------------------------------------------------------------- +# Search +# --------------------------------------------------------------------------- + + +def _cosine_similarity(vec_a: np.ndarray, vec_b: np.ndarray) -> float: + """Compute cosine similarity between two vectors.""" + dot = np.dot(vec_a, vec_b) + norm = np.linalg.norm(vec_a) * np.linalg.norm(vec_b) + if norm == 0: + return 0.0 + return float(dot / norm) + + +def search_chunks( + query: str, + upload_ids: list[int], + top_k: int = 3, +) -> str: + """Find the most relevant document chunks for a given query. + + Searches only within the specified uploads (documents selected by the + user). Returns the top-K chunks concatenated as a single context string. + + Args: + query: The search query (typically the lesson topic). + upload_ids: List of Upload IDs to restrict the search to. + top_k: Number of top-matching chunks to return. + + Returns: + A concatenated string of the most relevant chunks, or empty + string if no relevant chunks are found. + """ + # Retrieve chunks belonging to the selected documents + chunks = DocumentChunk.objects.filter( + upload_id__in=upload_ids, + embedding__isnull=False, + ).values_list("content", "embedding") + + if not chunks: + logger.info("No indexed chunks found for uploads %s", upload_ids) + return "" + + # Embed the query + query_vector = np.array(embed_text(query)) + + # Score each chunk by cosine similarity + scored: list[tuple[float, str]] = [] + for content, embedding in chunks: + chunk_vector = np.array(embedding) + score = _cosine_similarity(query_vector, chunk_vector) + scored.append((score, content)) + + # Sort by similarity (descending) and take top-K + scored.sort(key=lambda x: x[0], reverse=True) + top_chunks = [content for _, content in scored[:top_k]] + + logger.info( + "RAG search for '%s' across %d uploads: found %d chunks, returning top %d", + query[:50], + len(upload_ids), + len(scored), + len(top_chunks), + ) + + return "\n\n---\n\n".join(top_chunks) diff --git a/ai_core/tests.py b/ai_core/tests.py new file mode 100644 index 00000000..7ce503c2 --- /dev/null +++ b/ai_core/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/ai_core/urls.py b/ai_core/urls.py new file mode 100644 index 00000000..53cee82b --- /dev/null +++ b/ai_core/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from . import views + +app_name = "ai_core" + +urlpatterns = [ + path("lesson/generate/", views.generate_lesson, name="generate_lesson"), + path("lesson/save-doc/", views.save_lesson_doc, name="save_lesson_doc"), + path("lesson//html/", views.get_lesson_html, name="get_lesson_html"), + path("quiz/generate/", views.generate_quiz, name="generate_quiz"), + path("chatbot/", views.chatbot_reply, name="chatbot_reply"), +] \ No newline at end of file diff --git a/ai_core/views.py b/ai_core/views.py new file mode 100644 index 00000000..37e8916e --- /dev/null +++ b/ai_core/views.py @@ -0,0 +1,188 @@ +import json +import logging +from io import BytesIO +import os +from django.conf import settings +from django.core.files.base import ContentFile +from django.http import JsonResponse +from django.views.decorators.http import require_http_methods +from django.contrib.auth.decorators import login_required +from ai_core.services import GeminiService, LessonService, QuizGenerationError, QuizService, ChatbotService +from ai_core.services.rag import embed_and_store_chunks, search_chunks +from ai_core.models import AIGeneration, DocumentChunk +from course.models import Course, Upload +from django.shortcuts import get_object_or_404, render +from django.views.decorators.csrf import csrf_exempt +from docx import Document +from htmldocx import HtmlToDocx + +logger = logging.getLogger("ai_core") + +@csrf_exempt +@login_required +@require_http_methods(["POST"]) +def generate_lesson(request): + """Generate a lesson in HTML format for a given topic. + + Optionally uses RAG to enrich the prompt with content from + user-selected course documents (specified via upload_ids). + """ + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + topic = data.get("topic", "").strip() + if not topic: + return JsonResponse({"error": "Topic is required"}, status=400) + + requirements = data.get("requirements", None) + upload_ids = data.get("upload_ids", []) + + # --- RAG: retrieve context from selected documents --- + context = None + if upload_ids: + try: + # Lazy indexing: parse and embed only if not already done + for uid in upload_ids: + embed_and_store_chunks(uid) + + # Semantic search within the selected documents + context = search_chunks(query=topic, upload_ids=upload_ids, top_k=3) + if not context: + logger.info("RAG returned no relevant chunks for topic: %s", topic) + except Exception as e: + logger.error("RAG processing failed, proceeding without context: %s", e) + context = None + + try: + llm = GeminiService() + service = LessonService(llm) + html_content = service.generate(topic, requirements=requirements, context=context) + return JsonResponse({"topic": topic, "content": html_content}) + except Exception as e: + logger.error(f"Lesson generation failed: {e}") + return JsonResponse({"error": "AI service unavailable"}, status=503) + + +@csrf_exempt +@login_required +@require_http_methods(["POST"]) +def generate_quiz(request): + """Generate multiple-choice questions from lesson content.""" + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + lesson_content = data.get("lesson_content", "").strip() + if not lesson_content: + return JsonResponse({"error": "lesson_content is required"}, status=400) + + num_questions = data.get("num_questions", 5) + if not isinstance(num_questions, int): + return JsonResponse({"error": "num_questions must be an integer"}, status=400) + + difficulty = data.get("difficulty", None) + if difficulty and difficulty not in ("easy", "medium", "hard"): + return JsonResponse( + {"error": "difficulty must be 'easy', 'medium', or 'hard'"}, status=400 + ) + + try: + llm = GeminiService() + service = QuizService(llm) + questions = service.generate(lesson_content, num_questions, difficulty) + return JsonResponse({"questions": questions, "count": len(questions)}) + except QuizGenerationError as e: + logger.error(f"Quiz generation failed: {e}") + return JsonResponse({"error": "Quiz generation failed"}, status=502) + except Exception as e: + logger.error(f"Quiz generation error: {e}") + return JsonResponse({"error": "AI service unavailable"}, status=503) + + +@csrf_exempt +@login_required +@require_http_methods(["POST"]) +def save_lesson_doc(request): + """Convert generated lesson HTML to Word (.docx) and save to Course Documents.""" + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + course_slug = data.get("course_slug") + topic = data.get("topic") + html_content = data.get("html_content") + + if not all([course_slug, topic, html_content]): + return JsonResponse({"error": "Missing required fields"}, status=400) + + course = get_object_or_404(Course, slug=course_slug) + + # Generate DOCX + document = Document() + document.add_heading(f"Lesson: {topic}", 0) + + # Parse HTML and append to document + new_parser = HtmlToDocx() + new_parser.add_html_to_document(html_content, document) + + # Save the Document to a stream + result = BytesIO() + document.save(result) + + # Save the DOCX to the database + file_name = f"lesson_{topic.replace(' ', '_')[:30]}.docx" + + upload = Upload.objects.create( + title=f"AI Lesson: {topic}", + course=course, + html_content=html_content, + ) + upload.file.save(file_name, ContentFile(result.getvalue())) + + return JsonResponse({ + "status": "success", + "message": "Lesson saved successfully to Course Documents" + }) + + +@login_required +@require_http_methods(["GET"]) +def get_lesson_html(request, upload_id): + """Return the stored HTML content for a generated lesson.""" + upload = get_object_or_404(Upload, pk=upload_id) + if not upload.html_content: + return JsonResponse({"error": "No HTML content available"}, status=404) + return JsonResponse({ + "title": upload.title, + "html_content": upload.html_content, + }) + + +@csrf_exempt +@login_required +@require_http_methods(["POST"]) +def chatbot_reply(request): + """Return an AI chatbot reply for the user's message.""" + try: + data = json.loads(request.body) + except json.JSONDecodeError: + return JsonResponse({"error": "Invalid JSON"}, status=400) + + message = data.get("message", "").strip() + if not message: + return JsonResponse({"error": "message is required"}, status=400) + + history = data.get("history", []) + + try: + llm = GeminiService() + service = ChatbotService(llm) + reply = service.reply(message, history=history) + return JsonResponse({"reply": reply}) + except Exception as e: + logger.error(f"Chatbot error: {e}") + return JsonResponse({"error": "AI service unavailable"}, status=503) diff --git a/config/settings.py b/config/settings.py index 43f70b16..4306fba8 100644 --- a/config/settings.py +++ b/config/settings.py @@ -13,6 +13,7 @@ import os from decouple import config from django.utils.translation import gettext_lazy as _ +import cloudinary # Build paths inside the project like this: os.path.join(BASE_DIR, ...) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -52,6 +53,8 @@ "crispy_forms", "crispy_bootstrap5", "django_filters", + "cloudinary_storage", + "cloudinary", ] # Custom apps @@ -63,6 +66,7 @@ "search.apps.SearchConfig", "quiz.apps.QuizConfig", "payments.apps.PaymentsConfig", + "ai_core.apps.AiCoreConfig", # add ai_core ] # Combine all apps @@ -107,11 +111,14 @@ # Database # https://docs.djangoproject.com/en/2.2/ref/settings/#databases +import dj_database_url + DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": os.path.join(BASE_DIR, "db.sqlite3"), - } + 'default': dj_database_url.config( + default=f"postgres://{config('DB_USER')}:{config('DB_PASSWORD')}@{config('DB_HOST')}:{config('DB_PORT')}/{config('DB_NAME')}", + conn_max_age=600, + ssl_require=True + ) } # https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-DEFAULT_AUTO_FIELD @@ -269,3 +276,13 @@ def gettext(s): (SECOND, _("Second")), (THIRD, _("Third")), ) + +# Read Cloudinary credentials from .env +CLOUDINARY_STORAGE = { + 'CLOUD_NAME': config('CLOUDINARY_CLOUD_NAME'), + 'API_KEY': config('CLOUDINARY_API_KEY'), + 'API_SECRET': config('CLOUDINARY_API_SECRET'), +} + +# Tell Django to upload all media files to Cloudinary +DEFAULT_FILE_STORAGE = 'cloudinary_storage.storage.RawMediaCloudinaryStorage' diff --git a/config/urls.py b/config/urls.py index 74aa797b..ece0228c 100644 --- a/config/urls.py +++ b/config/urls.py @@ -26,6 +26,7 @@ path("search/", include("search.urls")), path("quiz/", include("quiz.urls")), path("payments/", include("payments.urls")), + path("ai/", include("ai_core.urls")), # ai_core ) diff --git a/course/forms.py b/course/forms.py index 18b1d9ea..bd88fe95 100644 --- a/course/forms.py +++ b/course/forms.py @@ -27,14 +27,14 @@ def __init__(self, *args, **kwargs): self.fields["credit"].widget.attrs.update({"class": "form-control"}) self.fields["summary"].widget.attrs.update({"class": "form-control"}) self.fields["program"].widget.attrs.update({"class": "form-control"}) - self.fields["level"].widget.attrs.update({"class": "form-control"}) + self.fields["year"].widget.attrs.update({"class": "form-control"}) self.fields["semester"].widget.attrs.update({"class": "form-control"}) class CourseAllocationForm(forms.ModelForm): courses = forms.ModelMultipleChoiceField( - queryset=Course.objects.all().order_by("level"), + queryset=Course.objects.all().order_by("year"), widget=forms.CheckboxSelectMultiple( attrs={"class": "browser-default checkbox"} ), @@ -57,7 +57,7 @@ def __init__(self, *args, **kwargs): class EditCourseAllocationForm(forms.ModelForm): courses = forms.ModelMultipleChoiceField( - queryset=Course.objects.all().order_by("level"), + queryset=Course.objects.all().order_by("year"), widget=forms.CheckboxSelectMultiple, required=True, ) diff --git a/course/migrations/0005_upload_html_content.py b/course/migrations/0005_upload_html_content.py new file mode 100644 index 00000000..75887cfd --- /dev/null +++ b/course/migrations/0005_upload_html_content.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.28 on 2026-02-28 13:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("course", "0004_alter_course_code_alter_course_credit_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="upload", + name="html_content", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/course/migrations/0006_remove_course_level.py b/course/migrations/0006_remove_course_level.py new file mode 100644 index 00000000..dc40ba91 --- /dev/null +++ b/course/migrations/0006_remove_course_level.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.28 on 2026-03-01 04:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("course", "0005_upload_html_content"), + ] + + operations = [ + migrations.RemoveField( + model_name="course", + name="level", + ), + ] diff --git a/course/models.py b/course/models.py index 37377458..20bd7a40 100644 --- a/course/models.py +++ b/course/models.py @@ -65,7 +65,7 @@ class Course(models.Model): credit = models.IntegerField(default=0) summary = models.TextField(max_length=200, blank=True) program = models.ForeignKey(Program, on_delete=models.CASCADE) - level = models.CharField(max_length=25, choices=settings.LEVEL_CHOICES) + year = models.IntegerField(choices=settings.YEARS, default=1) semester = models.CharField(choices=settings.SEMESTER_CHOICES, max_length=200) is_elective = models.BooleanField(default=False) @@ -123,6 +123,7 @@ def get_absolute_url(self): class Upload(models.Model): title = models.CharField(max_length=100) course = models.ForeignKey(Course, on_delete=models.CASCADE) + html_content = models.TextField(null=True, blank=True) file = models.FileField( upload_to="course_files/", help_text=_( diff --git a/course/views.py b/course/views.py index 73466e00..1666d983 100644 --- a/course/views.py +++ b/course/views.py @@ -419,20 +419,19 @@ def course_registration(request): courses = ( Course.objects.filter( program__pk=student.program.id, - level=student.level, semester=current_semester, ) .exclude(id__in=t) .order_by("year") ) all_courses = Course.objects.filter( - level=student.level, program__pk=student.program.id + program__pk=student.program.id ) no_course_is_registered = False # Check if no course is registered all_courses_are_registered = False - registered_courses = Course.objects.filter(level=student.level).filter(id__in=t) + registered_courses = Course.objects.filter(id__in=t) if ( registered_courses.count() == 0 ): # Check if number of registered courses is 0 diff --git a/quiz/urls.py b/quiz/urls.py index 4e42e7e4..6eb6bc47 100644 --- a/quiz/urls.py +++ b/quiz/urls.py @@ -20,5 +20,20 @@ views.MCQuestionCreate.as_view(), name="mc_create", ), + path( + "//generate-ai/", + views.generate_ai_questions, + name="generate_ai_questions", + ), #new path for generate MCQ + path( + "//questions/", + views.quiz_questions, + name="quiz_questions", + ), #another new path for checking questions that generate + path( + "mc-question////edit/", + views.MCQuestionUpdate.as_view(), + name="mc_update", + ), #new path for viewing questions # path('mc-question/add///', MCQuestionCreate.as_view(), name='mc_create'), ] diff --git a/quiz/views.py b/quiz/views.py index 263278f6..95d98127 100644 --- a/quiz/views.py +++ b/quiz/views.py @@ -1,3 +1,6 @@ +from ai_core.services import GeminiService, QuizService +from django.shortcuts import get_object_or_404, redirect #new +from .models import Quiz, MCQuestion, Choice #new from django.contrib import messages from django.contrib.auth.decorators import login_required from django.db import transaction @@ -334,3 +337,96 @@ def final_result_user(self): self.sitting.delete() return render(self.request, self.result_template_name, results) + + +# ######################################################## +# Quiz Gen +# ######################################################## +def generate_ai_questions(request, slug, quiz_id): + quiz = get_object_or_404(Quiz, id=quiz_id) + + num_questions = int(request.GET.get("num", 5)) + + # Use quiz description/title as context + lesson_content = quiz.description or quiz.title + + try: + llm = GeminiService() + service = QuizService(llm) + + questions_data = service.generate( + lesson_content, + num_questions, + difficulty=None + ) + + for q in questions_data: + + question = MCQuestion.objects.create( + content=q["q"], + explanation=q.get("explain", "") + ) + + question.quiz.add(quiz) + correct_index = q.get("correct") + + for index, option_text in enumerate(q["options"]): + Choice.objects.create( + question=question, + choice_text=option_text, + correct=(index == correct_index) + ) + + messages.success(request, f"{len(questions_data)} questions generated successfully!") + + except Exception as e: + messages.error(request, "AI generation failed.") + + return redirect("quiz_index", slug=slug) + +# ######################################################## +# Quiz List +# ######################################################## +@login_required +def quiz_questions(request, slug, quiz_id): + quiz = get_object_or_404(Quiz, id=quiz_id) + questions = MCQuestion.objects.filter(quiz=quiz) + + context = { + 'quiz': quiz, + 'questions': questions + } + return render(request, 'quiz/quiz_questions.html', context) + +# ######################################################## +# MCQ update +# ######################################################## +@method_decorator([login_required, lecturer_required], name="dispatch") +class MCQuestionUpdate(UpdateView): + model = MCQuestion + form_class = MCQuestionForm + template_name = "quiz/mcquestion_form.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["quiz_obj"] = get_object_or_404(Quiz, id=self.kwargs["quiz_id"]) + context["course"] = get_object_or_404(Course, slug=self.kwargs["slug"]) + + if self.request.method == "POST": + context["formset"] = MCQuestionFormSet(self.request.POST, instance=self.object) + else: + context["formset"] = MCQuestionFormSet(instance=self.object) + + return context + + def form_valid(self, form): + context = self.get_context_data() + formset = context["formset"] + + if formset.is_valid(): + self.object = form.save() + formset.save() + return redirect("quiz_question_list", + slug=self.kwargs["slug"], + quiz_id=self.kwargs["quiz_id"]) + return self.form_invalid(form) \ No newline at end of file diff --git a/requirements/base.txt b/requirements/base.txt index 7be538b3..85399f2f 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,26 +1,38 @@ -pytz==2022.7 # https://github.com/stub42/pytz -Pillow==9.3.0 # https://github.com/python-pillow/Pillow -whitenoise==6.2.0 # https://github.com/evansd/whitenoise +Pillow>=10.0,<11.0 # https://github.com/python-pillow/Pillow +whitenoise>=6.7,<7.0 # https://github.com/evansd/whitenoise # Django # ------------------------------------------------------------------------------ -django==4.0.8 # pyup: < 4.1 # https://www.djangoproject.com/ -django-model-utils==4.3.1 # https://github.com/jazzband/django-model-utils -django-crispy-forms==1.14.0 # https://github.com/django-crispy-forms/django-crispy-forms -crispy-bootstrap5==0.7 # https://github.com/django-crispy-forms/crispy-bootstrap5 -django-filter==23.5 # https://github.com/carltongibson/django-filter -django-modeltranslation==0.18.11 # https://github.com/Buren/django-modeltranslation +django>=4.2,<5.0 # https://www.djangoproject.com/ (LTS) +django-model-utils>=4.3,<5.0 # https://github.com/jazzband/django-model-utils +django-crispy-forms>=2.0,<3.0 # https://github.com/django-crispy-forms/django-crispy-forms +crispy-bootstrap5>=2024.2 # https://github.com/django-crispy-forms/crispy-bootstrap5 +django-filter>=23.5,<24.0 # https://github.com/carltongibson/django-filter +django-modeltranslation>=0.18,<0.19 # https://github.com/Buren/django-modeltranslation + # PDF generator -reportlab==4.0.4 -xhtml2pdf==0.2.15 +reportlab>=4.0,<5.0 +xhtml2pdf>=0.2.15,<1.0 + +# Document generator +python-docx>=1.2,<2.0 +htmldocx>=0.0.6 # Customize django admin -django-jet-reboot==1.3.5 +django-jet-reboot>=1.3,<2.0 # Environment variable -python-decouple==3.8 +python-decouple>=3.8,<4.0 # Payments -stripe==5.5.0 -gopay==2.0.1 +stripe>=8.0,<9.0 +gopay>=2.0,<3.0 + +# AI core +langchain +langchain-google-genai + +#Cloudinary +psycopg2-binary dj-database-url +cloudinary django-cloudinary-storage diff --git a/requirements/local.txt b/requirements/local.txt index c3077d43..773d7b36 100644 --- a/requirements/local.txt +++ b/requirements/local.txt @@ -2,6 +2,6 @@ # Code quality # ------------------------------------------------------------------------------ -black==22.12.0 # https://github.com/psf/black -factory-boy==3.3.1 -django-extensions==3.2.3 \ No newline at end of file +black>=24.0,<25.0 # https://github.com/psf/black +factory-boy>=3.3,<4.0 +django-extensions>=3.2,<4.0 \ No newline at end of file diff --git a/requirements/production.txt b/requirements/production.txt index f4848067..bcbfae0b 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -2,9 +2,9 @@ -r base.txt -gunicorn==20.1.0 # https://github.com/benoitc/gunicorn +gunicorn>=21.2,<23.0 # https://github.com/benoitc/gunicorn # Django # ------------------------------------------------------------------------------ -django-storages[boto3]==1.13.1 # https://github.com/jschneier/django-storages -django-anymail[amazon_ses]==9.0 # https://github.com/anymail/django-anymail +django-storages[boto3]>=1.14,<2.0 # https://github.com/jschneier/django-storages +django-anymail[amazon_ses]>=10.0,<12.0 # https://github.com/anymail/django-anymail diff --git a/result/models.py b/result/models.py index ba036193..b4d54995 100644 --- a/result/models.py +++ b/result/models.py @@ -153,7 +153,6 @@ def calculate_gpa(self): taken_courses = TakenCourse.objects.filter( student=self.student, - course__level=self.student.level, course__semester=current_semester.semester, ) diff --git a/result/views.py b/result/views.py index ef2cad96..4d660a42 100644 --- a/result/views.py +++ b/result/views.py @@ -123,10 +123,9 @@ def add_score_for(request, id): # print(student.student) # print(student.student.program.id) courses = ( - Course.objects.filter(level=student.student.level) - .filter(program__pk=student.student.program.id) + Course.objects.filter(program__pk=student.student.program.id) .filter(semester=current_semester) - ) # all courses of a specific level in current semester + ) # all courses in current semester total_credit_in_semester = 0 for i in courses: if i == courses.count(): @@ -204,9 +203,7 @@ def add_score_for(request, id): @student_required def grade_result(request): student = Student.objects.get(student__pk=request.user.id) - courses = TakenCourse.objects.filter(student__student__pk=request.user.id).filter( - course__level=student.level - ) + courses = TakenCourse.objects.filter(student__student__pk=request.user.id) # total_credit_in_semester = 0 results = Result.objects.filter(student__student__pk=request.user.id) @@ -261,7 +258,7 @@ def grade_result(request): def assessment_result(request): student = Student.objects.get(student__pk=request.user.id) courses = TakenCourse.objects.filter( - student__student__pk=request.user.id, course__level=student.level + student__student__pk=request.user.id ) result = Result.objects.filter(student__student__pk=request.user.id) @@ -362,7 +359,7 @@ def result_sheet_pdf_view(request, id): normal.fontSize = 10 normal.leading = 15 level = result.filter(course_id=id).first() - title = "Level: " + str(level.course.level) + title = "Level: " + str(level.student.level) title = Paragraph(title.upper(), normal) Story.append(title) Story.append(Spacer(1, 0.6 * inch)) diff --git a/static/css/chatbot.css b/static/css/chatbot.css new file mode 100644 index 00000000..ffbffe86 --- /dev/null +++ b/static/css/chatbot.css @@ -0,0 +1,351 @@ +/* ========================================================= + SkyLearn AI Chatbot – Floating Widget Styles + ========================================================= */ + +/* Toggle Button */ +.chatbot-toggle-btn { + position: fixed; + bottom: 80px; + right: 28px; + z-index: 10000; + width: 58px; + height: 58px; + border-radius: 50%; + border: none; + background: linear-gradient(135deg, #4f6df5 0%, #6c40f7 100%); + color: #fff; + font-size: 24px; + cursor: pointer; + box-shadow: 0 4px 20px rgba(79, 109, 245, 0.45); + display: flex; + align-items: center; + justify-content: center; + transition: transform 0.25s ease, box-shadow 0.25s ease; +} + +.chatbot-toggle-btn:hover { + transform: scale(1.08); + box-shadow: 0 6px 28px rgba(79, 109, 245, 0.55); +} + +/* Badge */ +.chatbot-badge { + position: absolute; + top: -2px; + right: -2px; + background: #ef4444; + color: #fff; + font-size: 11px; + font-weight: 700; + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +/* Chat Window */ +.chatbot-window { + position: fixed; + bottom: 100px; + right: 28px; + z-index: 10001; + width: 380px; + max-width: calc(100vw - 32px); + height: 520px; + max-height: calc(100vh - 140px); + border-radius: 16px; + background: #fff; + box-shadow: 0 12px 48px rgba(0, 0, 0, 0.18); + display: flex; + flex-direction: column; + overflow: hidden; + animation: chatbot-slide-up 0.3s ease; +} + +@keyframes chatbot-slide-up { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Header */ +.chatbot-header { + background: linear-gradient(135deg, #4f6df5 0%, #6c40f7 100%); + color: #fff; + padding: 14px 16px; + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.chatbot-header-info { + display: flex; + align-items: center; + gap: 10px; +} + +.chatbot-avatar { + width: 38px; + height: 38px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.2); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.chatbot-header-title { + font-weight: 600; + font-size: 15px; + line-height: 1.2; +} + +.chatbot-header-status { + font-size: 12px; + opacity: 0.85; + display: flex; + align-items: center; + gap: 5px; +} + +.chatbot-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: #4ade80; + display: inline-block; +} + +.chatbot-header-actions { + display: flex; + gap: 6px; +} + +.chatbot-header-btn { + background: rgba(255, 255, 255, 0.15); + border: none; + color: #fff; + width: 32px; + height: 32px; + border-radius: 8px; + cursor: pointer; + font-size: 13px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; +} + +.chatbot-header-btn:hover { + background: rgba(255, 255, 255, 0.3); +} + +/* Messages Area */ +.chatbot-messages { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + background: #f8f9fb; +} + +.chatbot-messages::-webkit-scrollbar { + width: 5px; +} + +.chatbot-messages::-webkit-scrollbar-thumb { + background: #cbd5e1; + border-radius: 4px; +} + +/* Message Bubbles */ +.chatbot-msg { + display: flex; + gap: 8px; + max-width: 88%; + animation: chatbot-fade-in 0.25s ease; +} + +@keyframes chatbot-fade-in { + from { + opacity: 0; + transform: translateY(6px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.chatbot-msg-bot { + align-self: flex-start; +} + +.chatbot-msg-user { + align-self: flex-end; + flex-direction: row-reverse; +} + +.chatbot-msg-avatar { + width: 30px; + height: 30px; + border-radius: 50%; + background: linear-gradient(135deg, #4f6df5, #6c40f7); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + flex-shrink: 0; + margin-top: 2px; +} + +.chatbot-msg-user .chatbot-msg-avatar { + background: linear-gradient(135deg, #6366f1, #8b5cf6); +} + +.chatbot-msg-bubble { + padding: 10px 14px; + border-radius: 14px; + font-size: 13.5px; + line-height: 1.55; + word-wrap: break-word; + white-space: pre-wrap; +} + +.chatbot-msg-bot .chatbot-msg-bubble { + background: #fff; + color: #1e293b; + border: 1px solid #e2e8f0; + border-top-left-radius: 4px; +} + +.chatbot-msg-user .chatbot-msg-bubble { + background: linear-gradient(135deg, #4f6df5, #6c40f7); + color: #fff; + border-top-right-radius: 4px; +} + +/* Typing Indicator */ +.chatbot-typing { + display: flex; + gap: 4px; + padding: 8px 14px; +} + +.chatbot-typing span { + width: 7px; + height: 7px; + border-radius: 50%; + background: #94a3b8; + animation: chatbot-bounce 1.4s ease-in-out infinite; +} + +.chatbot-typing span:nth-child(2) { + animation-delay: 0.16s; +} + +.chatbot-typing span:nth-child(3) { + animation-delay: 0.32s; +} + +@keyframes chatbot-bounce { + + 0%, + 60%, + 100% { + transform: translateY(0); + } + + 30% { + transform: translateY(-6px); + } +} + +/* Input Area */ +.chatbot-input-area { + padding: 12px 14px; + border-top: 1px solid #e2e8f0; + flex-shrink: 0; + background: #fff; +} + +.chatbot-input-wrapper { + display: flex; + align-items: center; + gap: 8px; + background: #f1f5f9; + border-radius: 24px; + padding: 4px 6px 4px 16px; +} + +.chatbot-input { + flex: 1; + border: none; + background: transparent; + outline: none; + font-size: 13.5px; + color: #1e293b; + padding: 8px 0; +} + +.chatbot-input::placeholder { + color: #94a3b8; +} + +.chatbot-send-btn { + width: 38px; + height: 38px; + border-radius: 50%; + border: none; + background: linear-gradient(135deg, #4f6df5 0%, #6c40f7 100%); + color: #fff; + font-size: 14px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s, transform 0.2s; +} + +.chatbot-send-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.chatbot-send-btn:not(:disabled):hover { + transform: scale(1.08); +} + +/* Responsive */ +@media (max-width: 480px) { + .chatbot-window { + width: calc(100vw - 16px); + right: 8px; + bottom: 90px; + height: calc(100vh - 120px); + border-radius: 12px; + } + + .chatbot-toggle-btn { + right: 16px; + bottom: 18px; + width: 52px; + height: 52px; + font-size: 22px; + } +} \ No newline at end of file diff --git a/static/css/style.min.css b/static/css/style.min.css index 85a0a3d2..48e9a181 100644 --- a/static/css/style.min.css +++ b/static/css/style.min.css @@ -1 +1 @@ -@font-face{font-family:"Rubik";font-style:normal;font-weight:300;src:url("../fonts/rubik-v14-latin/rubik-v14-latin-300.eot");src:local(""),url("../fonts/rubik-v14-latin/rubik-v14-latin-300.eot?#iefix") format("embedded-opentype"),url("../fonts/rubik-v14-latin/rubik-v14-latin-300.woff2") format("woff2"),url("../fonts/rubik-v14-latin/rubik-v14-latin-300.woff") format("woff"),url("../fonts/rubik-v14-latin/rubik-v14-latin-300.ttf") format("truetype"),url("../fonts/rubik-v14-latin/rubik-v14-latin-300.svg#Rubik") format("svg")}:root{--primary: #f8d270}*,body{font-family:"Rubik",sans-serif}body{background-color:#f3f2f2}::-webkit-scrollbar{width:.8vw;height:.8vw}::-webkit-scrollbar-track{background:#f3f2f2;border-radius:.5vw}::-webkit-scrollbar-thumb{background:#ccc;border-radius:.5vw}::-webkit-scrollbar-thumb:hover{background:#999}::-moz-selection{background-color:var(--primary);color:#000}::selection{background-color:var(--primary);color:#000}a{color:var(--bs-primary);text-decoration:none}table .info{margin-left:-240px}video{max-width:100%;box-shadow:0px 2px 5px 0px rgba(0,0,0,.16),0px 2px 10px 0px rgba(0,0,0,.12)}.dim{box-shadow:0 0 0 10000px rgba(0,0,0,.5) !important;box-shadow:0 0 0 100vmax rgba(0,0,0,.5) !important}.table{width:100%;border-collapse:collapse}.table th{background-color:#f2f2f2}.table td,.table th{vertical-align:middle;padding:8px;border:1px solid #ddd;text-align:left}.table tbody>tr>td>a{display:flex;color:#2196f3;padding:.5rem 1rem;transition:.2s}.table tbody>tr>td>a:hover{background-color:rgba(157,220,223,.3);border-radius:.2em}.table tbody>tr>td>a:focus{box-shadow:0 0 0 .3rem rgba(127,190,193,.5)}.table input{padding:10px;max-width:130px;border-style:none;border:1px solid #bbb;border-radius:.25rem;transition:.3s}.table input:focus{color:#495057;background-color:#fff;border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.table .dropdown #dropdownMenuButton{color:#999}.table-title{text-transform:uppercase;font-size:16px;padding:10px;margin:10px 0;color:#2196f3}#main{padding-top:65px;padding-bottom:3rem;padding-left:300px;transition:.5s}@media(max-width: 800px){#main{padding-top:115px}}#top-navbar{position:fixed;top:0;right:0;left:300px;-webkit-margin-start:-10px;z-index:90;background:#f5f5f5;box-shadow:0px 2px 5px 0px rgba(0,0,0,.1);transition:.3s}#top-navbar .nav-wrapper{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center}#top-navbar .nav-wrapper .form-header{display:flex;flex:.8}#top-navbar .nav-wrapper .form-header .au-input{flex:.9}#top-navbar .nav-wrapper .form-header button{flex:.1}#top-navbar .nav-wrapper .toggle-btn{cursor:pointer;padding:.2rem .5rem}#top-navbar .nav-wrapper .toggle-btn:hover{background-color:#fff}#top-navbar.toggle-active{left:0}@media(max-width: 800px){#top-navbar .nav-wrapper .form-header{order:2}#top-navbar .nav-wrapper .toggle-btn{order:1}#top-navbar .nav-wrapper .dropdown{order:3}}.manage-wrap{position:fixed;bottom:0;right:0;left:300px;padding:.5rem;z-index:10;background-color:rgba(255,255,255,.8);border-top:1px solid #6c757d;transition:.3s}.manage-wrap.toggle-active{left:0}.au-input{display:flex;width:auto;line-height:40px;border:1px solid #e5e5e5;font-family:inherit;font-size:13px;color:#666;padding:0 17px;border-radius:3px;transition:all .2s ease}.au-input:focus{border:1px solid #343a40}.au-input--xl{min-width:935px}@media(max-width: 1600px){.au-input--xl{min-width:500px}}@media(max-width: 1000px){.au-input--xl{min-width:150px}}@media(max-width: 767px){.au-input--xl{min-width:150px;max-height:45px}}@media(max-width: 800px){.nav-wrapper .form-header{order:1;width:100%}.nav-wrapper .form-header .au-input--xl{width:100%}.nav-wrapper .toggle-btn{order:2}.nav-wrapper .dropdown{order:3}}.avatar{width:40px;height:40px;border-radius:50%;overflow:hidden}.avatar img{width:100%;height:100%;-o-object-fit:cover;object-fit:cover}.avatar img:hover{filter:contrast(0.9)}.avatar.avatar-md{width:60px;height:60px}.avatar.avatar-lg{width:80px;height:80px}.dropdown-menu{box-shadow:0 0 15px 0 rgba(0,0,0,.3)}.dropdown-menu .dropdown-item{padding-top:8px;padding-bottom:8px}@keyframes grow-top{0%{transform:scale(0.8)}100%{transform:scale(1)}}#side-nav{width:300px;position:fixed;left:0;top:0;bottom:0;display:flex;flex-direction:column;justify-content:space-between;z-index:100;overflow-y:auto;resize:horizontal;background-color:#fff;box-shadow:0px 2px 5px 0px rgba(0,0,0,.16),0px 2px 10px 0px rgba(0,0,0,.12);transition:.3s}#side-nav i{margin-right:8px}#side-nav footer{margin-top:4rem}#side-nav .top-side{background:#f5f5f5;box-shadow:0px 2px 5px 0px rgba(0,0,0,.1);margin-bottom:10px;padding:.5rem 2rem}#side-nav .top-side .desktop-hide{display:none}#side-nav .top-side .desktop-hide .toggle-btn{position:absolute;top:0;right:0;left:0;background-color:#fff;color:#f2f2f2;padding:0 1rem;border-radius:2px;cursor:pointer;margin-left:auto;transition:.5s}#side-nav .top-side .desktop-hide .toggle-btn i{color:#999;margin:0 auto}#side-nav .top-side .desktop-hide .toggle-btn i:hover{color:#666;transition:.2s}#side-nav .top-side .logo img{width:90%}#side-nav ul{padding:0}#side-nav ul li{list-style:none}#side-nav ul li:last-child{border-bottom:none}#side-nav ul li a{display:flex;align-items:center;padding:.8rem 1rem;color:#666;border-radius:0 2em 2em 0;transition:.25s}#side-nav ul li a:hover{color:#007bff;background:rgba(0,111,255,.1)}#side-nav ul li.active a{background:var(--bs-primary);color:#fff}#side-nav.toggle-active{box-shadow:0px 0px 0px 0px #dbdbdb;left:-300px}@media screen and (max-width: 1150px){#side-nav .top-side{padding-top:3rem}#side-nav .top-side .desktop-hide{display:block}}#main.toggle-active{box-shadow:0px 0px 0px 0px #dbdbdb;padding-left:0px}@media screen and (max-width: 1150px){#side-nav{left:-300px}#side-nav.toggle-active{left:0;box-shadow:0 0 0 10000px rgba(0,0,0,.5)}#main{padding-left:0}#top-navbar{left:0}.manage-wrap{left:0}}#input-nav{display:flex;flex-wrap:wrap;align-items:center;padding:.3rem .9rem;margin-bottom:1rem;color:#fd7e14;border-radius:3px;background-color:#fff !important;box-shadow:inset 0 0 2.5rem rgba(0,0,0,.2)}#input-nav a{color:#007bff}#input-nav a:hover{text-decoration:underline}#input-nav a::after{content:">";color:#666;margin:0 5px;vertical-align:middle}.footer{top:100%;bottom:0;display:block;text-align:center;justify-content:center;padding:.75rem 1.25rem;margin-top:4rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.1)}.footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.title-1{position:relative;display:inline-flex;align-items:center;font-family:inherit;text-transform:capitalize;font-weight:700;font-size:24px;margin-bottom:16px;border-radius:.2em}.title-1::before{content:"";position:absolute;bottom:0;right:-5px;width:50%;height:15px;z-index:-1;border-radius:1px;background-color:rgba(83,126,226,.2);animation:lineAnim 1s ease-in forwards}.title-1 i{margin-right:8px}@keyframes lineAnim{0%{width:0px;height:4px}60%{width:50%;height:4px}100%{width:50%;height:15px}}.form-title{display:flex;align-items:center;justify-content:center;font-weight:400;font-size:17px;padding:10px;background:linear-gradient(40deg, #45cafc, #303f9f);color:#fff;text-align:center;margin-bottom:10px}.form-title i{margin-right:8px}.news{background:linear-gradient(40deg, #45cafc, #303f9f) !important;color:#fff}.events{background:linear-gradient(40deg, #ad86f6, #572ca7) !important;color:#fff}.allocate-btn{text-align:center;width:auto;padding:10px 20px;cursor:pointer;color:#5c6ac4;border:1px solid #ddd;border-radius:2rem;transition:.2s}.allocate-btn:hover{background:#ddd}.link{color:#2196f3;margin-top:40px;transition:.2s}.link:hover{color:#0b21ad}.score-wrapper{position:relative;display:flex;width:7rem;height:7rem;overflow:hidden;font-size:.75rem;background-color:#d5dce4;border-radius:50%;box-shadow:0px 0px 3px 10px #f0f0f0}.score-wrapper>.score-wrapper-bar{position:absolute;bottom:0;width:100%;transition:width 6s ease}.score-wrapper>.score-wrapper-text{position:absolute;font-size:20px;height:100%;width:100%;z-index:1;color:#fff;display:flex;justify-content:center;align-items:center}.score-wrapper .bg-success{background-color:#6cbd45 !important}.score-wrapper .bg-warning{background-color:#ffc107 !important}.score-wrapper .bg-danger{background-color:#f53d3d !important}.bg-sub-info{background-color:#35b6cc !important;color:#fff}.main-progress{animation:main-progress1 7s ease-in-out forwards}@keyframes main-progress1{0%{transform:scale(0)}95%{transform:scale(0)}100%{transform:scale(1)}}#progress-card{display:flex;align-items:center;justify-content:center;position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;overflow:hidden;background:#fff}.loader{position:relative;display:flex;justify-content:center;align-items:center;line-height:5.6;animation:loader-in-out 7s ease-in-out forwards}@keyframes loader-in-out{0%{transform:scale(0)}10%{transform:scale(1)}95%{transform:scale(1)}100%{transform:scale(0)}}.progress-bar{animation:loader-bar ease-in-out 3s forwards}@keyframes loader-bar{0%,10%{width:0%}50%,70%{width:50%}80%,95%{width:97%}100%{width:100%}}@media screen and (max-width: 500px){.content-center{display:block}.mobile-hide-500{display:none}.save-btn{font-size:14px}.title-1{font-size:20px}}@media screen and (max-width: 450px){.mobile-hide-450{display:none}}.edit-btn i{margin-right:10px}@media screen and (max-width: 450px){.edit-btn i{margin-right:0}}.switch.switch-text{position:relative;display:inline-block;vertical-align:top;width:48px;height:24px;background-color:rgba(0,0,0,0);cursor:pointer}.switch.switch-text .switch-input{position:absolute;top:0;left:0;opacity:0}.switch.switch-text .switch-label{position:relative;display:block;height:inherit;font-size:10px;font-weight:600;text-transform:uppercase;background-color:#dc3545;border-radius:2px;transition:opacity background-color .15s ease-out}.switch.switch-text .switch-label::before,.switch.switch-text .switch-label::after{position:absolute;top:50%;width:50%;margin-top:-0.5em;line-height:1;text-align:center;transition:inherit}.switch.switch-text .switch-label::before{right:1px;color:#e9ecef;content:attr(data-off)}.switch.switch-text .switch-label::after{left:1px;color:#fff;content:attr(data-on);opacity:0}.switch.switch-text .switch-input:checked~.switch-label::before{opacity:0}.switch.switch-text .switch-input:checked~.switch-label::after{opacity:1}.switch.switch-text .switch-handle{position:absolute;top:2px;left:2px;width:20px;height:20px;background:#fff;border-color:#fff;border-radius:1px;transition:left .15s ease-out}.switch.switch-text .switch-input:checked~.switch-handle{left:26px}.switch.switch-text.switch-lg{width:56px;height:28px}.switch.switch-text.switch-lg .switch-label{font-size:12px}.switch.switch-text.switch-lg .switch-handle{width:24px;height:24px}.switch.switch-text.switch-lg .switch-input:checked~.switch-handle{left:30px}.switch.switch-text.switch-sm{width:40px;height:20px}.switch.switch-text.switch-sm .switch-label{font-size:8px}.switch.switch-text.switch-sm .switch-handle{width:16px;height:16px}.switch.switch-text.switch-sm .switch-input:checked~.switch-handle{left:22px}.switch.switch-text.switch-xs{width:32px;height:16px}.switch.switch-text.switch-xs .switch-label{font-size:7px}.switch.switch-text.switch-xs .switch-handle{width:12px;height:12px}.switch.switch-text.switch-xs .switch-input:checked~.switch-handle{left:18px}.switch-pill .switch-label,.switch.switch-3d .switch-label,.switch-pill .switch-handle,.switch.switch-3d .switch-handle{border-radius:50em !important}.switch-pill .switch-label::before,.switch.switch-3d .switch-label::before{right:2px !important}.switch-pill .switch-label::after,.switch.switch-3d .switch-label::after{left:2px !important}.switch-success>.switch-input:checked~.switch-label{background:#28a745 !important;border-color:#1e7e34}.switch-success>.switch-input:checked~.switch-handle{border-color:#1e7e34}.switch-success-outline>.switch-input:checked~.switch-label{background:#fff !important;border-color:#28a745}.switch-success-outline>.switch-input:checked~.switch-label::after{color:#28a745}.switch-success-outline>.switch-input:checked~.switch-handle{border-color:#28a745}.switch-success-outline-alt>.switch-input:checked~.switch-label{background:#fff !important;border-color:#28a745}.switch-success-outline-alt>.switch-input:checked~.switch-label::after{color:#28a745}.switch-success-outline-alt>.switch-input:checked~.switch-handle{background:#28a745 !important;border-color:#28a745}.fas,.fa{font-size:16px;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;background-color:rgba(0,0,0,.093);border-radius:50%}.fas.unstyled,.fa.unstyled{background-color:unset;border-radius:unset}.card-count .fas,.card-count .fa{font-size:24px;display:initial;align-items:initial;justify-content:initial;width:initial;height:initial;background-color:initial;border-radius:initial}.card-count{display:flex;flex-direction:column;align-items:center;justify-content:center}.card-count .text-right{display:flex;flex-direction:row-reverse;align-items:flex-end;gap:5px}.card-count .text-right h2{margin:0}@media(min-width: 800px){.card-count{display:flex;flex-direction:row;align-items:center;justify-content:space-between}.card-count .text-right{display:block;text-align:end}.card-count .text-right h2{margin:0}.card-count .fas,.card-count .fa{border-right:1px solid #ddd}.users-count .card-count h3{border-right:1px solid #e6e6e6}}.chart-wrap{position:relative;padding:1rem;transition:.5s;background-color:#fff;border-radius:10px}.fa-expand-alt{display:none;position:absolute;top:.5rem;right:.5rem;padding:.5rem;cursor:pointer;transition:.3s}.fa-expand-alt:hover{background-color:#f1f1f1}.chart-wrap:hover{box-shadow:0 0 0 1px inset #666}.chart-wrap:hover .fa-expand-alt{display:block}.expand{transform:translateY(100%);position:fixed;bottom:0;top:3rem;left:0;right:0;width:100%;z-index:999;flex:0 0 100%;background-color:#fff;box-shadow:0 0 0 10000px rgba(0,0,0,.5) !important;box-shadow:0 0 0 100vmax rgba(0,0,0,.5) !important;transform-origin:bottom left;animation:popupAnim forwards alternate .5s ease-in-out;overflow:auto}.expand .fa-expand-alt{display:block}@keyframes popupAnim{from{transform:translateY(100%)}to{transform:translateY(0)}}.users-count .card-count{width:100%;height:100%;display:flex;justify-content:space-between;align-items:center;background-color:#fff}.users-count .card-count h2{font-weight:1000}.users-count .card-count h3{flex:0 0 40%}.users-count .card-count h3 i{display:inline-flex;width:60px;height:60px;display:flex;align-items:center;justify-content:center;border-radius:50%}.bg-light-aqua{background-color:rgba(32,177,177,.8) !important;box-shadow:0 0 0 10px rgba(32,177,177,.2) !important;color:#fff !important}.bg-light-orange{background-color:rgba(253,174,28,.8) !important;box-shadow:0 0 0 10px rgba(253,174,28,.2) !important;color:#fff !important}.bg-light-purple{background-color:rgba(203,31,255,.8) !important;box-shadow:0 0 0 10px rgba(203,31,255,.2) !important;color:#fff !important}.bg-light-red{background-color:rgba(255,19,157,.8) !important;box-shadow:0 0 0 10px rgba(255,19,157,.2) !important;color:#fff !important}.activities ul{padding-left:.5rem}.activities ul li{list-style-type:disc}.top-side{background-size:cover;background-position:top center}.color-indicator{display:inline-block;width:10px;height:10px;border-radius:2px}.bg-purple{background-color:#6f42c1}.card-header-ne{position:relative;display:flex;align-items:center;justify-content:space-between}.card-header-ne .title{vertical-align:middle}.text-danger{color:red}.user-picture{width:100px;height:100px;border:3px solid #fff;margin-top:-50px;-o-object-fit:cover;object-fit:cover}.dashboard-description strong{font-weight:600}.card .h5{font-size:1.25rem;color:#333;margin-top:15px;margin-bottom:15px}.text-danger{color:red}.bg-light-warning{background-color:#fcd96f !important}#progress-main{display:none}.class-item{display:block;border-left:4px solid #6cbd45;padding:1rem !important;background:#f8f9fa;border-radius:3px;box-shadow:0px 2px 5px 0px rgba(0,0,0,.3);transition:.5s}.class-item:hover{transform:translateX(15px)}.class-item p{padding:2px;margin:0;color:#b4b4b4;transition:.5s}.class-item a{padding:2px;color:#343a40;text-decoration:none;transition:.5s}/*# sourceMappingURL=style.min.css.map */ \ No newline at end of file +:root,[data-bs-theme]{--primary: #f8d270;--bs-font-sans-serif:"Inter",sans-serif;--bs-body-font-family:"Inter",sans-serif}body{font-family:"Inter",sans-serif}body{background-color:#f3f2f2}::-webkit-scrollbar{width:.8vw;height:.8vw}::-webkit-scrollbar-track{background:#f3f2f2;border-radius:.5vw}::-webkit-scrollbar-thumb{background:#ccc;border-radius:.5vw}::-webkit-scrollbar-thumb:hover{background:#999}::-moz-selection{background-color:var(--primary);color:#000}::selection{background-color:var(--primary);color:#000}a{color:var(--bs-primary);text-decoration:none}table .info{margin-left:-240px}video{max-width:100%;box-shadow:0px 2px 5px 0px rgba(0,0,0,.16),0px 2px 10px 0px rgba(0,0,0,.12)}.dim{box-shadow:0 0 0 10000px rgba(0,0,0,.5) !important;box-shadow:0 0 0 100vmax rgba(0,0,0,.5) !important}.table{width:100%;border-collapse:collapse}.table th{background-color:#f2f2f2}.table td,.table th{vertical-align:middle;padding:8px;border:1px solid #ddd;text-align:left}.table tbody>tr>td>a{display:flex;color:#2196f3;padding:.5rem 1rem;transition:.2s}.table tbody>tr>td>a:hover{background-color:rgba(157,220,223,.3);border-radius:.2em}.table tbody>tr>td>a:focus{box-shadow:0 0 0 .3rem rgba(127,190,193,.5)}.table input{padding:10px;max-width:130px;border-style:none;border:1px solid #bbb;border-radius:.25rem;transition:.3s}.table input:focus{color:#495057;background-color:#fff;border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.table .dropdown #dropdownMenuButton{color:#999}.table-title{text-transform:uppercase;font-size:16px;padding:10px;margin:10px 0;color:#2196f3}#main{padding-top:65px;padding-bottom:3rem;padding-left:300px;transition:.5s}@media(max-width: 800px){#main{padding-top:115px}}#top-navbar{position:fixed;top:0;right:0;left:300px;-webkit-margin-start:-10px;z-index:90;background:#f5f5f5;box-shadow:0px 2px 5px 0px rgba(0,0,0,.1);transition:.3s}#top-navbar .nav-wrapper{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center}#top-navbar .nav-wrapper .form-header{display:flex;flex:.8}#top-navbar .nav-wrapper .form-header .au-input{flex:.9}#top-navbar .nav-wrapper .form-header button{flex:.1}#top-navbar .nav-wrapper .toggle-btn{cursor:pointer;padding:.2rem .5rem}#top-navbar .nav-wrapper .toggle-btn:hover{background-color:#fff}#top-navbar.toggle-active{left:0}@media(max-width: 800px){#top-navbar .nav-wrapper .form-header{order:2}#top-navbar .nav-wrapper .toggle-btn{order:1}#top-navbar .nav-wrapper .dropdown{order:3}}.manage-wrap{position:fixed;bottom:0;right:0;left:300px;padding:.5rem;z-index:10;background-color:rgba(255,255,255,.8);border-top:1px solid #6c757d;transition:.3s}.manage-wrap.toggle-active{left:0}.au-input{display:flex;width:auto;line-height:40px;border:1px solid #e5e5e5;font-family:inherit;font-size:13px;color:#666;padding:0 17px;border-radius:3px;transition:all .2s ease}.au-input:focus{border:1px solid #343a40}.au-input--xl{min-width:935px}@media(max-width: 1600px){.au-input--xl{min-width:500px}}@media(max-width: 1000px){.au-input--xl{min-width:150px}}@media(max-width: 767px){.au-input--xl{min-width:150px;max-height:45px}}@media(max-width: 800px){.nav-wrapper .form-header{order:1;width:100%}.nav-wrapper .form-header .au-input--xl{width:100%}.nav-wrapper .toggle-btn{order:2}.nav-wrapper .dropdown{order:3}}.avatar{width:40px;height:40px;border-radius:50%;overflow:hidden}.avatar img{width:100%;height:100%;-o-object-fit:cover;object-fit:cover}.avatar img:hover{filter:contrast(0.9)}.avatar.avatar-md{width:60px;height:60px}.avatar.avatar-lg{width:80px;height:80px}.dropdown-menu{box-shadow:0 0 15px 0 rgba(0,0,0,.3)}.dropdown-menu .dropdown-item{padding-top:8px;padding-bottom:8px}@keyframes grow-top{0%{transform:scale(0.8)}100%{transform:scale(1)}}#side-nav{width:300px;position:fixed;left:0;top:0;bottom:0;display:flex;flex-direction:column;justify-content:space-between;z-index:100;overflow-y:auto;resize:horizontal;background-color:#fff;box-shadow:0px 2px 5px 0px rgba(0,0,0,.16),0px 2px 10px 0px rgba(0,0,0,.12);transition:.3s}#side-nav i{margin-right:8px}#side-nav footer{margin-top:4rem}#side-nav .top-side{background:#f5f5f5;box-shadow:0px 2px 5px 0px rgba(0,0,0,.1);margin-bottom:10px;padding:.5rem 2rem}#side-nav .top-side .desktop-hide{display:none}#side-nav .top-side .desktop-hide .toggle-btn{position:absolute;top:0;right:0;left:0;background-color:#fff;color:#f2f2f2;padding:0 1rem;border-radius:2px;cursor:pointer;margin-left:auto;transition:.5s}#side-nav .top-side .desktop-hide .toggle-btn i{color:#999;margin:0 auto}#side-nav .top-side .desktop-hide .toggle-btn i:hover{color:#666;transition:.2s}#side-nav .top-side .logo img{width:90%}#side-nav ul{padding:0}#side-nav ul li{list-style:none}#side-nav ul li:last-child{border-bottom:none}#side-nav ul li a{display:flex;align-items:center;padding:.8rem 1rem;color:#666;border-radius:0 2em 2em 0;transition:.25s}#side-nav ul li a:hover{color:#007bff;background:rgba(0,111,255,.1)}#side-nav ul li.active a{background:var(--bs-primary);color:#fff}#side-nav.toggle-active{box-shadow:0px 0px 0px 0px #dbdbdb;left:-300px}@media screen and (max-width: 1150px){#side-nav .top-side{padding-top:3rem}#side-nav .top-side .desktop-hide{display:block}}#main.toggle-active{box-shadow:0px 0px 0px 0px #dbdbdb;padding-left:0px}@media screen and (max-width: 1150px){#side-nav{left:-300px}#side-nav.toggle-active{left:0;box-shadow:0 0 0 10000px rgba(0,0,0,.5)}#main{padding-left:0}#top-navbar{left:0}.manage-wrap{left:0}}#input-nav{display:flex;flex-wrap:wrap;align-items:center;padding:.3rem .9rem;margin-bottom:1rem;color:#fd7e14;border-radius:3px;background-color:#fff !important;box-shadow:inset 0 0 2.5rem rgba(0,0,0,.2)}#input-nav a{color:#007bff}#input-nav a:hover{text-decoration:underline}#input-nav a::after{content:">";color:#666;margin:0 5px;vertical-align:middle}.footer{top:100%;bottom:0;display:block;text-align:center;justify-content:center;padding:.75rem 1.25rem;margin-top:4rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.1)}.footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.title-1{position:relative;display:inline-flex;align-items:center;font-family:inherit;text-transform:capitalize;font-weight:700;font-size:24px;margin-bottom:16px;border-radius:.2em}.title-1::before{content:"";position:absolute;bottom:0;right:-5px;width:50%;height:15px;z-index:-1;border-radius:1px;background-color:rgba(83,126,226,.2);animation:lineAnim 1s ease-in forwards}.title-1 i{margin-right:8px}@keyframes lineAnim{0%{width:0px;height:4px}60%{width:50%;height:4px}100%{width:50%;height:15px}}.form-title{display:flex;align-items:center;justify-content:center;font-weight:400;font-size:17px;padding:10px;background:linear-gradient(40deg, #45cafc, #303f9f);color:#fff;text-align:center;margin-bottom:10px}.form-title i{margin-right:8px}.news{background:linear-gradient(40deg, #45cafc, #303f9f) !important;color:#fff}.events{background:linear-gradient(40deg, #ad86f6, #572ca7) !important;color:#fff}.allocate-btn{text-align:center;width:auto;padding:10px 20px;cursor:pointer;color:#5c6ac4;border:1px solid #ddd;border-radius:2rem;transition:.2s}.allocate-btn:hover{background:#ddd}.link{color:#2196f3;margin-top:40px;transition:.2s}.link:hover{color:#0b21ad}.score-wrapper{position:relative;display:flex;width:7rem;height:7rem;overflow:hidden;font-size:.75rem;background-color:#d5dce4;border-radius:50%;box-shadow:0px 0px 3px 10px #f0f0f0}.score-wrapper>.score-wrapper-bar{position:absolute;bottom:0;width:100%;transition:width 6s ease}.score-wrapper>.score-wrapper-text{position:absolute;font-size:20px;height:100%;width:100%;z-index:1;color:#fff;display:flex;justify-content:center;align-items:center}.score-wrapper .bg-success{background-color:#6cbd45 !important}.score-wrapper .bg-warning{background-color:#ffc107 !important}.score-wrapper .bg-danger{background-color:#f53d3d !important}.bg-sub-info{background-color:#35b6cc !important;color:#fff}.main-progress{animation:main-progress1 7s ease-in-out forwards}@keyframes main-progress1{0%{transform:scale(0)}95%{transform:scale(0)}100%{transform:scale(1)}}#progress-card{display:flex;align-items:center;justify-content:center;position:fixed;top:0;left:0;right:0;bottom:0;z-index:9999;overflow:hidden;background:#fff}.loader{position:relative;display:flex;justify-content:center;align-items:center;line-height:5.6;animation:loader-in-out 7s ease-in-out forwards}@keyframes loader-in-out{0%{transform:scale(0)}10%{transform:scale(1)}95%{transform:scale(1)}100%{transform:scale(0)}}.progress-bar{animation:loader-bar ease-in-out 3s forwards}@keyframes loader-bar{0%,10%{width:0%}50%,70%{width:50%}80%,95%{width:97%}100%{width:100%}}@media screen and (max-width: 500px){.content-center{display:block}.mobile-hide-500{display:none}.save-btn{font-size:14px}.title-1{font-size:20px}}@media screen and (max-width: 450px){.mobile-hide-450{display:none}}.edit-btn i{margin-right:10px}@media screen and (max-width: 450px){.edit-btn i{margin-right:0}}.switch.switch-text{position:relative;display:inline-block;vertical-align:top;width:48px;height:24px;background-color:rgba(0,0,0,0);cursor:pointer}.switch.switch-text .switch-input{position:absolute;top:0;left:0;opacity:0}.switch.switch-text .switch-label{position:relative;display:block;height:inherit;font-size:10px;font-weight:600;text-transform:uppercase;background-color:#dc3545;border-radius:2px;transition:opacity background-color .15s ease-out}.switch.switch-text .switch-label::before,.switch.switch-text .switch-label::after{position:absolute;top:50%;width:50%;margin-top:-0.5em;line-height:1;text-align:center;transition:inherit}.switch.switch-text .switch-label::before{right:1px;color:#e9ecef;content:attr(data-off)}.switch.switch-text .switch-label::after{left:1px;color:#fff;content:attr(data-on);opacity:0}.switch.switch-text .switch-input:checked~.switch-label::before{opacity:0}.switch.switch-text .switch-input:checked~.switch-label::after{opacity:1}.switch.switch-text .switch-handle{position:absolute;top:2px;left:2px;width:20px;height:20px;background:#fff;border-color:#fff;border-radius:1px;transition:left .15s ease-out}.switch.switch-text .switch-input:checked~.switch-handle{left:26px}.switch.switch-text.switch-lg{width:56px;height:28px}.switch.switch-text.switch-lg .switch-label{font-size:12px}.switch.switch-text.switch-lg .switch-handle{width:24px;height:24px}.switch.switch-text.switch-lg .switch-input:checked~.switch-handle{left:30px}.switch.switch-text.switch-sm{width:40px;height:20px}.switch.switch-text.switch-sm .switch-label{font-size:8px}.switch.switch-text.switch-sm .switch-handle{width:16px;height:16px}.switch.switch-text.switch-sm .switch-input:checked~.switch-handle{left:22px}.switch.switch-text.switch-xs{width:32px;height:16px}.switch.switch-text.switch-xs .switch-label{font-size:7px}.switch.switch-text.switch-xs .switch-handle{width:12px;height:12px}.switch.switch-text.switch-xs .switch-input:checked~.switch-handle{left:18px}.switch-pill .switch-label,.switch.switch-3d .switch-label,.switch-pill .switch-handle,.switch.switch-3d .switch-handle{border-radius:50em !important}.switch-pill .switch-label::before,.switch.switch-3d .switch-label::before{right:2px !important}.switch-pill .switch-label::after,.switch.switch-3d .switch-label::after{left:2px !important}.switch-success>.switch-input:checked~.switch-label{background:#28a745 !important;border-color:#1e7e34}.switch-success>.switch-input:checked~.switch-handle{border-color:#1e7e34}.switch-success-outline>.switch-input:checked~.switch-label{background:#fff !important;border-color:#28a745}.switch-success-outline>.switch-input:checked~.switch-label::after{color:#28a745}.switch-success-outline>.switch-input:checked~.switch-handle{border-color:#28a745}.switch-success-outline-alt>.switch-input:checked~.switch-label{background:#fff !important;border-color:#28a745}.switch-success-outline-alt>.switch-input:checked~.switch-label::after{color:#28a745}.switch-success-outline-alt>.switch-input:checked~.switch-handle{background:#28a745 !important;border-color:#28a745}.fas,.fa{font-size:16px;display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;background-color:rgba(0,0,0,.093);border-radius:50%}.fas.unstyled,.fa.unstyled{background-color:unset;border-radius:unset}.card-count .fas,.card-count .fa{font-size:24px;display:initial;align-items:initial;justify-content:initial;width:initial;height:initial;background-color:initial;border-radius:initial}.card-count{display:flex;flex-direction:column;align-items:center;justify-content:center}.card-count .text-right{display:flex;flex-direction:row-reverse;align-items:flex-end;gap:5px}.card-count .text-right h2{margin:0}@media(min-width: 800px){.card-count{display:flex;flex-direction:row;align-items:center;justify-content:space-between}.card-count .text-right{display:block;text-align:end}.card-count .text-right h2{margin:0}.card-count .fas,.card-count .fa{border-right:1px solid #ddd}.users-count .card-count h3{border-right:1px solid #e6e6e6}}.chart-wrap{position:relative;padding:1rem;transition:.5s;background-color:#fff;border-radius:10px}.fa-expand-alt{display:none;position:absolute;top:.5rem;right:.5rem;padding:.5rem;cursor:pointer;transition:.3s}.fa-expand-alt:hover{background-color:#f1f1f1}.chart-wrap:hover{box-shadow:0 0 0 1px inset #666}.chart-wrap:hover .fa-expand-alt{display:block}.expand{transform:translateY(100%);position:fixed;bottom:0;top:3rem;left:0;right:0;width:100%;z-index:999;flex:0 0 100%;background-color:#fff;box-shadow:0 0 0 10000px rgba(0,0,0,.5) !important;box-shadow:0 0 0 100vmax rgba(0,0,0,.5) !important;transform-origin:bottom left;animation:popupAnim forwards alternate .5s ease-in-out;overflow:auto}.expand .fa-expand-alt{display:block}@keyframes popupAnim{from{transform:translateY(100%)}to{transform:translateY(0)}}.users-count .card-count{width:100%;height:100%;display:flex;justify-content:space-between;align-items:center;background-color:#fff}.users-count .card-count h2{font-weight:1000}.users-count .card-count h3{flex:0 0 40%}.users-count .card-count h3 i{display:inline-flex;width:60px;height:60px;display:flex;align-items:center;justify-content:center;border-radius:50%}.bg-light-aqua{background-color:rgba(32,177,177,.8) !important;box-shadow:0 0 0 10px rgba(32,177,177,.2) !important;color:#fff !important}.bg-light-orange{background-color:rgba(253,174,28,.8) !important;box-shadow:0 0 0 10px rgba(253,174,28,.2) !important;color:#fff !important}.bg-light-purple{background-color:rgba(203,31,255,.8) !important;box-shadow:0 0 0 10px rgba(203,31,255,.2) !important;color:#fff !important}.bg-light-red{background-color:rgba(255,19,157,.8) !important;box-shadow:0 0 0 10px rgba(255,19,157,.2) !important;color:#fff !important}.activities ul{padding-left:.5rem}.activities ul li{list-style-type:disc}.top-side{background-size:cover;background-position:top center}.color-indicator{display:inline-block;width:10px;height:10px;border-radius:2px}.bg-purple{background-color:#6f42c1}.card-header-ne{position:relative;display:flex;align-items:center;justify-content:space-between}.card-header-ne .title{vertical-align:middle}.text-danger{color:red}.user-picture{width:100px;height:100px;border:3px solid #fff;margin-top:-50px;-o-object-fit:cover;object-fit:cover}.dashboard-description strong{font-weight:600}.card .h5{font-size:1.25rem;color:#333;margin-top:15px;margin-bottom:15px}.text-danger{color:red}.bg-light-warning{background-color:#fcd96f !important}#progress-main{display:none}.class-item{display:block;border-left:4px solid #6cbd45;padding:1rem !important;background:#f8f9fa;border-radius:3px;box-shadow:0px 2px 5px 0px rgba(0,0,0,.3);transition:.5s}.class-item:hover{transform:translateX(15px)}.class-item p{padding:2px;margin:0;color:#b4b4b4;transition:.5s}.class-item a{padding:2px;color:#343a40;text-decoration:none;transition:.5s}/*# sourceMappingURL=style.min.css.map */ \ No newline at end of file diff --git a/static/fonts/rubik-v14-latin/rubik-v14-latin-300.svg b/static/fonts/rubik-v14-latin/rubik-v14-latin-300.svg deleted file mode 100644 index 32ef9d3e..00000000 --- a/static/fonts/rubik-v14-latin/rubik-v14-latin-300.svg +++ /dev/null @@ -1,453 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/static/fonts/rubik-v14-latin/rubik-v14-latin-regular.svg b/static/fonts/rubik-v14-latin/rubik-v14-latin-regular.svg deleted file mode 100644 index 8f465486..00000000 --- a/static/fonts/rubik-v14-latin/rubik-v14-latin-regular.svg +++ /dev/null @@ -1,453 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/static/img/brand_new.jpg b/static/img/brand_new.jpg new file mode 100644 index 00000000..edcebde6 Binary files /dev/null and b/static/img/brand_new.jpg differ diff --git a/static/img/favicon_new.jpg b/static/img/favicon_new.jpg new file mode 100644 index 00000000..44af8a5a Binary files /dev/null and b/static/img/favicon_new.jpg differ diff --git a/static/js/chatbot.js b/static/js/chatbot.js new file mode 100644 index 00000000..f39c7df1 --- /dev/null +++ b/static/js/chatbot.js @@ -0,0 +1,171 @@ +/* ========================================================= + SkyLearn AI Chatbot – Client Logic + ========================================================= */ +(function () { + "use strict"; + + // Detect language prefix from current URL (i18n_patterns support) + var langMatch = window.location.pathname.match(/^\/([a-z]{2}(?:-[a-z]{2})?)\//); + var langPrefix = langMatch ? "/" + langMatch[1] : ""; + var CHATBOT_API = langPrefix + "/ai/chatbot/"; + const MAX_HISTORY = 20; // keep last N messages for context + + // DOM elements + const toggleBtn = document.getElementById("chatbot-toggle"); + const chatWindow = document.getElementById("chatbot-window"); + const messagesDiv = document.getElementById("chatbot-messages"); + const form = document.getElementById("chatbot-form"); + const input = document.getElementById("chatbot-input"); + const sendBtn = document.getElementById("chatbot-send"); + const clearBtn = document.getElementById("chatbot-clear"); + const minimizeBtn = document.getElementById("chatbot-minimize"); + const badge = document.getElementById("chatbot-badge"); + const iconOpen = toggleBtn.querySelector(".chatbot-icon-open"); + const iconClose = toggleBtn.querySelector(".chatbot-icon-close"); + + let isOpen = false; + let history = []; // {role, content} + + // ---- Toggle window ---- + function openChat() { + isOpen = true; + chatWindow.classList.remove("d-none"); + iconOpen.classList.add("d-none"); + iconClose.classList.remove("d-none"); + badge.classList.add("d-none"); + input.focus(); + scrollToBottom(); + } + + function closeChat() { + isOpen = false; + chatWindow.classList.add("d-none"); + iconOpen.classList.remove("d-none"); + iconClose.classList.add("d-none"); + } + + toggleBtn.addEventListener("click", function () { + isOpen ? closeChat() : openChat(); + }); + + minimizeBtn.addEventListener("click", closeChat); + + // ---- Input handling ---- + input.addEventListener("input", function () { + sendBtn.disabled = !input.value.trim(); + }); + + // ---- Send message ---- + form.addEventListener("submit", function (e) { + e.preventDefault(); + const text = input.value.trim(); + if (!text) return; + + appendMessage("user", text); + history.push({ role: "user", content: text }); + input.value = ""; + sendBtn.disabled = true; + + showTyping(); + fetchReply(text); + }); + + // ---- API call ---- + function fetchReply(message) { + const csrfToken = getCookie("csrftoken"); + const payload = { + message: message, + history: history.slice(-MAX_HISTORY), + }; + + fetch(CHATBOT_API, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": csrfToken, + }, + body: JSON.stringify(payload), + }) + .then(function (res) { + if (!res.ok) throw new Error("HTTP " + res.status); + return res.json(); + }) + .then(function (data) { + hideTyping(); + if (data.reply) { + appendMessage("bot", data.reply); + history.push({ role: "assistant", content: data.reply }); + } else if (data.error) { + appendMessage("bot", "⚠️ " + data.error); + } + }) + .catch(function () { + hideTyping(); + appendMessage("bot", "⚠️ Unable to connect. Please try again later."); + }); + } + + // ---- DOM helpers ---- + function appendMessage(role, text) { + var msgDiv = document.createElement("div"); + msgDiv.className = "chatbot-msg chatbot-msg-" + (role === "user" ? "user" : "bot"); + + var avatarDiv = document.createElement("div"); + avatarDiv.className = "chatbot-msg-avatar"; + avatarDiv.innerHTML = + role === "user" + ? '' + : ''; + + var bubbleDiv = document.createElement("div"); + bubbleDiv.className = "chatbot-msg-bubble"; + bubbleDiv.textContent = text; + + msgDiv.appendChild(avatarDiv); + msgDiv.appendChild(bubbleDiv); + messagesDiv.appendChild(msgDiv); + scrollToBottom(); + } + + function showTyping() { + var existing = document.getElementById("chatbot-typing"); + if (existing) return; + + var typingDiv = document.createElement("div"); + typingDiv.id = "chatbot-typing"; + typingDiv.className = "chatbot-msg chatbot-msg-bot"; + typingDiv.innerHTML = + '
      ' + + '
      ' + + "" + + "
      "; + messagesDiv.appendChild(typingDiv); + scrollToBottom(); + } + + function hideTyping() { + var el = document.getElementById("chatbot-typing"); + if (el) el.remove(); + } + + function scrollToBottom() { + messagesDiv.scrollTop = messagesDiv.scrollHeight; + } + + // ---- Clear chat ---- + clearBtn.addEventListener("click", function () { + // Keep only the initial greeting + while (messagesDiv.children.length > 1) { + messagesDiv.removeChild(messagesDiv.lastChild); + } + history = []; + }); + + // ---- CSRF helper ---- + function getCookie(name) { + var value = "; " + document.cookie; + var parts = value.split("; " + name + "="); + if (parts.length === 2) return parts.pop().split(";").shift(); + return ""; + } +})(); diff --git a/static/scss/style.scss b/static/scss/style.scss index 11c54467..83196b44 100644 --- a/static/scss/style.scss +++ b/static/scss/style.scss @@ -1,30 +1,12 @@ -/* rubik-300 - latin */ -@font-face { - font-family: "Rubik"; - font-style: normal; - font-weight: 300; - src: url("../fonts/rubik-v14-latin/rubik-v14-latin-300.eot"); /* IE9 Compat Modes */ - src: local(""), - url("../fonts/rubik-v14-latin/rubik-v14-latin-300.eot?#iefix") - format("embedded-opentype"), - /* IE6-IE8 */ url("../fonts/rubik-v14-latin/rubik-v14-latin-300.woff2") - format("woff2"), - /* Super Modern Browsers */ - url("../fonts/rubik-v14-latin/rubik-v14-latin-300.woff") format("woff"), - /* Modern Browsers */ - url("../fonts/rubik-v14-latin/rubik-v14-latin-300.ttf") format("truetype"), - /* Safari, Android, iOS */ - url("../fonts/rubik-v14-latin/rubik-v14-latin-300.svg#Rubik") - format("svg"); /* Legacy iOS */ -} - -:root { +:root, +[data-bs-theme] { --primary: #f8d270; + --bs-font-sans-serif: "Inter", sans-serif; + --bs-body-font-family: "Inter", sans-serif; } -*, body { - font-family: "Rubik", sans-serif; + font-family: "Inter", sans-serif; } body { background-color: #f3f2f2; diff --git a/templates/accounts/add_student.html b/templates/accounts/add_student.html index 851a9adb..99d584a2 100644 --- a/templates/accounts/add_student.html +++ b/templates/accounts/add_student.html @@ -40,7 +40,6 @@

      {% trans 'Stud

      {% trans 'Others' %}

      {{ form.program|as_crispy_field }} - {{ form.level|as_crispy_field }}
      diff --git a/templates/accounts/profile.html b/templates/accounts/profile.html index b1260219..ae52b434 100644 --- a/templates/accounts/profile.html +++ b/templates/accounts/profile.html @@ -83,7 +83,7 @@

      {% trans 'Applicant Info' %}

      {% trans 'School:' %} {% trans 'Hawas Preparatory School' %}

      -

      {% trans 'Level:' %} {{ level.level }}

      +
      {% endif %} diff --git a/templates/accounts/profile_single.html b/templates/accounts/profile_single.html index 89f8e93f..9936df08 100644 --- a/templates/accounts/profile_single.html +++ b/templates/accounts/profile_single.html @@ -96,7 +96,7 @@

      {% trans 'Applicant Info' %}

      {% trans 'School' %}: Unity College

      -

      {% trans 'Level' %}: {{ level.level }}

      +

      {% trans 'Program' %}: {{ student.program }}

      {% endif %} diff --git a/templates/base.html b/templates/base.html index 4e8249ff..1961ff8f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -10,7 +10,12 @@ {% block title %}DjangoSMS{% endblock title %} - + + + + + + @@ -20,6 +25,8 @@ + + {% block header %}{% endblock %} @@ -47,6 +54,13 @@ + {% if user.is_authenticated %} + {% if request.resolver_match.url_name != 'quiz_take' %} + {% include 'snippets/chatbot_widget.html' %} + + {% endif %} + {% endif %} + {% block js %} {% endblock js %} diff --git a/templates/course/course_add.html b/templates/course/course_add.html index e0ae187c..419133ed 100644 --- a/templates/course/course_add.html +++ b/templates/course/course_add.html @@ -40,7 +40,6 @@ {{ form.credit|as_crispy_field }} {{ form.year|as_crispy_field }} {{ form.semester|as_crispy_field }} - {{ form.level|as_crispy_field }} {{ form.is_elective|as_crispy_field }} diff --git a/templates/course/course_registration.html b/templates/course/course_registration.html index 3afbec0d..19b2af47 100644 --- a/templates/course/course_registration.html +++ b/templates/course/course_registration.html @@ -185,7 +185,7 @@

      {% trans 'Check the university calender' %}

      {% trans 'Course Drop' %}

      -

      {{ student.level }}

      +
      {% csrf_token %}
      diff --git a/templates/course/course_single.html b/templates/course/course_single.html index d2612509..c58d7985 100644 --- a/templates/course/course_single.html +++ b/templates/course/course_single.html @@ -32,7 +32,7 @@ {% trans 'Upload new video' %} - + {% endif %}
      @@ -52,7 +52,9 @@
      -

      {% trans 'Video Tutorials' %}

      +
      +
      {% trans 'Video Tutorials' %}
      +
      @@ -128,7 +130,14 @@
      -

      {% trans 'Documentations' %}

      +
      +
      {% trans 'Documentations' %}
      + {% if request.user.is_superuser or request.user.is_lecturer %} + + {% endif %} +
      @@ -147,10 +156,18 @@ {% for file in files %} - @@ -182,14 +199,12 @@ @@ -240,4 +255,321 @@
      {% trans 'No lecturer assigned for this course' %} + + + + + + + + + + + + {% endblock content %} \ No newline at end of file diff --git a/templates/course/program_single.html b/templates/course/program_single.html index 53c8e825..b533e52e 100644 --- a/templates/course/program_single.html +++ b/templates/course/program_single.html @@ -43,7 +43,6 @@
      - @@ -60,7 +59,6 @@ {{ course.title }} - {% empty %} -
      {{ forloop.counter }} + + {% if file.html_content %} + + + {{ file.title|title }} + + {% else %} + {{ file.title|title }} + {% endif %} {{ file.upload_time|date }} {{ file.updated_date|date }} - {% trans 'No File Uploaded.' %} + No lessons available. {% if request.user.is_superuser or request.user.is_lecturer %} - - - {% trans 'Upload now.' %} - - {% endif %} + + Create one AI Lesson now. + {% endif %} {% trans 'Course Name' %} {% trans 'Course Code' %} {% trans 'Cr.Hr' %} {% trans 'Level' %} {% trans 'Year' %} {% trans 'Semester' %} {% trans 'Current Semester' %} {{ course.code }} {{ course.credit }}{{ course.level }} {{ course.year }} {{ course.semester }} @@ -91,7 +89,7 @@
      + {% trans 'No course for this progrm.' %} {% if request.user.is_superuser %} diff --git a/templates/navbar.html b/templates/navbar.html index dece5ec0..6853536a 100644 --- a/templates/navbar.html +++ b/templates/navbar.html @@ -17,12 +17,16 @@ - + + + +
      + + - + + +
      + + diff --git a/templates/quiz/quiz_list.html b/templates/quiz/quiz_list.html index f3b19d5a..a3c0db08 100644 --- a/templates/quiz/quiz_list.html +++ b/templates/quiz/quiz_list.html @@ -38,15 +38,17 @@ {% for quiz in quizzes %}
      -
      +
      {{ quiz.category|title }} {% trans 'Quiz' %}
      {{ quiz.get_questions.count }} {% trans 'Questions' %}
      -
      {{ quiz.title|title }}
      + +
      {{ quiz.title|title }}
      + {% if quiz.description %}

      {{ quiz.description }}

      {% else %} @@ -57,12 +59,24 @@
      {{ quiz.title|title }}

      {% trans "You will only get one attempt at this quiz" %}.

      {% endif %} -
      - {% trans "Start quiz" %} » + + {% if request.user.is_superuser or request.user.is_lecturer %} + + + Manage Questions + + {% endif %} + + + + {% trans "Start quiz" %} » + {% if request.user.is_superuser or request.user.is_lecturer %}
      - + {% endif %} -
      diff --git a/templates/quiz/quiz_questions.html b/templates/quiz/quiz_questions.html new file mode 100644 index 00000000..2ecaa7dd --- /dev/null +++ b/templates/quiz/quiz_questions.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block content %} +
      +

      Questions for {{ quiz.title }}

      + + + + + + + + + + + {% for question in questions %} + + + + + + {% empty %} + + + + {% endfor %} + +
      #QuestionActions
      {{ forloop.counter }}{{ question.content }} + + Edit + +
      No questions found.
      +
      +{% endblock %} \ No newline at end of file diff --git a/templates/registration/register.html b/templates/registration/register.html index 7446844f..b06be977 100644 --- a/templates/registration/register.html +++ b/templates/registration/register.html @@ -58,10 +58,6 @@

      {% trans 'Personal Info' %}

      {{ form.gender }}
      -
      - - {{ form.level }} -
      {{ form.program }} diff --git a/templates/result/assessment_results.html b/templates/result/assessment_results.html index 787880d6..1be7b004 100644 --- a/templates/result/assessment_results.html +++ b/templates/result/assessment_results.html @@ -14,7 +14,7 @@ {% include 'snippets/messages.html' %}
      {% trans 'Assesment Results' %}
      -

      {{ student.level }} {% trans 'Result' %}

      +
      {% trans 'First Semester:' %}
      diff --git a/templates/result/grade_results.html b/templates/result/grade_results.html index 08050eac..c829a7b4 100644 --- a/templates/result/grade_results.html +++ b/templates/result/grade_results.html @@ -14,7 +14,7 @@ {% include 'snippets/messages.html' %}
      {% trans 'Grade Results' %}
      -

      {{ student.level }} {% trans 'Result' %}

      +
      {% trans 'First Semester:' %}
      diff --git a/templates/sidebar.html b/templates/sidebar.html index 58997af5..21df8308 100644 --- a/templates/sidebar.html +++ b/templates/sidebar.html @@ -10,7 +10,7 @@
      - SkyLearn + SkyLearn

      diff --git a/templates/snippets/chatbot_widget.html b/templates/snippets/chatbot_widget.html new file mode 100644 index 00000000..6d86ba5d --- /dev/null +++ b/templates/snippets/chatbot_widget.html @@ -0,0 +1,67 @@ +{% load static %} +{% load i18n %} + + +

      + + + + +
      + +
      +
      +
      + +
      +
      +
      SkyLearn AI
      +
      + {% trans 'Online' %} +
      +
      +
      +
      + + +
      +
      + + +
      +
      +
      +
      + {% trans 'Hi there' %} 👋 {% trans "I'm your SkyLearn AI Assistant. How can I help you today?" %} +
      +
      +
      + + +
      +
      +
      + + +
      +
      +
      +
      +