From ab9c1c4b0925d0a8270dae308d816f22c8c78e9a Mon Sep 17 00:00:00 2001 From: vikrantwiz02 Date: Tue, 17 Feb 2026 20:05:43 +0530 Subject: [PATCH 1/7] Update README with module-wise Git workflow guide --- README.md | 252 +++++++++++++++++++++++++++++++++++------------------- 1 file changed, 164 insertions(+), 88 deletions(-) diff --git a/README.md b/README.md index f9d929d53..adf76564d 100644 --- a/README.md +++ b/README.md @@ -1,116 +1,192 @@ -# FusionIIIT +# FusionIIIT (Backend) **FusionIIIT** is the automation of various functionalities, modules and tasks of/for **PDPM Indian Institute of Information Technology, Design and Manufacturing, Jabalpur** being developed in `python3.8` and using `Django` Webframework. +## Critical Prerequisites + +**You MUST strictly use the following versions:** + +* Python `3.8.10` +* pip `21.1.1` +* PostgreSQL `14` + ## System Configuration * Ubuntu `20.04` **(Recommended)** -* *OR* WSL for Windows `10` \(Follow the guide below\) : - [Windows Subsystem for Linux Installation Guide for Windows 10](https://docs.microsoft.com/en-us/windows/wsl/install-win10) +* *OR* WSL for Windows `10` \(Follow the guide below\) :[Windows Subsystem for Linux Installation Guide for Windows 10](https://docs.microsoft.com/en-us/windows/wsl/install-win10) * *OR* Windows `7/8/8.1/10` ## Software Requirements -* Python `3.8` +* Python `3.8.10` +* pip `21.1.1` +* PostgreSQL `14` * Git +## Module-Wise Sync Targets + +For production synchronization targets, refer to: [Fusion-README](https://github.com/FusionIIIT/Fusion-README) + ## Contributing Guidelines For contributing to this repository, you have to follow the guidelines given in [CONTRIBUTING.md](./CONTRIBUTING.md) and [CODE_OF_CONDUCT.md](./CODE_OF_CONDUCT.md) for smooth workflow of contributions and changes inside repository. +## Full-Stack Module-Wise Git Workflow Guide + +This section outlines the repository setup, branch management, and contribution workflow for teams working on the Fusion ERP Backend. + +### Phase 1: Team Lead Setup + +1. **Fork the Main Repository:** + + * Go to [https://github.com/FusionIIIT/Fusion](https://github.com/FusionIIIT/Fusion) and click **Fork, Uncheck** the **Checkbox,** and click **Create fork.** +2. **Share:** + + * Distribute your forked repository URL to your team members + +### Phase 2: Team Member Setup + +1. **Fork the Team Lead's Repository:** + + * Navigate to your Team Lead's fork and fork it to your own GitHub account +2. **Clone Locally:** + + ```sh + git clone https://github.com//Fusion.git + ``` +3. **Set Upstream:** + + ```sh + cd Fusion + git remote add upstream https://github.com//Fusion.git + ``` + +### Phase 3: Module-Wise Branch Switching + +**Note:** v1 (MANUAL : Work on Existing Codebase) and v2 (AI : Work from Scracth according to documnets and Fusion README) - If you are assigned to the AI group, replace `v1` with `v2` in the commands below. + +Fetch upstream data first: + +```sh +cd Fusion +git fetch upstream +``` + +Then run your specific module command: + +* **Examination:** `git checkout -b examination-v1 upstream/examination-v1` +* **LMS:** `git checkout -b lms-v1 upstream/lms-v1` +* **Award & Scholarship:** `git checkout -b scholarships-v1 upstream/scholarships-v1` +* **Department:** `git checkout -b department-v1 upstream/department-v1` +* **Other Academic Procedure:** `git checkout -b academic-procedures-v1 upstream/academic-procedures-v1` +* **Announcements:** `git checkout -b announcements-v1 upstream/announcements-v1` +* **Placement Cell + PBI:** `git checkout -b placement-pbi-v1 upstream/placement-pbi-v1` +* **Gymkhana:** `git checkout -b gymkhana-v1 upstream/gymkhana-v1` +* **Primary Health Center:** `git checkout -b health-center-v1 upstream/health-center-v1` +* **Hostel Management:** `git checkout -b hostel-management-v1 upstream/hostel-management-v1` +* **Mess Management:** `git checkout -b mess-management-v1 upstream/mess-management-v1` +* **Visitor Hostel:** `git checkout -b visitor-hostel-v1 upstream/visitor-hostel-v1` +* **Visitor Management System:** `git checkout -b visitor-management-v1 upstream/visitor-management-v1` +* **Dashboards:** `git checkout -b dashboards-v1 upstream/dashboards-v1` +* **File Tracking System:** `git checkout -b file-tracking-v1 upstream/file-tracking-v1` +* **RSPC:** `git checkout -b rspc-v1 upstream/rspc-v1` +* **P&S Management:** `git checkout -b ps-management-v1 upstream/ps-management-v1` +* **HR (EIS):** `git checkout -b hr-eis-v1 upstream/hr-eis-v1` +* **Patent Management System:** `git checkout -b patent-management-v1 upstream/patent-management-v1` +* **Institute Works Department:** `git checkout -b institute-works-v1 upstream/institute-works-v1` +* **Internal Audit and Accounts:** `git checkout -b audit-accounts-v1 upstream/audit-accounts-v1` +* **Complaint Management:** `git checkout -b complaint-management-v1 upstream/complaint-management-v1` + ## How to get started * on **Ubuntu**: - ```sh - // Install the required packages using the following command: - - sudo apt install python3-pip python3-dev python3-venv libpq-dev build-essential git - sudo -H pip3 install --upgrade pip - ``` + ```sh + // Install the required packages using the following command: + sudo apt install python3-pip python3-dev python3-venv libpq-dev build-essential git + sudo -H pip3 install --upgrade pip + ``` * on **Windows**: - * Get Python 3.8 from [here](https://www.python.org/ftp/python/3.8.3/python-3.8.3-amd64.exe) for AMD64/x64 or [here](https://www.python.org/ftp/python/3.8.3/python-3.8.3.exe) for x86 + * Get Python 3.8.10 from [here](https://www.python.org/downloads/release/python-3810/) * Git from [here](https://git-scm.com/download/win) - * Install both using the downloaded `exe` files - **Important:** Make sure to check the box that says **Add Python 3.x to PATH** to ensure that the interpreter will be placed in your execution path - -### Downloading the Code - -* Go to () and click on **Fork** -* You will be redirected to *your* fork, `https://github.com//Fusion` -* Open the terminal, change to the directory where you want to clone the **Fusion** repository -* Clone your repository using `git clone https://github.com//Fusion` -* Enter the cloned directory using `cd Fusion/` + * Install both using the downloaded `exe` files + **Important:** Make sure to check the box that says **Add Python 3.x to PATH** to ensure that the interpreter will be placed in your execution path ### Setting up environment -* Create a virtual environment - * on **Ubuntu**: `python3 -m venv env` - * on **Windows PowerShell**: `python -m venv env` -* Activate the *env* +* Create a virtual environment, run below command inside Fusion Directory or root. + * on **Ubuntu**: `python3 -m venv env` + * on **Windows PowerShell**: `python -m venv env OR py -3.8 -m venv env` +* Activate the *env* * on **Ubuntu**: `source env/bin/activate` - * on **Windows PowerShell**: `.\env\Scripts\Activate.ps1` - **Note** : On Windows, it may be required to enable the Activate.ps1 script by setting the execution policy for the user. You can do this by issuing the following command: `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` -* Install the requirements: `pip install -r requirements.txt` + * on **Windows PowerShell**: `.\env\Scripts\Activate.ps1` + **Note** : On Windows, it may be required to enable the Activate.ps1 script by setting the execution policy for the user. You can do this by issuing the following command: `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser` + +### Installing Packages + +Navigate to the Fusion directory and install requirements: + +```sh +cd Fusion +pip install -r requirements.txt +``` ### Running server * Change directory to **FusionIIIT** `cd FusionIIIT` * Run the server `python manage.py runserver` -## Working with Code \(Method 1\) +## Phase 4: Syncing, Committing, and PRs -### Setting upstream +### Production Sync Targets -* `git remote add upstream https://github.com/FusionIIIT/Fusion` - * Adds the remote repository (the repository you forked from) so that changes can be pulled from/pushed to it +* **Backend Production Branch:** `prod/acad-react` -### Switching branch +### Workflow -* `git checkout -b ` - * Creates a new branch `` in your repository -* `git checkout ` - * Switches to the branch you just created - -### Migrating Changes (Database) +1. **Sync with Production:** -* Make migrations `$ python manage.py makemigrations` -* Migrate the changes to the database `$ python manage.py migrate` + * Frequently pull the latest changes from your specific module's production branch to avoid merge conflicts later + * Team lead syncs first, then team members sync from the team lead's fork -### Committing + ```sh + git pull upstream + ``` +2. **Make Changes:** -* `git add .` - * Adds the changes to the staging area -* `git commit` - * Commits the staged changes + * Make your code changes and commit them locally to your active module branch +3. **Migrating Database Changes:** -### Syncing + * Make migrations: `python manage.py makemigrations` + * Migrate the changes to the database: `python manage.py migrate` +4. **Committing:** -#### Pulling + ```sh + git add . + git commit -m "Your descriptive commit message" + ``` +5. **Pushing:** -* `git pull upstream master` - * Pulls the changes from the *upstream* master branch + ```sh + git push origin + ``` +6. **Create Pull Request:** -#### Pushing - -* `git push -u origin ` - * Pushes the changes to your repository **\(First time only\)**; using `git push` is sufficient later on -* Go to `https://github.com//Fusion/tree/` and create pull request + * Go to `https://github.com//Fusion/tree/` and create a Pull Request + * **Important:** Target the Team Lead's fork, NOT the main FusionIIIT repository ## Working with Code \(Alternative\) -* **(Recommended)** Use [Visual Studio Code](https://code.visualstudio.com/) as a text editor. Go through the [Tutorial](https://code.visualstudio.com/docs/python/python-tutorial) for getting started with **Visual Studio Code for Python**. -**Note** : Use the following guide if using **WSL** for Development - () -* Use the inbuilt **Source Control** feature for checking out, committing, pushing, pulling changes. You can also use [Github Desktop](https://desktop.github.com/) **_\(Windows/Mac only\)_**. -* Refer to below link for best practices regarding commit messages : - () - -## Testing Procedure: +* **(Recommended)** Use [Visual Studio Code](https://code.visualstudio.com/) as a text editor. Go through the [Tutorial](https://code.visualstudio.com/docs/python/python-tutorial) for getting started with **Visual Studio Code for Python**.**Note** : Use the following guide if using **WSL** for Development([https://code.visualstudio.com/docs/remote/wsl](https://code.visualstudio.com/docs/remote/wsl)) +* Use the inbuilt **Source Control** feature for checking out, committing, pushing, pulling changes. You can also use [Github Desktop](https://desktop.github.com/) **_\(Windows/Mac only\)_**. +* Refer to below link for best practices regarding commit messages : + ([https://gist.github.com/robertpainsi/b632364184e70900af4ab688decf6f53](https://gist.github.com/robertpainsi/b632364184e70900af4ab688decf6f53)) + +## Testing Procedure: -### Selenium-webdriver +### Selenium-webdriver Selenium is a browser automation library. Most often used for testing web-applications, Selenium may be used for any task that requires automating @@ -120,7 +196,7 @@ interaction with the browser. You can visit Selenium Official website and can download the language-specific client drivers(Java in our case) - +[https://selenium-release.storage.googleapis.com/3.141/selenium-java-3.141.59.zip](https://selenium-release.storage.googleapis.com/3.141/selenium-java-3.141.59.zip) You will need to download additional components to work with each of the major browsers. The drivers for Chrome, Firefox, and Microsoft's IE and Edge web @@ -129,47 +205,47 @@ browsers are all standalone executables that should be placed on your system macOS Sierra. You will need to enable Remote Automation in the Develop menu of Safari 10 before testing. - -| Browser | Component | -| ----------------- | ---------------------------------- | -| Chrome | [ChromeDriver](https://chromedriver.storage.googleapis.com/index.html?path=83.0.4103.39/) | -| Internet Explorer | [IEDriverServer](http://selenium-release.storage.googleapis.com/index.html?path=2.39/) | -| Firefox | [GeckoDriver](https://chromedriver.storage.googleapis.com/index.html?path=83.0.4103.39/) | +| Browser | Component | +| ----------------- | -------------------------------------------------------------------------------------- | +| Chrome | [ChromeDriver](https://chromedriver.storage.googleapis.com/index.html?path=83.0.4103.39/) | +| Internet Explorer | [IEDriverServer](http://selenium-release.storage.googleapis.com/index.html?path=2.39/) | +| Firefox | [GeckoDriver](https://chromedriver.storage.googleapis.com/index.html?path=83.0.4103.39/) | ### Add the Cucumber Eclipse Plugin for BDD testing + * Install the Cucumber Eclipse Plugin from Eclipse MarketPlace under help ### Getting Started + * Open the Test folder in Eclipse IDE(You are free to use any IDE) + * Open the pom.xml and build the project - * Change the driver path in System.setProperty in line 16 of Step_defination.java - + * Change the driver path in System.setProperty in line 16 of Step_defination.java * Under the src/main/resources we have main.feature file to define Scenarios and Steps * Give the step defination of the defined scenarios and steps in Step_Defination.java under src/main/java - ## Different modules included -* Academic database management +* Academic database management * Academic workflows -* Finance and Accounting -* Placement Cell -* Mess management -* Gymkhana Activities -* Scholarship and Awards Portal -* Employee Management -* Course Management -* Complaint System -* File Tracking System -* Health Centre Mangement -* Visitor's Hostel Management +* Finance and Accounting +* Placement Cell +* Mess management +* Gymkhana Activities +* Scholarship and Awards Portal +* Employee Management +* Course Management +* Complaint System +* File Tracking System +* Health Centre Mangement +* Visitor's Hostel Management * Leave Module ## Notifications Support The project now supports notifications across all modules. To implement notifications in your module refer to the instructions below. -* Create your notification class in [**`./FusionIIIT/notifications/views.py`**](https://github.com/FusionIIIT/Fusion/blob/master/FusionIIIT/notification/views.py) +* Create your notification class in [**`./FusionIIIT/notifications/views.py`**](https://github.com/FusionIIIT/Fusion/blob/master/FusionIIIT/notification/views.py) ``` def module_notif(sender, recipient, type): url='slug:slug' @@ -196,7 +272,7 @@ The project now supports notifications across all modules. To implement notifica * The Notifications should then appear in the dashboard for the recipient ## Setting up Fusion using Docker + - Make sure you have docker & docker-compose setup properly. - Run `docker-compose up` - Once the server starts, run `sudo docker exec -i fusion_db_1 psql -U fusion_admin -d fusionlab < path_to_db_dump` - From c265c2145569c9400e10c82869b2781837c4f193 Mon Sep 17 00:00:00 2001 From: vikrantwiz02 Date: Tue, 17 Feb 2026 20:15:04 +0530 Subject: [PATCH 2/7] Consolidate Critical Prerequisites and Software Requirements sections --- README.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index adf76564d..802ecbd2d 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,14 @@ **FusionIIIT** is the automation of various functionalities, modules and tasks of/for **PDPM Indian Institute of Information Technology, Design and Manufacturing, Jabalpur** being developed in `python3.8` and using `Django` Webframework. -## Critical Prerequisites +## Critical Prerequisites & Software Requirements **You MUST strictly use the following versions:** * Python `3.8.10` * pip `21.1.1` * PostgreSQL `14` +* Git ## System Configuration @@ -16,13 +17,6 @@ * *OR* WSL for Windows `10` \(Follow the guide below\) :[Windows Subsystem for Linux Installation Guide for Windows 10](https://docs.microsoft.com/en-us/windows/wsl/install-win10) * *OR* Windows `7/8/8.1/10` -## Software Requirements - -* Python `3.8.10` -* pip `21.1.1` -* PostgreSQL `14` -* Git - ## Module-Wise Sync Targets For production synchronization targets, refer to: [Fusion-README](https://github.com/FusionIIIT/Fusion-README) From cdba401dbd97bdde429af65f67e4154be492fb49 Mon Sep 17 00:00:00 2001 From: Indrapal Singh Date: Wed, 25 Mar 2026 03:45:01 +0530 Subject: [PATCH 3/7] =?UTF-8?q?feat(online=5Fcms):=20align=20CMS=20APIs=20?= =?UTF-8?q?with=20DB=20models=E2=80=94role=20normalization,=20threaded=20f?= =?UTF-8?q?orum,=20attendance,=20assignments=20(link=20submit/grade),=20qu?= =?UTF-8?q?izzes=20(create/submit=20persistence),=20and=20link-based=20cou?= =?UTF-8?q?rse=20materials=20(migration=20+=20endpoints)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FusionIIIT/Fusion/api_auth.py | 58 + FusionIIIT/Fusion/settings/common.py | 16 + FusionIIIT/Fusion/settings/development.py | 16 +- FusionIIIT/Fusion/settings/production.py | 5 +- FusionIIIT/Fusion/urls.py | 9 +- FusionIIIT/applications/globals/api/urls.py | 7 + .../online_cms/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../management/commands/seed_ocms_demo.py | 156 ++ .../0002_gradingscheme_studentevaluation.py | 35 + ...0003_alter_coursedocuments_document_url.py | 16 + FusionIIIT/applications/online_cms/models.py | 14 +- .../applications/online_cms/selectors.py | 73 + .../applications/online_cms/serializers.py | 72 + .../applications/online_cms/services.py | 94 + FusionIIIT/applications/online_cms/urls.py | 108 +- FusionIIIT/applications/online_cms/views.py | 2149 ++++++----------- FusionIIIT/fix_globals_urls.py | 13 + FusionIIIT/fix_login.py | 11 + FusionIIIT/fix_notification.py | 10 + FusionIIIT/fix_notification_url.py | 11 + FusionIIIT/fix_notification_view.py | 7 + FusionIIIT/fix_settings.py | 31 + FusionIIIT/fix_urls.py | 23 + FusionIIIT/reset_pass.py | 16 + FusionIIIT/update_views.py | 553 +++++ 26 files changed, 2032 insertions(+), 1471 deletions(-) create mode 100644 FusionIIIT/Fusion/api_auth.py create mode 100644 FusionIIIT/applications/online_cms/management/__init__.py create mode 100644 FusionIIIT/applications/online_cms/management/commands/__init__.py create mode 100644 FusionIIIT/applications/online_cms/management/commands/seed_ocms_demo.py create mode 100644 FusionIIIT/applications/online_cms/migrations/0002_gradingscheme_studentevaluation.py create mode 100644 FusionIIIT/applications/online_cms/migrations/0003_alter_coursedocuments_document_url.py create mode 100644 FusionIIIT/applications/online_cms/selectors.py create mode 100644 FusionIIIT/applications/online_cms/serializers.py create mode 100644 FusionIIIT/applications/online_cms/services.py create mode 100644 FusionIIIT/fix_globals_urls.py create mode 100644 FusionIIIT/fix_login.py create mode 100644 FusionIIIT/fix_notification.py create mode 100644 FusionIIIT/fix_notification_url.py create mode 100644 FusionIIIT/fix_notification_view.py create mode 100644 FusionIIIT/fix_settings.py create mode 100644 FusionIIIT/fix_urls.py create mode 100644 FusionIIIT/reset_pass.py create mode 100644 FusionIIIT/update_views.py diff --git a/FusionIIIT/Fusion/api_auth.py b/FusionIIIT/Fusion/api_auth.py new file mode 100644 index 000000000..163453c0c --- /dev/null +++ b/FusionIIIT/Fusion/api_auth.py @@ -0,0 +1,58 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView + +class AuthMeView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + role_str = "student" + try: + role_str = str(user.extrainfo.user_type).lower() + except: + pass + + # Normalize roles so the frontend can reliably distinguish student vs faculty. + # In this dataset, faculty users typically have user_type = 'staff'. + if role_str == "staff": + role_str = "faculty" + + # Give them comprehensive permissions so the sidebar populates fully for testing + accessible_modules = { + role_str: { + "home": True, + "online_cms": True, + "course_registration": True, + "program_and_curriculum": True, + "mess_management": True, + "visitor_hostel": True, + "phc": True, + "fts": True, + "spacs": True, + "complaint_management": True, + "placement_cell": True, + "department": True, + "rspc": True, + "inventory_management": True, + "purchase_and_store": True, + "hr": True, + "examinations": True, + "gymkhana": True, + "iwd": True, + "hostel_management": True, + "other_academics": True, + "course_management": True, + "patent_management": True, + } + } + + return Response({ + 'id': user.id, + 'username': user.username, + 'email': user.email, + 'role': role_str, + 'roles': [role_str], + 'accessibleModules': accessible_modules + }) diff --git a/FusionIIIT/Fusion/settings/common.py b/FusionIIIT/Fusion/settings/common.py index b98ea6960..5803afd32 100644 --- a/FusionIIIT/Fusion/settings/common.py +++ b/FusionIIIT/Fusion/settings/common.py @@ -150,6 +150,7 @@ 'django_cleanup.apps.CleanupConfig', 'django_unused_media', 'rest_framework', + 'rest_framework_simplejwt', 'rest_framework.authtoken', ] @@ -279,3 +280,18 @@ CORS_ORIGIN_ALLOW_ALL = True ALLOW_PASS_RESET = True + + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.BasicAuthentication', + ), +} + +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), +} diff --git a/FusionIIIT/Fusion/settings/development.py b/FusionIIIT/Fusion/settings/development.py index 6acc214c1..32686de04 100644 --- a/FusionIIIT/Fusion/settings/development.py +++ b/FusionIIIT/Fusion/settings/development.py @@ -7,22 +7,26 @@ ALLOWED_HOSTS = ['*'] DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'default': { + 'ENGINE': 'django.db.backends.postgresql', 'NAME': 'fusionlab', - 'HOST': os.environ.get("DB_HOST", default='localhost'), - 'USER': 'fusion_admin', - 'PASSWORD': 'hello123', + 'USER': 'postgres', + 'PASSWORD': '2304', + 'HOST': 'localhost', + 'PORT': '5432', } } REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.BasicAuthentication', ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', - ) + ), } if DEBUG: diff --git a/FusionIIIT/Fusion/settings/production.py b/FusionIIIT/Fusion/settings/production.py index b2bbf01b5..42152ef6d 100644 --- a/FusionIIIT/Fusion/settings/production.py +++ b/FusionIIIT/Fusion/settings/production.py @@ -23,9 +23,12 @@ REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.BasicAuthentication', ), 'DEFAULT_PERMISSION_CLASSES': ( 'rest_framework.permissions.IsAuthenticated', - ) + ), } diff --git a/FusionIIIT/Fusion/urls.py b/FusionIIIT/Fusion/urls.py index 837bf776a..7d1963989 100755 --- a/FusionIIIT/Fusion/urls.py +++ b/FusionIIIT/Fusion/urls.py @@ -21,9 +21,15 @@ from django.conf.urls.static import static from django.contrib import admin from django.contrib.auth import views as auth_views +from . import api_auth urlpatterns = [ + # API AUTH + url(r'^api/auth/login/$', api_auth.TokenObtainPairView.as_view(), name='token_obtain_pair'), + url(r'^api/token/refresh/$', api_auth.TokenRefreshView.as_view(), name='token_refresh'), + url(r'^api/auth/me/$', api_auth.AuthMeView.as_view(), name='api_auth_me'), + url(r'^', include('applications.globals.urls')), url(r'^feeds/', include('applications.feeds.urls')), url(r'^admin/', admin.site.urls), @@ -54,7 +60,8 @@ url(r'^gymkhana/', include('applications.gymkhana.urls')), url(r'^library/', include('applications.library.urls')), url(r'^establishment/', include('applications.establishment.urls')), - url(r'^ocms/', include('applications.online_cms.urls')), + url(r'^ocms/', include(('applications.online_cms.urls', 'online_cms'), namespace='online_cms')), + url(r'^api/online_cms/', include(('applications.online_cms.urls', 'online_cms'), namespace='online_cms_api')), url(r'^counselling/', include('applications.counselling_cell.urls')), url(r'^hostelmanagement/', include('applications.hostel_management.urls')), url(r'^income-expenditure/', include('applications.income_expenditure.urls')), diff --git a/FusionIIIT/applications/globals/api/urls.py b/FusionIIIT/applications/globals/api/urls.py index 72d32c89e..dec4867d8 100644 --- a/FusionIIIT/applications/globals/api/urls.py +++ b/FusionIIIT/applications/globals/api/urls.py @@ -3,6 +3,13 @@ from . import views urlpatterns = [ + url(r'^notification/$', views.NotificationRead, name='dummy_notifs'), + url(r'^auth/me$', views.profile, name='me-api-2'), + + url(r'^notification/$', views.NotificationRead, name='dummy_notifs'), + url(r'^auth/me$', views.profile, name='me-api-2'), + + url(r'^auth/me/', views.profile, name='me-api'), url(r'^auth/login/', views.login, name='login-api'), url(r'^auth/logout/', views.logout, name='logout-api'), diff --git a/FusionIIIT/applications/online_cms/management/__init__.py b/FusionIIIT/applications/online_cms/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FusionIIIT/applications/online_cms/management/commands/__init__.py b/FusionIIIT/applications/online_cms/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/FusionIIIT/applications/online_cms/management/commands/seed_ocms_demo.py b/FusionIIIT/applications/online_cms/management/commands/seed_ocms_demo.py new file mode 100644 index 000000000..5947073e0 --- /dev/null +++ b/FusionIIIT/applications/online_cms/management/commands/seed_ocms_demo.py @@ -0,0 +1,156 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth.models import User +from django.db import transaction + +from applications.globals.models import ExtraInfo, Faculty, DepartmentInfo +from applications.academic_information.models import Course, Curriculum, Curriculum_Instructor, Student +from applications.academic_procedures.models import Register + + +class Command(BaseCommand): + help = "Seed demo data for Online CMS (faculty + CSE courses + enrollment + passwords)." + + @transaction.atomic + def handle(self, *args, **options): + demo_password = "Fusion@2024" + + # --- Ensure student exists and reset password --- + student_username = "23BCS001" + try: + student_user = User.objects.get(username=student_username) + except User.DoesNotExist: + self.stderr.write(self.style.ERROR(f"Student user '{student_username}' not found")) + return + + student_user.set_password(demo_password) + student_user.save(update_fields=["password"]) + + student_extrainfo = ExtraInfo.objects.filter(user=student_user).first() + if not student_extrainfo: + self.stderr.write(self.style.ERROR(f"ExtraInfo missing for '{student_username}'")) + return + + student_obj = Student.objects.filter(id=student_extrainfo).first() + if not student_obj: + self.stderr.write(self.style.ERROR(f"Student row missing for '{student_username}'")) + return + + # --- Create faculty user + profile (Prof. Ashok) --- + faculty_username = "ashok" + faculty_user, created = User.objects.get_or_create( + username=faculty_username, + defaults={ + "first_name": "Ashok", + "last_name": "", + "email": "ashok@iiitdmj.ac.in", + "is_staff": True, + "is_active": True, + }, + ) + if not created: + # keep existing names/email if already there + pass + + faculty_user.set_password(demo_password) + faculty_user.save() + + department = DepartmentInfo.objects.filter(name__iexact="CSE").first() or DepartmentInfo.objects.first() + + faculty_extrainfo, _ = ExtraInfo.objects.get_or_create( + user=faculty_user, + defaults={ + "id": faculty_username, + "title": "Dr.", + "sex": "M", + "user_status": "PRESENT", + "address": "", + "phone_no": 9999999999, + "user_type": "staff", + "department": department, + "about_me": "Professor Ashok (demo)", + }, + ) + + # Ensure faculty model exists + Faculty.objects.get_or_create(id=faculty_extrainfo) + + # --- Create demo courses and assign instructor + enroll student --- + demo_courses = [ + ("CS101", "Introduction to Programming", "Basics of programming in Python, problem solving."), + ("CS102", "Data Structures", "Arrays, stacks, queues, linked lists, trees, graphs."), + ("CS201", "Database Systems", "Relational model, SQL, normalization, transactions."), + ] + + programme = "B.Tech" + branch = "CSE" + batch = 2023 + sem = 1 + credits = 4 + course_type = "Professional Core" + + created_curr = 0 + for course_code, course_name, course_details in demo_courses: + course, _ = Course.objects.get_or_create( + course_name=course_name, + defaults={"course_details": course_details}, + ) + if course.course_details != course_details: + course.course_details = course_details + course.save(update_fields=["course_details"]) + + curr, was_created = Curriculum.objects.get_or_create( + course_code=course_code, + batch=batch, + programme=programme, + defaults={ + "course_id": course, + "credits": credits, + "course_type": course_type, + "branch": branch, + "sem": sem, + "optional": False, + "floated": True, + }, + ) + if not was_created: + # keep it consistent with demo values + changed = False + if curr.course_id_id != course.id: + curr.course_id = course + changed = True + if curr.branch != branch: + curr.branch = branch + changed = True + if curr.sem != sem: + curr.sem = sem + changed = True + if curr.credits != credits: + curr.credits = credits + changed = True + if curr.course_type != course_type: + curr.course_type = course_type + changed = True + if not curr.floated: + curr.floated = True + changed = True + if changed: + curr.save() + else: + created_curr += 1 + + Curriculum_Instructor.objects.get_or_create( + curriculum_id=curr, + instructor_id=faculty_extrainfo, + defaults={"chief_inst": True}, + ) + + Register.objects.get_or_create( + curr_id=curr, + student_id=student_obj, + defaults={"semester": sem}, + ) + + self.stdout.write(self.style.SUCCESS("Seeded Online CMS demo data")) + self.stdout.write(self.style.SUCCESS(f"Student login: {student_username} / {demo_password}")) + self.stdout.write(self.style.SUCCESS(f"Faculty login: {faculty_username} / {demo_password}")) + self.stdout.write(self.style.SUCCESS(f"Created {created_curr} new Curriculum entries")) diff --git a/FusionIIIT/applications/online_cms/migrations/0002_gradingscheme_studentevaluation.py b/FusionIIIT/applications/online_cms/migrations/0002_gradingscheme_studentevaluation.py new file mode 100644 index 000000000..3f4fc4967 --- /dev/null +++ b/FusionIIIT/applications/online_cms/migrations/0002_gradingscheme_studentevaluation.py @@ -0,0 +1,35 @@ +# Generated by Django 3.1.5 on 2026-03-25 00:54 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0001_initial'), + ('online_cms', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='GradingScheme', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('component', models.CharField(max_length=100)), + ('weightage', models.FloatField()), + ('max_marks', models.FloatField()), + ('course_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='academic_information.course')), + ], + ), + migrations.CreateModel( + name='StudentEvaluation', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('marks_obtained', models.FloatField(default=0)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('scheme', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='online_cms.gradingscheme')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='academic_information.student')), + ], + ), + ] diff --git a/FusionIIIT/applications/online_cms/migrations/0003_alter_coursedocuments_document_url.py b/FusionIIIT/applications/online_cms/migrations/0003_alter_coursedocuments_document_url.py new file mode 100644 index 000000000..e2274b915 --- /dev/null +++ b/FusionIIIT/applications/online_cms/migrations/0003_alter_coursedocuments_document_url.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("online_cms", "0002_gradingscheme_studentevaluation"), + ] + + operations = [ + migrations.AlterField( + model_name="coursedocuments", + name="document_url", + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/FusionIIIT/applications/online_cms/models.py b/FusionIIIT/applications/online_cms/models.py index a34e5c3ee..0841326c7 100644 --- a/FusionIIIT/applications/online_cms/models.py +++ b/FusionIIIT/applications/online_cms/models.py @@ -10,7 +10,7 @@ class CourseDocuments(models.Model): upload_time = models.DateTimeField(auto_now=True) description = models.CharField(max_length=100) document_name = models.CharField(max_length=40) - document_url = models.CharField(max_length=100, null=True) + document_url = models.TextField(null=True, blank=True) def __str__(self): return '{} - {}'.format(self.course_id, self.document_name) @@ -203,3 +203,15 @@ class ForumReply(models.Model): def __str__(self): return '{} - {} - {}'.format(self.pk, self.forum_ques, self.forum_reply) + +class GradingScheme(models.Model): + course_id = models.ForeignKey(Course, on_delete=models.CASCADE) + component = models.CharField(max_length=100) + weightage = models.FloatField() + max_marks = models.FloatField() + +class StudentEvaluation(models.Model): + scheme = models.ForeignKey(GradingScheme, on_delete=models.CASCADE) + student = models.ForeignKey(Student, on_delete=models.CASCADE) + marks_obtained = models.FloatField(default=0) + updated_at = models.DateTimeField(auto_now=True) diff --git a/FusionIIIT/applications/online_cms/selectors.py b/FusionIIIT/applications/online_cms/selectors.py new file mode 100644 index 000000000..43ecee31c --- /dev/null +++ b/FusionIIIT/applications/online_cms/selectors.py @@ -0,0 +1,73 @@ +from .models import CourseDocuments, Assignment, StudentAssignment, Forum, ForumReply, Quiz, QuestionBank, Topics, Question, StudentGrades + +def get_course_documents(course): + """ + Return all documents and videos for a course. + """ + return CourseDocuments.objects.filter(course_id=course).order_by('-upload_time') + +def get_course_assignments(course): + """ + Return all assignments for a course. + """ + return Assignment.objects.filter(course_id=course).order_by('-upload_time') + +def get_student_assignments(assignment): + """ + Return all student submissions for an assignment. + """ + return StudentAssignment.objects.filter(assignment_id=assignment) + +def get_student_assignment(student, assignment): + """ + Return specific assignment submission for a student. + """ + return StudentAssignment.objects.filter(student_id=student, assignment_id=assignment).first() + +def get_course_forum(course): + """ + Return all root forum posts for a course. + """ + replies = ForumReply.objects.all().values_list('forum_reply_id', flat=True) + return Forum.objects.filter(course_id=course).exclude(id__in=replies).order_by('-comment_time') + +def get_forum_replies(forum_post): + """ + Return replies for a specific forum post. + """ + return ForumReply.objects.filter(reply_info=forum_post).select_related('forum_reply') + +def get_course_quizzes(course): + """ + Return all quizzes for a course. + """ + return Quiz.objects.filter(course_id=course).order_by('-start_time') + +def get_active_quizzes(course, current_time): + """ + Return active quizzes. + """ + return Quiz.objects.filter( + course_id=course, + start_time__lte=current_time, + end_time__gte=current_time + ) + +def get_course_question_banks(course): + """ + Return question banks for a course. + """ + return QuestionBank.objects.filter(course_id=course) + +def get_quiz_questions(quiz): + """ + Return questions of a quiz. + """ + from .models import QuizQuestion + return QuizQuestion.objects.filter(quiz_id=quiz).select_related('question') + +def get_student_grades(student, course): + """ + Return grades for a student in a specific course. + """ + return StudentGrades.objects.filter(student_id=student, course_id=course) diff --git a/FusionIIIT/applications/online_cms/serializers.py b/FusionIIIT/applications/online_cms/serializers.py new file mode 100644 index 000000000..3978f5637 --- /dev/null +++ b/FusionIIIT/applications/online_cms/serializers.py @@ -0,0 +1,72 @@ +from rest_framework import serializers +from .models import * +from applications.academic_information.models import Course, Curriculum + +class CourseSerializer(serializers.ModelSerializer): + course_code = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = ['id', 'course_name', 'course_details', 'course_code'] + + def get_course_code(self, obj): + curr = Curriculum.objects.filter(course_id=obj).first() + return curr.course_code if curr else None + +class AssignmentSerializer(serializers.ModelSerializer): + class Meta: + model = Assignment + fields = ['id', 'assignment_name', 'submit_date', 'assignment_url', 'upload_time', 'course_id'] + +class StudentAssignmentSerializer(serializers.ModelSerializer): + class Meta: + model = StudentAssignment + fields = ['id', 'assign_name', 'upload_url', 'score', 'feedback', 'upload_time', 'student_id', 'assignment_id'] + +class CourseDocumentSerializer(serializers.ModelSerializer): + class Meta: + model = CourseDocuments + fields = ['id', 'document_name', 'description', 'document_url', 'upload_time', 'course_id'] + +class ForumSerializer(serializers.ModelSerializer): + class Meta: + model = Forum + fields = ['id', 'comment', 'comment_time', 'commenter_id'] + + +class ForumReplySerializer(serializers.ModelSerializer): + class Meta: + model = ForumReply + fields = ['id', 'reply_dict', 'forum_ques_id'] + +class QuizSerializer(serializers.ModelSerializer): + class Meta: + model = Quiz + fields = ['id', 'quiz_name', 'start_time', 'end_time', 'd_day', 'd_hour', 'd_minute', 'negative_marks', 'number_of_question', 'description', 'rules', 'total_score'] + +class QuestionSerializer(serializers.ModelSerializer): + class Meta: + model = Question + fields = ['id', 'question', 'options1', 'options2', 'options3', 'options4', 'options5', 'answer', 'image', 'marks', 'topic_id'] + +class QuizResultSerializer(serializers.ModelSerializer): + class Meta: + model = QuizResult + fields = ['id', 'score', 'finished', 'quiz_id', 'student_id'] + +class QuestionBankSerializer(serializers.ModelSerializer): + class Meta: + model = QuestionBank + fields = ['id', 'name', 'course_id'] + +class TopicSerializer(serializers.ModelSerializer): + class Meta: + model = Topics + fields = ['id', 'topic_name', 'course_id'] + +class AttendanceSerializer(serializers.ModelSerializer): + class Meta: + from applications.academic_information.models import Student_attendance + model = Student_attendance + fields = ['id', 'date', 'present', 'student_id', 'instructor_id'] + diff --git a/FusionIIIT/applications/online_cms/services.py b/FusionIIIT/applications/online_cms/services.py new file mode 100644 index 000000000..4428a3002 --- /dev/null +++ b/FusionIIIT/applications/online_cms/services.py @@ -0,0 +1,94 @@ +from applications.academic_information.models import Curriculum, Curriculum_Instructor, Student, Course, Student_attendance +from applications.academic_procedures.models import Register +from applications.globals.models import ExtraInfo +from .models import (Assignment, StudentAssignment, CourseDocuments, + Forum, ForumReply, Quiz, QuizQuestion, StudentAnswer, + QuizResult, GradingScheme, StudentEvaluation) +import datetime + +def get_extra_info(user): + return ExtraInfo.objects.filter(user=user).first() + +def is_student(extra_info): + return Student.objects.filter(id=extra_info).exists() + +def get_courses_for_user(user): + extra_info = get_extra_info(user) + if not extra_info: + return [] + student = Student.objects.filter(id=extra_info).first() + seen = set() + result = [] + if student: + registers = Register.objects.filter(student_id=student).select_related( + 'curr_id', 'curr_id__course_id').order_by('curr_id__course_code') + curriculums = [r.curr_id for r in registers] + else: + instructor_links = Curriculum_Instructor.objects.filter( + instructor_id=extra_info).select_related( + 'curriculum_id', 'curriculum_id__course_id').order_by( + 'curriculum_id__course_code') + curriculums = [link.curriculum_id for link in instructor_links] + for curr in curriculums: + if not curr or curr.course_code in seen: + continue + seen.add(curr.course_code) + result.append({ + 'courseCode': curr.course_code, + 'courseName': curr.course_id.course_name, + 'semester': curr.sem, + 'credits': curr.credits, + }) + return result + +def get_course_obj(course_code): + curr = Curriculum.objects.select_related('course_id').filter( + course_code=course_code).first() + return curr + +def is_enrolled(user, course_code): + extra_info = get_extra_info(user) + if not extra_info: + return False + student = Student.objects.filter(id=extra_info).first() + if student: + return Register.objects.filter( + student_id=student, + curr_id__course_code=course_code).exists() + return Curriculum_Instructor.objects.filter( + instructor_id=extra_info, + curriculum_id__course_code=course_code).exists() + + +def get_course_roster(course_code): + """Return enrolled students for a curriculum/course_code. + + Output items: + { "student_id": "23BCS001", "name": "Full Name" } + """ + regs = Register.objects.filter(curr_id__course_code=course_code).select_related( + 'student_id', 'student_id__id', 'student_id__id__user' + ) + res = [] + seen = set() + for r in regs: + s = r.student_id + if not s or not getattr(s, 'id', None) or not getattr(s.id, 'user', None): + continue + username = s.id.user.username + if username in seen: + continue + seen.add(username) + res.append({ + 'student_id': username, + 'name': s.id.user.get_full_name() or username, + }) + return res + + +def get_instructor_link(extra_info, course_code): + """Return Curriculum_Instructor row for this faculty+course_code (or None).""" + return Curriculum_Instructor.objects.filter( + instructor_id=extra_info, + curriculum_id__course_code=course_code, + ).select_related('curriculum_id').first() diff --git a/FusionIIIT/applications/online_cms/urls.py b/FusionIIIT/applications/online_cms/urls.py index c24c69cc2..d249119cf 100644 --- a/FusionIIIT/applications/online_cms/urls.py +++ b/FusionIIIT/applications/online_cms/urls.py @@ -1,72 +1,36 @@ -from django.conf.urls import url - -from . import views -app_name = 'online_cms' - -urlpatterns = [ - - url(r'^$', views.viewcourses, name='viewcourses'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/$', views.course, name='course'), - # url(r'^(?P[A-z]+[0-9]+[A-z]?)/edit_marks$', views.edit_marks, name='edit_marks'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/get_exam_data$', views.get_exam_data, name='get_exam_data'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/forum$', views.forum, - name='forum'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/ajax_reply$', views.ajax_reply, - name='ajax_reply'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/ajax_new$', views.ajax_new, - name='ajax_new'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/ajax_remove$', views.ajax_remove, - name='ajax_remove'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/upload_assignment$', views.upload_assignment, - name='upload_assignment'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/add_documents$', views.add_document, - name='add_document'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/add_assignment$', - views.add_assignment, name='add_assignment'), - # url(r'^(?P[A-z]+[0-9]+[A-z]?)/add_video$', views.add_videos, - # name='add_videos'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/delete/$', views.delete, - name='delete'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/ajax_assess$', views.ajax_assess, - name='ajax_assess'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/ajax_feedback$', views.ajax_feedback, - name='ajax_feedback'), - url(r'^quiz/(?P[0-9]+)/$', views.quiz, name='quiz'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/create_quiz/$', views.create_quiz, name='create_quiz'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/edit_quiz/(?P[0-9]+)/$', - views.edit_quiz, name='edit_quiz'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/edit_quiz/(?P[0-9]+)/(?P[0-9]+)$', - views.edit_quiz_topic, name='edit_quiz_topic'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/(?P[0-9]+)/add_question_topic$', - views.add_question_topicwise, name='add_question_topicwise'), - url(r'^(?P[0-9]+?)/(?P[0-9]+)/add_questions_to_quiz$', - views.add_questions_to_quiz, name='add_questions_to_quiz'), - url( - r'^(?P[A-z]+[0-9]+[A-z]?)/(?P[0-9]+)/(?P[0-9]+)/remove_quiz_question$', - views.remove_quiz_question, name='remove_quiz_question'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/preview_quiz/(?P[0-9]+)/$', views.preview_quiz, - name='preview_quiz'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/edit_quiz_details/(?P[0-9]+)/$', - views.edit_quiz_details, name='edit_quiz_details'), - url(r'^(?P[0-9]+)/ajax$', views.ajax_q, name='ajax_q'), - url(r'^(?P[0-9]+)/submit$', views.submit, name='submit'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/remove_quiz$', views.remove_quiz, - name='remove_quiz'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/remove_bank$', views.remove_bank, - name='remove_bank'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/remove_topic$', views.remove_topic, - name='remove_topic'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/create_bank$', views.create_bank, - name='create_bank'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/create_topic$', views.create_topic, - name='create_topic'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/(?P[0-9]+)/(?P[0-9]+)$', - views.edit_qb_topics, name='edit_qb_topics'), - url(r'^(?P[0-9]+?)/(?P[0-9]+)/(?P[0-9]+)/add_question$', - views.add_question, name='add_question'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/(?P[0-9]+)/(?P[0-9]+)/remove_question$', - views.remove_question, name='remove_question'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/edit_bank/(?P[0-9]+)$', - views.edit_bank, name='edit_bank'), - url(r'^(?P[A-z]+[0-9]+[A-z]?)/attendance$', views.submit_attendance, - name='submit_attendance'),] +from django.urls import path +from . import views + +app_name = 'online_cms' + +urlpatterns = [ + path('api/courses/', views.ApiCourseList.as_view(), name='api_courses'), + path('api//dashboard/', views.ApiCourseDashboard.as_view(), name='api_dashboard'), + path('api//assignments/', views.ApiAssignments.as_view(), name='api_assignments'), + path('api//assignments/add/', views.ApiAddAssignment.as_view(), name='api_add_assignment'), + path('api//assignments/upload/', views.ApiUploadAssignment.as_view(), name='api_upload_assignment'), + path('api//assignments//grade/', views.ApiGradeAssignment.as_view(), name='api_grade_assignment'), + path('api//assignments//delete/', views.ApiDeleteAssignment.as_view(), name='api_delete_assignment'), + path('api//documents/', views.ApiDocuments.as_view(), name='api_documents'), + path('api//documents/add/', views.ApiAddDocument.as_view(), name='api_add_document'), + path('api//documents//delete/', views.ApiDeleteDocument.as_view(), name='api_delete_document'), + path('api//forum/', views.ApiForum.as_view(), name='api_forum'), + path('api//forum/new/', views.ApiForumNew.as_view(), name='api_forum_new'), + path('api//forum/reply/', views.ApiForumReply.as_view(), name='api_forum_reply'), + path('api//forum//remove/', views.ApiForumRemove.as_view(), name='api_forum_remove'), + path('api//quizzes/', views.ApiQuizzes.as_view(), name='api_quizzes'), + path('api//quizzes/create/', views.ApiCreateQuiz.as_view(), name='api_create_quiz'), + path('api//quizzes//', views.ApiQuizDetail.as_view(), name='api_quiz_detail'), + path('api//quizzes//submit/', views.ApiQuizSubmit.as_view(), name='api_quiz_submit'), + path('api//quizzes//remove/', views.ApiRemoveQuiz.as_view(), name='api_remove_quiz'), + path('api//attendance/', views.ApiAttendance.as_view(), name='api_attendance'), + path('api//attendance/roster/', views.ApiAttendanceRoster.as_view(), name='api_attendance_roster'), + path('api//questionbank/', views.ApiQuestionBank.as_view(), name='api_questionbank'), + path('api//questionbank/create/', views.ApiCreateBank.as_view(), name='api_create_bank'), + path('api//questionbank//topic/add/', views.ApiAddTopic.as_view(), name='api_add_topic'), + path('api//questionbank//topic//question/add/', views.ApiAddQuestion.as_view(), name='api_add_question'), + path('api//grading/', views.ApiGrading.as_view(), name='api_grading'), + path('api//grading/create/', views.ApiCreateGradingScheme.as_view(), name='api_create_grading'), + path('api//grading/evaluate/', views.ApiEvaluate.as_view(), name='api_evaluate'), + path('api//grading/student-grades/', views.ApiStudentGrades.as_view(), name='api_student_grades'), +] diff --git a/FusionIIIT/applications/online_cms/views.py b/FusionIIIT/applications/online_cms/views.py index cc74626f3..6ab08d752 100644 --- a/FusionIIIT/applications/online_cms/views.py +++ b/FusionIIIT/applications/online_cms/views.py @@ -1,1407 +1,776 @@ -from __future__ import unicode_literals -from django.views.decorators.csrf import csrf_protect -from django.core import serializers -import collections -import json -import os -import random -import subprocess -import datetime -import requests -from django.conf import settings -from django.contrib.auth.decorators import login_required -from django.core.files.storage import FileSystemStorage -from django.http import HttpResponse, HttpResponseBadRequest -from django.shortcuts import redirect, render +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from django.utils import timezone - -from applications.academic_information.models import (Course, Curriculum_Instructor,Curriculum, - Student,Student_attendance) -from applications.academic_procedures.models import Register -from applications.globals.models import ExtraInfo -from applications.globals.models import * - -from .forms import * -# from .helpers import create_thumbnail, semester -from .models import * -from .helpers import create_thumbnail, semester - - -@login_required -def viewcourses(request): - ''' - desc: Shows all the courses under the user - ''' - user = request.user - - extrainfo = ExtraInfo.objects.select_related().get(user=user) #get the type of user - if extrainfo.user_type == 'student': #if student is using - student = Student.objects.select_related('id').get(id=extrainfo) - roll = student.id.id[:4] #get the roll no. of the student - register = Register.objects.select_related().filter(student_id=student, semester=semester(roll)) #info of registered student - courses = collections.OrderedDict() #courses in which student is registerd - for reg in register: #info of the courses - instructor = Curriculum_Instructor.objects.select_related().get(course_id=reg.course_id) - courses[reg] = instructor - return render(request, 'coursemanagement/coursemanagement1.html', - {'courses': courses, - - 'extrainfo': extrainfo}) - else: #if the user is lecturer - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) #get info of the instructor - curriculum_list = [] - for x in instructor: - c = Curriculum.objects.select_related().get(curriculum_id = x.curriculum_id.curriculum_id) - curriculum_list.append(c) - - - return render(request, 'coursemanagement/coursemanagement1.html', - {'instructor': instructor, - 'extrainfo': extrainfo, - 'curriculum_list': curriculum_list}) - - - -@login_required -def course(request, course_code): - ''' - desc: Home page for each courses for Student/Faculty - ''' - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - if extrainfo.user_type == 'student': #if the user is student .. funtionality used by him/her - student = Student.objects.select_related('id').get(id=extrainfo) - roll = student.id.id[:4] - - #info about courses he is registered in - curriculum = Curriculum.objects.select_related('course_id').get(course_code=course_code) - course = curriculum.course_id - #instructor of the course - instructor = Curriculum_Instructor.objects.select_related().get(curriculum_id=curriculum) - #course material uploaded by the instructor - # videos = CourseVideo.objects.filter(course_id=course) - videos = [] - if request.method == 'POST': - search_url = "https://www.googleapis.com/youtube/v3/search" - video_url = "https://www.googleapis.com/youtube/v3/videos" - search_params = { - 'part': 'snippet', - 'q': request.POST['search'], - 'key': settings.YOUTUBE_DATA_API_KEY, - 'type': 'video', - 'channelId': 'channel_id' - } - videos_ids = [] - r = requests.get(search_url, params=search_params) - # print(r) - results = r.json()['items'] - for result in results: - videos_ids.append(result['id']['videoId']) - - video_params = { - 'key': settings.YOUTUBE_DATA_API_KEY, - 'part': 'snippet,contentDetails', - 'id': ','.join(videos_ids), - 'maxResults': 9 - } - - p = requests.get(video_url, params=video_params) - results1 = p.json()['items'] - - for result in results1: - video_data = { - 'id': result['id'], - # 'url': f'https://www.youtube.com/watch?v={result["id"]}', - 'title': result['snippet']['title'], - # 'duration': int(parse_duration(result['contentDetails']['duration']).total_seconds() // 60), - # 'thumbnails': result['snippet']['thumbnails']['high']['url'] - } - - videos.append(video_data) - else: - channel_url = "https://www.googleapis.com/youtube/v3/channels" - playlist_url = "https://www.googleapis.com/youtube/v3/playlistItems" - videos_url = "https://www.googleapis.com/youtube/v3/videos" - - videos_list = [] - channel_params = { - 'part': 'contentDetails', - 'id': 'channel_id', - 'key': settings.YOUTUBE_DATA_API_KEY, - } - r = requests.get(channel_url, params=channel_params) - results = r.json()['items'][0]['contentDetails']['relatedPlaylists']['uploads'] - - playlist_params = { - 'key': settings.YOUTUBE_DATA_API_KEY, - 'part': 'snippet', - 'playlistId': results, - 'maxResults': 5, +from django.utils.dateparse import parse_date, parse_datetime +from . import services, models +from applications.academic_information.models import Student, Student_attendance + +class BaseCourseView(APIView): + permission_classes = [IsAuthenticated] + + def check_enrollment(self, request, course_code): + return services.is_enrolled(request.user, course_code) + + def get_role_info(self, request): + extra_info = services.get_extra_info(request.user) + return extra_info, services.is_student(extra_info) + +class ApiCourseList(APIView): + permission_classes = [IsAuthenticated] + def get(self, request): + courses = services.get_courses_for_user(request.user) + return Response(courses) + +class ApiCourseDashboard(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + + curr = services.get_course_obj(course_code) + if not curr: + return Response({'detail': 'Course not found'}, status=404) + + assignments_count = models.Assignment.objects.filter(course_id=curr.course_id).count() + documents_count = models.CourseDocuments.objects.filter(course_id=curr.course_id).count() + + return Response({ + "courseCode": course_code, + "courseName": curr.course_id.course_name, + "courseDetails": curr.course_id.course_details, + "credits": curr.credits, + "semester": curr.sem, + "programme": curr.course_id.program_id.name if curr.course_id.program_id else "", + "branch": curr.course_id.branch_id.name if getattr(curr.course_id, "branch_id", None) else "", + "batch": curr.course_id.batch_id.name if getattr(curr.course_id, "batch_id", None) else "", + "counts": { + "assignments": assignments_count, + "documents": documents_count } - p = requests.get(playlist_url, params=playlist_params) - results1 = p.json()['items'] - - for result in results1: - # print(results) - videos_list.append(result['snippet']['resourceId']['videoId']) - - videos_params = { - 'key': settings.YOUTUBE_DATA_API_KEY, - 'part': 'snippet', - 'id': ','.join(videos_list) - } - - v = requests.get(videos_url, params=videos_params) - results2 = v.json()['items'] - videos = [] - for res in results2: - video_data = { - 'id': res['id'], - 'title': res['snippet']['title'], - } - - videos.append(video_data) - # print(videos) - slides = CourseDocuments.objects.select_related().filter(course_id=course) - quiz = Quiz.objects.select_related().filter(course_id=course) - assignment = Assignment.objects.select_related().filter(course_id=course) - student_assignment = [] - for assi in assignment: - sa = StudentAssignment.objects.select_related().filter(assignment_id=assi, student_id=student) - student_assignment.append(sa) - ''' - marks to store the marks of quizes of student - marks_pk to store the quizs taken by student - quizs=>quizs that are not over - ''' - marks = [] - quizs = [] - marks_pk = [] - #quizzes details - for q in quiz: - qs = QuizResult.objects.select_related().filter(quiz_id=q, student_id=student) - qs_pk = qs.values_list('quiz_id', flat=True) - if q.end_time > timezone.now(): - quizs.append(q) - if qs: - marks.append(qs[0]) - marks_pk.append(qs_pk[0]) - lec = 0 - comments = Forum.objects.select_related().filter(course_id=course).order_by('comment_time') - answers = collections.OrderedDict() - for comment in comments: - fr = ForumReply.objects.select_related().filter(forum_reply=comment) - fr1 = ForumReply.objects.select_related().filter(forum_ques=comment) - if not fr: - answers[comment] = fr1 - return render(request, 'coursemanagement/viewcourse.html', - {'course': course, - 'quizs': marks, - 'quizs_pk': marks_pk, - 'fut_quiz': quizs, - 'videos': videos, - 'instructor': instructor, - 'slides': slides, - 'extrainfo': extrainfo, - 'answers': answers, - 'assignment': assignment, - 'student_assignment': student_assignment, - 'Lecturer': lec, - 'curriculum': curriculum}) - - else: - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - registered_students = Register.objects.select_related('student_id').filter(curr_id = ins.curriculum_id.curriculum_id) - students = {} - test_marks = {} - for x in registered_students: - students[x.student_id.id.id] = (x.student_id.id.user.first_name + " " + x.student_id.id.user.last_name, x.id) - # stored_marks = StoreMarks.objects.filter(mid = x.r_id) - # for x in stored_marks: - # test_marks[x.id] = (x.mid.r_id,x.exam_type,x.marks) - #marks_id.append(x.curr_id) - #print(stored_marks) - #for x in stored_marks: - # print(x) - - curriculum = ins.curriculum_id - course = ins.curriculum_id.course_id - result_topics = Topics.objects.select_related().filter(course_id = course) - if (len(list(result_topics))!=0): - topics = result_topics - else: - topics = None - present_attendance = {} - total_attendance=None - for x in registered_students: - a = Student_attendance.objects.select_related().filter(student_id=x.student_id , instructor_id = ins) - total_attendance = len(a) - count =0 - for row in a: - if(row.present): - count += 1 - present_attendance[x.student_id.id.id] = count - - lec = 1 - - # videos = CourseVideo.objects.filter(course_id=course) - channel_url = "https://www.googleapis.com/youtube/v3/channels" - playlist_url = "https://www.googleapis.com/youtube/v3/playlistItems" - videos_url = "https://www.googleapis.com/youtube/v3/videos" - - videos_list = [] - channel_params = { - 'part': 'contentDetails', - # 'forUsername': 'TechGuyWeb', - 'id': 'UCdGQeihs84hyCssI2KuAPmA', - 'key': settings.YOUTUBE_DATA_API_KEY, - } - r = requests.get(channel_url, params=channel_params) - results = r.json()['items'][0]['contentDetails']['relatedPlaylists']['uploads'] - - playlist_params = { - 'key': settings.YOUTUBE_DATA_API_KEY, - 'part': 'snippet', - 'playlistId': results, - 'maxResults': 5, - } - p = requests.get(playlist_url, params=playlist_params) - results1 = p.json()['items'] - - for result in results1: - videos_list.append(result['snippet']['resourceId']['videoId']) - - videos_params = { - 'key': settings.YOUTUBE_DATA_API_KEY, - 'part': 'snippet', - 'id': ','.join(videos_list) - } - - v = requests.get(videos_url, params=videos_params) - results2 = v.json()['items'] - videos = [] - for res in results2: - video_data = { - 'id': res['id'], - 'title': res['snippet']['title'], + }) + +class ApiAssignments(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + + curr = services.get_course_obj(course_code) + course = curr.course_id + assignments = models.Assignment.objects.filter(course_id=course).order_by('-upload_time') + + extra_info, is_student_user = self.get_role_info(request) + student_obj = None + if is_student_user: + student_obj = Student.objects.filter(id=extra_info).first() + + res = [] + for a in assignments: + item = { + 'id': a.pk, + 'title': a.assignment_name, + 'deadline': a.submit_date.isoformat() if a.submit_date else None, + 'createdAt': a.upload_time.isoformat() if hasattr(a, 'upload_time') else None, + 'submissions': [], } - videos.append(video_data) - slides = CourseDocuments.objects.select_related().filter(course_id=course) - quiz = Quiz.objects.select_related().filter(course_id=course) - marks = [] - quizs = [] - assignment = Assignment.objects.select_related().filter(course_id=course) - student_assignment = [] - for assi in assignment: - sa = StudentAssignment.objects.select_related().filter(assignment_id=assi) - student_assignment.append(sa) - for q in quiz: - qs = QuizResult.objects.select_related().filter(quiz_id=q) - if q.end_time > timezone.now(): - quizs.append(q) - if len(qs) != 0: - marks.append(qs) - comments = Forum.objects.select_related().filter(course_id=course).order_by('comment_time') - answers = collections.OrderedDict() - for comment in comments: - fr = ForumReply.objects.select_related().filter(forum_reply=comment) - fr1 = ForumReply.objects.select_related().filter(forum_ques=comment) - if not fr: - answers[comment] = fr1 - qb = QuestionBank.objects.select_related().filter(instructor_id=extrainfo, course_id=course) - return render(request, 'coursemanagement/viewcourse.html', - {'instructor': instructor, - 'extrainfo': extrainfo, - 'curriculum': curriculum, - 'students' : students, - 'registered_students': registered_students, - 'fut_quiz': quizs, - 'quizs': marks, - 'videos': videos, - 'slides': slides, - 'topics':topics, - 'course': course, - 'answers': answers, - 'assignment': assignment, - 'student_assignment': student_assignment, - 'Lecturer': lec, - 'questionbank': qb, - 'students': students, - 'total_attendance' : total_attendance, - 'present_attendance':present_attendance, - 'test_marks': test_marks - }) - -#when student uploads the assignment's solution -@login_required -def upload_assignment(request, course_code): - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - if extrainfo.user_type == "student": - student = Student.objects.select_related('id').get(id=extrainfo) - try: - #all details of the assignment - doc = request.FILES.get('img') #the images in the assignment - assi_name = request.POST.get('assignment_topic') - name = request.POST.get('name') - assign = Assignment.objects.get(pk=assi_name) - filename, file_extenstion = os.path.splitext(request.FILES.get('img').name) - except: - return HttpResponse("Please fill each and every field correctly!") - filename = name - full_path = settings.MEDIA_ROOT + "/online_cms/" + course_code + "/assi/" #storing the media files - full_path = full_path + assign.assignment_name + "/" + student.id.id + "/" - url = settings.MEDIA_URL + filename - if not os.path.isdir(full_path): - cmd = "mkdir " + full_path - subprocess.call(cmd, shell=True) - fs = FileSystemStorage(full_path, url) - fs.save(name + file_extenstion, doc) #saving the media files - uploaded_file_url = full_path+ "/" + name + file_extenstion - # to save the solution of assignment the database - sa = StudentAssignment( - student_id=student, - assignment_id=assign, - upload_url=uploaded_file_url, - assign_name=name+file_extenstion + if is_student_user: + subs = models.StudentAssignment.objects.filter(assignment_id=a, student_id=student_obj).order_by('-upload_time') + else: + subs = models.StudentAssignment.objects.filter(assignment_id=a).select_related( + 'student_id', 'student_id__id', 'student_id__id__user' + ).order_by('-upload_time') + + for s in subs: + username = None + full_name = 'Unknown' + if s.student_id and getattr(s.student_id, 'id', None) and getattr(s.student_id.id, 'user', None): + username = s.student_id.id.user.username + full_name = s.student_id.id.user.get_full_name() or username + + item['submissions'].append({ + 'id': s.pk, + 'assignmentId': a.pk, + 'studentId': username, + 'studentName': full_name, + 'submissionLink': s.upload_url, + 'submittedAt': s.upload_time.isoformat() if hasattr(s, 'upload_time') else None, + 'score': s.score, + 'feedback': s.feedback, + }) + + res.append(item) + + return Response(res) + +class ApiAddAssignment(BaseCourseView): + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + return Response({'detail': 'Faculty only'}, status=403) + + curr = services.get_course_obj(course_code) + data = request.data + + deadline = data.get('deadline') + deadline_dt = parse_datetime(deadline) if isinstance(deadline, str) else None + if deadline_dt is None and isinstance(deadline, str): + d = parse_date(deadline) + if d is not None: + deadline_dt = timezone.make_aware(timezone.datetime(d.year, d.month, d.day, 23, 59, 0)) + + if not data.get('title'): + return Response({'detail': 'title is required'}, status=400) + if deadline_dt is None: + return Response({'detail': 'deadline is required (ISO datetime or YYYY-MM-DD)'}, status=400) + + a = models.Assignment.objects.create( + course_id=curr.course_id, + assignment_name=data.get('title'), + submit_date=deadline_dt, ) - sa.save() - return HttpResponse("Upload successful.") - else: - return HttpResponse("not found") - -# when faculty uploads the slides, ppt -@login_required -def add_document(request, course_code): - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - if extrainfo.user_type == "faculty": #user should be faculty only - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) #get the course information - - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - course = ins.curriculum_id.course_id - - try: - description = request.POST.get('description') - doc = request.FILES.get('img') - name = request.POST.get('name') - filename, file_extenstion = os.path.splitext(request.FILES.get('img').name) - except: - return HttpResponse("Please fill each and every field correctly!") - #for storing the media files properly - filename = name - full_path = settings.MEDIA_ROOT + "/online_cms/" + course_code + "/doc/" - url = settings.MEDIA_URL + filename + file_extenstion - if not os.path.isdir(full_path): - cmd = "mkdir " + full_path - subprocess.call(cmd, shell=True) - fs = FileSystemStorage(full_path, url) - fs.save(filename + file_extenstion, doc) - uploaded_file_url = full_path + filename + file_extenstion - #save the info/details in the database - CourseDocuments.objects.create( - course_id=course, - upload_time=datetime.datetime.now(), - description=description, - document_url=uploaded_file_url, - document_name=name+file_extenstion + return Response({'id': a.pk}) + +class ApiUploadAssignment(BaseCourseView): + # Allow both form-data (legacy) and JSON (link submission from new UI) + parser_classes = [MultiPartParser, FormParser, JSONParser] + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if not is_student_user: + return Response({'detail': 'Student only'}, status=403) + + assignment_id = request.data.get('assignment_id') + submission_link = request.data.get('submission_link') or request.data.get('upload_url') or request.data.get('link') + if not submission_link: + return Response({'detail': 'submission_link is required'}, status=400) + + a = models.Assignment.objects.get(pk=assignment_id) + if timezone.now() > a.submit_date: + return Response({'detail': 'Submission deadline has passed'}, status=400) + + student = Student.objects.get(id=extra_info) + sub = models.StudentAssignment.objects.create( + student_id=student, + assignment_id=a, + upload_url=submission_link, + assign_name=a.assignment_name, ) - return HttpResponse("Upload successful.") - else: - return HttpResponse("not found") - -#it is to delete things(assignment, slides, videos, ) from the dustin icon or delete buttons -@login_required -def delete(request, course_code): - data_type = request.POST.get('type') - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - #get the course and user information first - - if extrainfo.user_type == "faculty": - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - course = ins.curriculum_id.course_id - - if extrainfo.user_type == 'student': - curriculum_details = Curriculum.objects.select_related('course_id').filter(course_code=course_code) - course = curriculum_details - course1 = curriculum_details[0].course_id - curriculum1 = course[0] - pk = request.POST.get('pk') - #to delete videos - if data_type == 'video': - video = CourseVideo.objects.get(pk=pk, course_id=course) - path = video.video_url - video.delete() - #to delete slides/documents - elif data_type == 'slide': - slide = CourseDocuments.objects.select_related().get(pk=pk, course_id=course) - path = slide.document_url - slide.delete() - #to delete the submitted assignment - elif data_type == 'stuassignment': - stu_assi = StudentAssignment.objects.select_related().get(pk=pk) - path = stu_assi.upload_url - stu_assi.delete() - #to delete the assignment uploaded by faculty - elif data_type == 'lecassignment': - lec_assi = Assignment.objects.select_related().get(pk=pk) - path = lec_assi.assignment_url - lec_assi.delete() - cmd = "rm "+path - subprocess.call(cmd, shell=True) - data = { 'msg': 'Data Deleted successfully'} - return HttpResponse(json.dumps(data), content_type='application/json') - -# to upload videos related to the course -@login_required -def add_videos(request, course_code): - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - #only faculty can add the videos - if extrainfo.user_type == "faculty": - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - course = ins.curriculum_id.course_id - try: - description = request.POST.get('description') #the media files required - vid = request.FILES.get('img') - name = request.POST.get('name') - filename, file_extenstion = os.path.splitext(request.FILES.get('img').name) - except: - return HttpResponse("Please fill each and every field correctly!") - #saving the media files - filename = name - full_path = settings.MEDIA_ROOT + "/online_cms/" + course_code + "/vid/" - url = settings.MEDIA_URL+filename + file_extenstion - if not os.path.isdir(full_path): - cmd = "mkdir "+full_path - subprocess.call(cmd, shell=True) - fs = FileSystemStorage(full_path, url) - fs.save(filename+file_extenstion, vid) - uploaded_file_url = full_path + filename + file_extenstion - #saving in the - video = CourseVideo.objects.create( - course_id=course, - upload_time=datetime.datetime.now(), + return Response({'id': sub.pk, 'submittedAt': timezone.now().isoformat()}) + +class ApiGradeAssignment(BaseCourseView): + def post(self, request, course_code, pk=None): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + return Response({'detail': 'Faculty only'}, status=403) + + sub_pk = request.data.get('student_assignment_id') or request.data.get('id') or pk + sub = models.StudentAssignment.objects.get(pk=sub_pk) + sub.score = request.data.get('score') + sub.feedback = request.data.get('feedback') + sub.save() + return Response({'status': 'graded'}) + +class ApiDeleteAssignment(BaseCourseView): + def delete(self, request, course_code, pk): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + return Response({'detail': 'Faculty only'}, status=403) + + models.Assignment.objects.filter(pk=pk).delete() + return Response(status=204) + +class ApiDocuments(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + + curr = services.get_course_obj(course_code) + docs = models.CourseDocuments.objects.filter(course_id=curr.course_id) + res = [] + for d in docs: + raw_url = (d.document_url or '').strip() if hasattr(d, 'document_url') else '' + if raw_url: + if raw_url.startswith('http://') or raw_url.startswith('https://'): + full_url = raw_url + elif raw_url.startswith('/'): + full_url = request.build_absolute_uri(raw_url) + else: + full_url = request.build_absolute_uri('/' + raw_url) + else: + full_url = None + + res.append({ + 'id': d.pk, + 'title': getattr(d, 'title', None) or getattr(d, 'document_name', ''), + 'description': d.description, + 'url': full_url, + # Back-compat for any older UI expecting docFile. + 'docFile': full_url, + 'uploadedAt': d.upload_time.isoformat() if hasattr(d, 'upload_time') else None, + }) + return Response(res) + +class ApiAddDocument(BaseCourseView): + parser_classes = [JSONParser, FormParser, MultiPartParser] + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + return Response({'detail': 'Faculty only'}, status=403) + + curr = services.get_course_obj(course_code) + + title = (request.data.get('title') or request.data.get('document_name') or '').strip() + description = (request.data.get('description') or '').strip() + url = request.data.get('url') or request.data.get('document_url') or request.data.get('doc_file') + + if url is None: + return Response({'detail': 'url is required'}, status=400) + + # If someone posts a file in multipart, this will be an UploadedFile. + if hasattr(url, 'read'): + return Response({'detail': 'Only link uploads are supported. Provide a URL.'}, status=400) + + url = str(url).strip() + if not url: + return Response({'detail': 'url is required'}, status=400) + + # Model constraints + if hasattr(models.CourseDocuments, 'document_name'): + title = title[:40] + description = description[:100] + + doc = models.CourseDocuments.objects.create( + course_id=curr.course_id, description=description, - video_url=uploaded_file_url, - video_name=name + document_name=title or 'Material', + document_url=url, ) - create_thumbnail(course_code,course, video, name, file_extenstion, 'Big', 1, '700:500') - create_thumbnail(course_code,course, video, name, file_extenstion, 'Small', 1, '170:127') - return HttpResponse("Upload successful.") - else: - return HttpResponse("not found") - - -@login_required -def forum(request, course_code): - # take care of sem - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - if extrainfo.user_type == "student": - student = Student.objects.select_related('id').get(id=extrainfo) - roll = student.id.id[:4] - course = Course.objects.select_related().get(course_id=course_code, sem=semester(roll)) - else: - instructor = Curriculum_Instructor.objects.select_related().filter(instructor_id=extrainfo) - for ins in instructor: - if ins.course_id.course_id == course_code: - course = ins.course_id - comments = Forum.objects.select_related().filter(course_id=course).order_by('comment_time') - instructor = Curriculum_Instructor.objects.get(course_id=course) - if instructor.instructor_id.user.pk == request.user.pk: - lec = 1 - else: - lec = 0 - answers = collections.OrderedDict() - for comment in comments: - fr = ForumReply.objects.select_related().filter(forum_reply=comment) - fr1 = ForumReply.objects.select_related().filter(forum_ques=comment) - if not fr: - answers[comment] = fr1 - context = {'course': course, 'answers': answers, 'Lecturer': lec} - return render(request, 'online_cms/forum.html', context) - - -@login_required -def ajax_reply(request, course_code): - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - if extrainfo.user_type == "student": - student = Student.objects.select_related('id').get(id=extrainfo) - roll = student.id.id[:4] - - curriculum_details = Curriculum.objects.select_related('course_id').filter(course_code=course_code) #curriculum id - #print(curriculum_details[0].course_id) - #print(Curriculum.objects.values_list('curriculum_id')) - course = curriculum_details[0].course_id - # course = Course.objects.get(course_id=course_code, sem=semester(roll)) - else: - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - course = ins.curriculum_id.course_id - ex = ExtraInfo.objects.select_related().get(user=request.user) - f = Forum( - course_id=course, - commenter_id=ex, - comment=request.POST.get('reply') - ) - f.save() - ques = Forum.objects.select_related().get(pk=request.POST.get('question')) - fr = ForumReply( - forum_ques=ques, - forum_reply=f - ) - fr.save() - name = request.user.first_name + " " + request.user.last_name - time = f.comment_time.strftime('%b. %d, %Y, %I:%M %p') - data = {'pk': f.pk, 'reply': f.comment, 'replier': name, 'time': time} - return HttpResponse(json.dumps(data), content_type='application/json') - - -@login_required -def ajax_new(request, course_code): - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - if extrainfo.user_type == "student": - student = Student.objects.select_related('id').get(id=extrainfo) - roll = student.id.id[:4] - #course = Course.objects.get(course_id=course_code, sem=semester(roll)) - curriculum_details = Curriculum.objects.select_related('course_id').filter(course_code=course_code) #curriculum id - #print(curriculum_details[0].course_id) - #print(Curriculum.objects.values_list('curriculum_id')) - course = curriculum_details[0].course_id - else: - - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - course = ins.curriculum_id.course_id - ex = ExtraInfo.objects.select_related().get(user=request.user) - f = Forum( - course_id=course, - commenter_id=ex, - comment=request.POST.get('question') - ) - f.save() - name = request.user.first_name + " " + request.user.last_name - time = f.comment_time.strftime('%b. %d, %Y, %I:%M %p') - - data = {'pk': f.pk, 'question': f.comment, 'replier': f.commenter_id.user.username, - 'time': time, 'name': name} - return HttpResponse(json.dumps(data), content_type='application/json') - - -@login_required -def ajax_remove(request, course_code): - f = Forum.objects.select_related().get( - pk=request.POST.get('question') - ) - fr = ForumReply.objects.select_related().filter( - forum_reply=f - ) - - if not fr: - fr1 = ForumReply.objects.select_related().filter( - forum_ques=f + + if hasattr(doc, 'title'): + doc.title = title + doc.save(update_fields=['title']) + + return Response({'id': doc.pk}) + +class ApiDeleteDocument(BaseCourseView): + def delete(self, request, course_code, pk): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + return Response({'detail': 'Faculty only'}, status=403) + + models.CourseDocuments.objects.filter(pk=pk).delete() + return Response(status=204) + +class ApiForum(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + curr = services.get_course_obj(course_code) + + # Forum rows are messages. ForumReply is an edge table: parent (forum_ques) -> child (forum_reply). + messages = models.Forum.objects.filter(course_id=curr.course_id).select_related('commenter_id', 'commenter_id__user').order_by('comment_time') + edges = models.ForumReply.objects.filter( + forum_ques__course_id=curr.course_id, + forum_reply__course_id=curr.course_id, + ).select_related('forum_ques', 'forum_reply') + + by_id = {} + children = {} + is_child = set() + + for m in messages: + posted_by = 'Unknown' + posted_by_id = None + if m.commenter_id and getattr(m.commenter_id, 'user', None): + posted_by = m.commenter_id.user.get_full_name() or m.commenter_id.user.username + posted_by_id = m.commenter_id.user.username + + by_id[m.pk] = { + 'id': m.pk, + 'message': m.comment, + 'postedBy': posted_by, + 'postedById': posted_by_id, + 'createdAt': m.comment_time.isoformat() if hasattr(m, 'comment_time') else None, + 'replies': [], + } + children[m.pk] = [] + + for e in edges: + parent_id = e.forum_ques_id + child_id = e.forum_reply_id + if parent_id in children and child_id in by_id: + children[parent_id].append(child_id) + is_child.add(child_id) + + # Build a nested tree (depth-first). Guard against cycles. + def build_node(node_id, seen): + if node_id in seen: + return None + seen.add(node_id) + node = dict(by_id[node_id]) + node['replies'] = [] + for cid in children.get(node_id, []): + child_node = build_node(cid, seen) + if child_node: + node['replies'].append(child_node) + return node + + roots = [mid for mid in by_id.keys() if mid not in is_child] + res = [] + for rid in roots: + n = build_node(rid, set()) + if n: + res.append(n) + return Response(res) + +class ApiForumNew(BaseCourseView): + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + curr = services.get_course_obj(course_code) + extra_info, _ = self.get_role_info(request) + + msg = (request.data.get('message') or request.data.get('question') or request.data.get('comment') or '').strip() + if not msg: + return Response({'detail': 'message is required'}, status=400) + + f = models.Forum.objects.create( + course_id=curr.course_id, + commenter_id=extra_info, + comment=msg, ) - for x in fr1: - x.forum_reply.delete() - x.delete() - f.delete() - else: - fr.delete() - f.delete() - data = {'message': 'deleted'} - return HttpResponse(json.dumps(data), content_type='application/json') - - -@login_required -def add_assignment(request, course_code): #from faculty side - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - if extrainfo.user_type == "faculty": - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - course = ins.curriculum_id.course_id - try: - assi = request.FILES.get('img') - name = request.POST.get('name') - filename, file_extenstion = os.path.splitext(request.FILES.get('img').name) - except: - return HttpResponse("Please Enter The Form Properly") - filename = name - full_path = settings.MEDIA_ROOT + "/online_cms/" + course_code + "/assi/" + name + "/" - url = settings.MEDIA_URL + filename - if not os.path.isdir(full_path): - cmd = "mkdir " + full_path - subprocess.call(cmd, shell=True) - fs = FileSystemStorage(full_path, url) - fs.save(filename+file_extenstion, assi) - uploaded_file_url = full_path + filename + file_extenstion - assign = Assignment( - course_id=course, - submit_date=request.POST.get('myDate'), - assignment_url=uploaded_file_url, - assignment_name=name + return Response({'id': f.pk}) + +class ApiForumReply(BaseCourseView): + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, _ = self.get_role_info(request) + + parent_id = request.data.get('parent_id') or request.data.get('forum_id') + msg = (request.data.get('message') or request.data.get('reply') or '').strip() + if not parent_id: + return Response({'detail': 'parent_id is required'}, status=400) + if not msg: + return Response({'detail': 'message is required'}, status=400) + + parent = models.Forum.objects.get(pk=parent_id) + child = models.Forum.objects.create( + course_id=parent.course_id, + commenter_id=extra_info, + comment=msg, ) - assign.save() - return HttpResponse("Upload successful.") - else: - return HttpResponse("not found") - - -@login_required -def edit_bank(request, course_code, qb_code): - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - lec = 1 - if extrainfo.user_type == "faculty": - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - course = ins.curriculum_id.course_id - qb = QuestionBank.objects.select_related().filter(id=qb_code) - topics = Topics.objects.select_related().filter(course_id=course) - Topic = {} - if qb: - questions = Question.objects.select_related().filter(question_bank=qb[0]).values_list('topic', flat=True) - counter = dict(collections.Counter(questions)) - for topic in topics: - if topic.pk in counter.keys(): - Topic[topic] = counter[topic.pk] - else: - Topic[topic] = 0 - context = { - 'Lecturer': lec, - 'questionbank': qb[0], - 'topics': Topic, - 'course': course - } - return render(request, 'coursemanagement/create_bank.html', context) - else: - return HttpResponse("Unauthorized") - - -@login_required -def create_bank(request, course_code): - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - if extrainfo.user_type == "faculty": - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - course = ins.curriculum_id.course_id - qb = QuestionBank.objects.create(instructor_id=extrainfo, - course_id=course, name=request.POST.get('qbname')) - return redirect('/ocms/' + course_code + '/edit_bank/'+str(qb.id)) - -@login_required -def create_topic(request, course_code): - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - if extrainfo.user_type == "faculty": - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - course = ins.curriculum_id.course_id - topic = Topics.objects.create(course_id=course, topic_name=request.POST.get('topic_name')) - return redirect('/ocms/' + course_code) - - - -@login_required -def remove_bank(request, course_code): - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - if extrainfo.user_type == "faculty": - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - course = ins.curriculum_id.course_id - qb = QuestionBank.objects.select_related().get(id=request.POST.get('pk')) - qb.delete() - qb = QuestionBank.objects.select_related().filter(instructor_id=extrainfo, course_id=course) - data = {'message': "Removed", 'numberof_qbs': len(qb)} - return HttpResponse(json.dumps(data), content_type='application/json') - -@login_required -def remove_topic(request, course_code): - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - if extrainfo.user_type == "faculty": - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - course = ins.curriculum_id.course_id - topic = Topics.objects.select_related().get(id=request.POST.get('pk')) - topic.delete() - n_topics = Topics.objects.select_related().filter(course_id=course) - data = {'message': "Removed", 'numberof_topics': len(n_topics)} - return HttpResponse(json.dumps(data), content_type='application/json') - - -@login_required -def add_question(request, course_id, qb_code, topic_id): - user = request.user - course = Course.objects.select_related().get(pk=course_id) - curriculum = Curriculum.objects.select_related().get(course_id=course) - course_code = curriculum.course_code - extrainfo = ExtraInfo.objects.select_related().get(user=user) - if extrainfo.user_type == "faculty": - qb = QuestionBank.objects.select_related().filter(pk=qb_code) - topic = Topics.objects.select_related().get(id=request.POST.get('topic')) - try: - filename, file_extenstion = os.path.splitext(request.FILES['image'].name) - image = request.FILES['image'] - topic_name = topic.topic_name.replace(" ", "_")[:-2] - full_path = settings.MEDIA_ROOT + "/online_cms/" + course_code - full_path = full_path + "/qb/" + qb_code + "/" + topic_name + "/" - url = settings.MEDIA_URL + filename - if not os.path.isdir(full_path): - cmd = "mkdir " + full_path - subprocess.call(cmd, shell=True) - fs = FileSystemStorage(full_path, url) - fs.save(image.name, image) - uploaded_file_url = "/media/online_cms/" + course_code - uploaded_file_url = uploaded_file_url + "/qb/" + qb_code + "/" - uploaded_file_url = uploaded_file_url + topic_name + "/" + image.name - except: - uploaded_file_url = None - - Question.objects.create( - question_bank=qb[0], - topic=topic, - image=uploaded_file_url, - question=request.POST.get('problem-statement'), - options1=request.POST.get('option1'), - options2=request.POST.get('option2'), - options3=request.POST.get('option3'), - options4=request.POST.get('option4'), - options5=request.POST.get('option5'), - answer=request.POST.get('answer'), - marks=request.POST.get('score') + edge = models.ForumReply.objects.create( + forum_ques=parent, + forum_reply=child, ) - return redirect('/ocms/' + course_code + '/edit_bank/'+str(qb[0].id)) - - -@login_required -def remove_question(request, course_code, qb_code, topic_id): - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - if extrainfo.user_type == "faculty": - question = Question.objects.select_related().get(pk=request.POST.get('pk')) - question.delete() - data = {'message': 'question deleted'} - return HttpResponse(json.dumps(data), content_type='application/json') - - -@login_required -def edit_qb_topics(request, course_code, qb_code, topic_id): - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - lec = 1 - if extrainfo.user_type == "faculty": - instructor = Curriculum_Instructor.objects.select_related().filter(instructor_id=extrainfo) - for ins in instructor: - if ins.course_id.course_id == course_code: - course = ins.course_id - qb = QuestionBank.objects.select_related().filter(pk=qb_code) - topic = Topics.objects.select_related().get(id=topic_id) - questions = Question.objects.select_related().filter(question_bank=qb[0], topic=topic) - context = { - 'Lecturer': lec, - 'questionbank': qb[0], - 'topic': topic, - 'questions': questions, - 'course': course - } - return render(request, 'coursemanagement/topicwisequestion.html', context) - - -@login_required -def quiz(request, quiz_id): - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - if extrainfo.user_type == 'student': - # student = Student.objects.get(id=extrainfo) - quiz = Quiz.objects.select_related().get(pk=quiz_id) - length = quiz.number_of_question - rules = quiz.rules - rules = [z.encode('ascii', 'ignore') for z in rules.split('/')] - ques_pk = QuizQuestion.objects.select_related().filter(quiz_id=quiz).values_list('pk', flat=True) - try: - random_ques_pk = random.sample(list(ques_pk), length) - except: - random_ques_pk = ques_pk - shuffed_questions = [] - for x in random_ques_pk: - shuffed_questions.append(QuizQuestion.objects.select_related().get(pk=x)) - end = quiz.end_time - now = timezone.now() + datetime.timedelta(hours=5.5) - diff = end-now - days, seconds = diff.days, diff.seconds - hours = days * 24 + seconds // 3600 - minutes = (seconds % 3600) // 60 - seconds = seconds % 60 - return render(request, 'coursemanagement/quiz.html', - {'contest': quiz, 'ques': shuffed_questions, - 'days': days, 'hours': hours, 'minutes': minutes, - 'seconds': seconds, 'rules': rules}) - else: - return HttpResponse("unautherized Access!!It will be reported!!") - - -@login_required -def ajax_q(request, quiz_code): - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - student = Student.objects.select_related('id').get(id=extrainfo) - q = request.POST.get('question') - question = Question.objects.select_related().get(pk=q) - quiz_id = Quiz.objects.select_related().get(pk=quiz_code) - ques = QuizQuestion.objects.select_related().get(question=question, quiz_id=quiz_id) - - ans = int(request.POST.get('answer')) - lead = StudentAnswer.objects.filter(quiz_id=quiz_id, question_id=ques, student_id=student) - if lead: - lead = lead[0] - lead.choice = ans - lead.save() - else: - lead = StudentAnswer(quiz_id=quiz_id, question_id=ques, student_id=student, choice=ans) - lead.save() - data = {'status': "1"} - return HttpResponse(json.dumps(data), content_type='application/json') - - -@login_required -def submit(request, quiz_code): - ei = ExtraInfo.objects.select_related().get(user=request.user) - student = Student.objects.select_related().get(id=ei) - quiz = Quiz.objects.select_related().get(pk=quiz_code) - stu_ans = StudentAnswer.objects.select_related('question_id__quiz_id').filter(student_id=student, quiz_id=quiz) - score = 0 - for s_ans in stu_ans: - if s_ans.question_id.question.answer == s_ans.choice: - score += s_ans.question_id.question.marks - else: - score += (s_ans.quiz_id.negative_marks * s_ans.question_id.question.marks) - quiz_res = QuizResult( - quiz_id=quiz, - student_id=student, - score=score, - finished=True - ) - quiz_res.save() - data = {'message': 'you have submitted, cant enter again now', 'score': quiz_res.score} - return HttpResponse(json.dumps(data), content_type="application/json") - - -@login_required -def create_quiz(request, course_code): - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - - if extrainfo.user_type == 'faculty': - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - curriculum = ins.curriculum_id - course = ins.curriculum_id.course_id - - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - registered_students = Register.objects.filter(curr_id = ins.curriculum_id.curriculum_id) - - course = ins.curriculum_id.course_id - - form = QuizForm(request.POST or None) - errors = None - if form.is_valid(): - st_time = form.cleaned_data['starttime'] - k1 = st_time.hour - k2 = st_time.minute - k3 = st_time.second - start_date_time = datetime.datetime.combine(form.cleaned_data['startdate'], datetime.time(k1, k2, k3)) - st_time = form.cleaned_data['endtime'] - k1 = st_time.hour - k2 = st_time.minute - k3 = st_time.second - end_date_time = datetime.datetime.combine(form.cleaned_data['enddate'], datetime.time(k1, k2, k3)) - duration = end_date_time - start_date_time - days, seconds = duration.days, duration.seconds - hours, remainder = divmod(duration.seconds, 3600) - minutes, seconds = divmod(remainder, 60) - - # If you want to take into account fractions of a second - seconds += duration.microseconds / 1e6 - - description = form.cleaned_data['description'].replace('\r\n', '/') - rules = form.cleaned_data['rules'].replace('\r\n', '/') - obj = Quiz.objects.create( - course_id=course, - quiz_name=form.cleaned_data['name'], - description=description, - rules=rules, - number_of_question=form.cleaned_data['number_of_questions'], - negative_marks=form.cleaned_data['negative_marks'], - start_time=start_date_time, - end_time=end_date_time, - d_day=days, - d_hour=hours, - d_minute=minutes, - ) - return redirect('/ocms/' + course_code + '/edit_quiz/' + str(obj.pk)) - if form.errors: - errors = form.errors - return render(request, 'coursemanagement/createcontest.html', - {'form': form, 'errors': errors}) - - else: - return HttpResponse("unauthorized Access!!It will be reported!!") - - -@login_required -def edit_quiz_details(request, course_code, quiz_code): - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - if extrainfo.user_type == 'faculty': - x = request.POST.get('number') - quiz = Quiz.objects.select_related().get(pk=quiz_code) - if x == 'edit1': - st_time = request.POST.get('starttime') - st_date = request.POST.get('startdate_month') + " " + request.POST.get('startdate_day') - st_date = st_date + " " + request.POST.get('startdate_year') - string = str(st_date) + " " + str(st_time) - datetime_object = datetime.datetime.strptime(string, '%m %d %Y %H:%M') - quiz.start_time = datetime_object - quiz.save() - elif x == 'edit2': - st_time = request.POST.get('endtime') - st_date = request.POST.get('enddate_month') + " " + request.POST.get('enddate_day') - st_date = st_date + " " + request.POST.get('enddate_year') - string = str(st_date) + " " + str(st_time) - datetime_object = datetime.datetime.strptime(string, '%m %d %Y %H:%M') - quiz.end_time = datetime_object - quiz.save() - elif x == 'edit3': - number = request.POST.get('number_of_questions') - score = int(quiz.total_score / quiz.number_of_question) - quiz.number_of_question = number - quiz.total_score = int(number) * score - quiz.save() - elif x == 'edit4': - score = request.POST.get('per_question_score') - quiz.total_score = int(score) * quiz.number_of_question - quiz.save() - return HttpResponse("Done") - - else: - return HttpResponse("unautherized Access!!It will be reported!!") - - -@login_required -def edit_quiz(request, course_code, quiz_code): - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - if extrainfo.user_type == 'faculty': - lec = 1 - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - curriculum = ins.curriculum_id - course = ins.curriculum_id.course_id - # errors = None - quiz = Quiz.objects.select_related().get(pk=quiz_code) - questions = QuizQuestion.objects.select_related('question').filter(quiz_id=quiz) - topic_list = [] - for q in questions: - topic_list.append(q.question.topic) - counter = dict(collections.Counter(topic_list)) - form = QuizForm() - questions_left = quiz.number_of_question - len(questions) - description = quiz.description - description = [z.encode('ascii', 'ignore') for z in description.split('/')] - rules = quiz.rules - rules = [z.encode('ascii', 'ignore') for z in rules.split('/')] - questionbank = QuestionBank.objects.select_related().filter(instructor_id=extrainfo, course_id=course) - topic = Topics.objects.select_related().filter(course_id=course) - return render(request, 'coursemanagement/editcontest.html', - {'details': quiz, 'questionbank': questionbank, 'topics': topic, - 'course': course, 'lecturer': lec, 'form': form, - 'counter': counter, 'questions': questions, 'description': description, - 'rules': rules, 'questions_left': questions_left, 'curriculum': curriculum}) - else: - return HttpResponse("unauthorized Access!!It will be reported!!") - - -@login_required -def edit_quiz_topic(request, course_code, quiz_code, topic_id): - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - lec = 1 - if extrainfo.user_type == "faculty": - instructor = Curriculum_Instructor.objects.select_related().filter(instructor_id=extrainfo) - for ins in instructor: - if ins.course_id.course_id == course_code: - course = ins.course_id - quiz_question = QuizQuestion.objects.select_related('question').filter(quiz_id=quiz_code) - quest = [] - quiz = Quiz.objects.select_related().get(pk=quiz_code) - for q in quiz_question: - if str(q.question.topic.pk) == topic_id: - quest.append(q.question) - - topic = Topics.objects.select_related().get(id=topic_id) - return render(request, 'coursemanagement/topicwisequiz.html', - {'Lecturer': lec, 'questions': quest, - 'quiz': quiz, 'topic': topic, 'course': course}) - - -@login_required -def remove_quiz_question(request, course_code, quiz_code, topic_id): - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - if extrainfo.user_type == "faculty": - question = Question.objects.select_related().get(pk=request.POST.get('pk')) - question_remove = QuizQuestion.objects.select_related().get(question=question, quiz_id=quiz_code) - question_remove.delete() - data = {'message': 'question deleted'} - return HttpResponse(json.dumps(data), content_type='application/json') - - -@login_required -def add_question_topicwise(request, course_code, quiz_id): - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - if extrainfo.user_type == 'faculty': - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - course = ins.curriculum_id.course_id - ques_bank = request.POST.get('qbank') - quiz = Quiz.objects.select_related().get(pk=quiz_id) - topic = request.POST.get('topic') - questions = Question.objects.select_related().filter(question_bank=ques_bank, topic=topic) - questions_already_present = QuizQuestion.objects.select_related().filter(quiz_id=quiz_id) - question_already_present = [] - for ques in questions_already_present: - question_already_present.append(ques.question) - temp = [] - if questions_already_present: - for question in questions: - if question not in question_already_present: - temp.append(question) - questions = temp - context = { - 'questions': questions, - 'course': course, - 'details': quiz - } - return render(request, 'coursemanagement/select_question.html', context) - - -@login_required -def add_questions_to_quiz(request, course_id, quiz_id): - course = Course.objects.select_related().get(pk=course_id) - curriculum = Curriculum.objects.select_related().get(course_id = course) - course_code = curriculum.course_code - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - if extrainfo.user_type == 'faculty': - questions_selected = request.POST.getlist('questions_selected') - quiz = Quiz.objects.select_related().get(pk=quiz_id) - for questions in questions_selected: - question = Question.objects.select_related().get(pk=int(questions)) - QuizQuestion.objects.create( - quiz_id=quiz, - question=question + return Response({'id': edge.pk, 'message_id': child.pk}) + +class ApiForumRemove(BaseCourseView): + def delete(self, request, course_code, pk): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + target = models.Forum.objects.filter(pk=pk).select_related('commenter_id', 'commenter_id__user').first() + if not target: + return Response(status=204) + + is_owner = bool(target.commenter_id_id == getattr(extra_info, 'id', None)) + if is_student_user and not is_owner: + return Response({'detail': 'Not allowed'}, status=403) + + # Delete subtree: collect all descendants via ForumReply edges. + to_delete = set([target.pk]) + frontier = [target.pk] + while frontier: + parent_ids = frontier + frontier = [] + child_ids = list(models.ForumReply.objects.filter(forum_ques_id__in=parent_ids).values_list('forum_reply_id', flat=True)) + for cid in child_ids: + if cid not in to_delete: + to_delete.add(cid) + frontier.append(cid) + models.Forum.objects.filter(pk__in=list(to_delete)).delete() + return Response(status=204) + +class ApiQuizzes(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + curr = services.get_course_obj(course_code) + extra_info, is_student_user = self.get_role_info(request) + + quizzes = models.Quiz.objects.filter(course_id=curr.course_id) + res = [] + now = timezone.now() + for q in quizzes: + if is_student_user: + student = models.Student.objects.get(id=extra_info) + has_finished = models.QuizResult.objects.filter(quiz_id=q, student_id=student, finished=True).exists() + if not (q.start_time <= now <= q.end_time and not has_finished): + continue + + res.append({ + 'id': q.pk, + 'title': getattr(q, 'title', getattr(q, 'quiz_name', '')), + 'description': q.description if hasattr(q, 'description') else '', + 'startTime': q.start_time.isoformat(), + 'endTime': q.end_time.isoformat(), + 'duration': getattr(q, 'duration', getattr(q, 'd_time', 0)), + 'negativeMarks': getattr(q, 'negative_marks', 0), + 'totalQuestions': q.number_of_question if hasattr(q, 'number_of_question') else 0 + }) + return Response(res) + +class ApiCreateQuiz(BaseCourseView): + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: return Response({'detail': 'Faculty only'}, status=403) + + curr = services.get_course_obj(course_code) + d = request.data + + title = (d.get('title') or '').strip() + if not title: + return Response({'detail': 'title is required'}, status=400) + + start_raw = d.get('start_time') + end_raw = d.get('end_time') + start_dt = parse_datetime(start_raw) if isinstance(start_raw, str) else None + end_dt = parse_datetime(end_raw) if isinstance(end_raw, str) else None + if start_dt is None or end_dt is None: + return Response({'detail': 'start_time and end_time must be ISO datetimes'}, status=400) + + if timezone.is_naive(start_dt): + start_dt = timezone.make_aware(start_dt) + if timezone.is_naive(end_dt): + end_dt = timezone.make_aware(end_dt) + + if end_dt <= start_dt: + return Response({'detail': 'end_time must be after start_time'}, status=400) + + delta = end_dt - start_dt + total_minutes = int(delta.total_seconds() // 60) + days = total_minutes // (60 * 24) + hours = (total_minutes % (60 * 24)) // 60 + minutes = total_minutes % 60 + + q = models.Quiz.objects.create( + course_id=curr.course_id, + quiz_name=title[:20], + start_time=start_dt, + end_time=end_dt, + d_day=str(days).zfill(2), + d_hour=str(hours).zfill(2), + d_minute=str(minutes).zfill(2), + negative_marks=float(d.get('negative_marks') or 0), + description=(d.get('description') or '').strip(), + rules=(d.get('rules') or '').strip(), + ) + return Response({'id': q.pk}) + +class ApiQuizDetail(BaseCourseView): + def get(self, request, course_code, quiz_id): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + q = models.Quiz.objects.get(pk=quiz_id) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + if timezone.now() > q.end_time: + return Response({'detail': 'Quiz has ended'}, status=403) + student = models.Student.objects.get(id=extra_info) + if models.QuizResult.objects.filter(quiz_id=q, student_id=student, finished=True).exists(): + return Response({'detail': 'You have already attempted this quiz'}, status=403) + + questions = models.QuizQuestion.objects.filter(quiz_id=q).select_related('question') + res_q = [] + for x in questions: + ques = x.question + res_q.append({ + 'id': x.pk, + 'question': ques.question, + 'option1': ques.options1, + 'option2': ques.options2, + 'option3': ques.options3, + 'option4': ques.options4, + 'option5': ques.options5, + 'marks': ques.marks, + }) + return Response({ + 'id': q.pk, + 'title': getattr(q, 'title', getattr(q, 'quiz_name', '')), + 'questions': res_q + }) + +class ApiQuizSubmit(BaseCourseView): + def post(self, request, course_code, quiz_id): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if not is_student_user: return Response({'detail': 'Student only'}, status=403) + + q = models.Quiz.objects.get(pk=quiz_id) + if timezone.now() > q.end_time: + return Response({'detail': 'Quiz has ended'}, status=403) + + student = models.Student.objects.get(id=extra_info) + answers = request.data.get('answers', []) + score = 0 + total = 0 + negative = getattr(q, 'negative_marks', 0) + + for ans in answers: + qq = models.QuizQuestion.objects.select_related('question').get(pk=ans['question_id']) + ques = qq.question + total += ques.marks + correct = ques.answer + models.StudentAnswer.objects.create( + student_id=student, + quiz_id=q, + question_id=qq, + choice=ans['selected_option'] ) - return redirect('/ocms/' + course_code + '/edit_quiz/' + quiz_id) - - -@login_required -def preview_quiz(request, course_code, quiz_code): - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - if extrainfo.user_type == 'faculty': - instructor = Curriculum_Instructor.objects.select_related('curriculum_id').filter(instructor_id=extrainfo) - for ins in instructor: - if ins.curriculum_id.course_code == course_code: - course = ins.curriculum_id.course_id - quiz = Quiz.objects.select_related().get(pk=quiz_code) - questions = QuizQuestion.objects.select_related().filter(quiz_id=quiz) - - total_marks = 0 - for q in questions: - total_marks = total_marks + q.question.marks - rules = quiz.rules - rules = [z.encode('ascii', 'ignore') for z in rules.split('/')] - - context = { - 'contest': quiz, - 'course': course, - 'rules': rules, - 'questions': questions, - 'totalmarks': total_marks, - } - return render(request, 'coursemanagement/preview_quiz.html', context) - - -@login_required -def remove_quiz(request, course_code): - quiz = Quiz.objects.select_related().get(pk=request.POST.get('pk')) - quizQuestion = QuizQuestion.objects.select_related().filter(quiz_id=quiz) - for q in quizQuestion: - q.delete() - quiz.delete() - return HttpResponse("Done") - - -@login_required -def ajax_assess(request, course_code): - sa = StudentAssignment.objects.select_related().get(pk=request.POST.get('pk')) - sa.score = request.POST.get('marks') - sa.save() - return HttpResponse("Marks uploaded") - - -@login_required -def ajax_feedback(request, course_code): - sa = StudentAssignment.objects.select_related().get(pk=request.POST.get('pk')) - sa.feedback = request.POST.get('feedback') - sa.save() -# print(sa,"qwerty") - return HttpResponse("Feedback uploaded") - -#For adding objective assignments for practice -@login_required -def create_practice_contest(request, course_code): - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - - if extrainfo.user_type == 'faculty': - instructor = Instructor.objects.filter(instructor_id=extrainfo) - for ins in instructor: - if ins.course_id.course_id == course_code: - course = ins.course_id - form = PracticeForm(request.POST or None) - errors = None - if form.is_valid(): - description = form.cleaned_data['description'].replace('\r\n', '/') - obj = Practice.objects.create( - course_id=course, - prac_quiz_name=form.cleaned_data['name'], - negative_marks=form.cleaned_data['negative_marks'], - number_of_question=form.cleaned_data['number_of_questions'], - description=description, - total_score =form.cleaned_data['total_score'], - ) - # print "Done" - return redirect('/ocms/' + course_code + '/edit_practice_contest/' + str(obj.pk)) - '''except: - return HttpResponse('Unexpected Error')''' - if form.errors: - errors = form.errors - return render(request, 'coursemanagement/create_practice_contest.html', - {'form': form, 'errors': errors}) - -@login_required -def edit_practice_contest(request, course_code, practice_contest_code): - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - if extrainfo.user_type == 'faculty': - lec = 1 - instructor = Instructor.objects.filter(instructor_id=extrainfo) - for ins in instructor: - if ins.course_id.course_id == course_code: - course = ins.course_id - # errors = None - practice_contest = Practice.objects.get(pk=practice_contest_code) - questions = PracticeQuestion.objects.filter(prac_quiz_id=practice_contest) - topic_list = [] - for q in questions: - topic_list.append(q.question.topic) - counter = dict(collections.Counter(topic_list)) - form = PracticeQuestionFormObjective() - questions_left = practice_contest.number_of_question - len(questions) - description = practice_contest.description - description = [z.encode('ascii', 'ignore') for z in description.split('/')] - - #questionbank = QuestionBank.objects.filter(instructor_id=extrainfo, course_id=course) - #topic = Topics.objects.filter(course_id=course) - return render(request, 'coursemanagement/edit_practice_contest.html', - {'details': practice_contest, #'questionbank': questionbank, 'topics': topic, - 'course': course, 'lecturer': lec, 'form': form, - 'counter': counter, 'questions': questions, 'description': description, - 'questions_left': questions_left}) - else: - return HttpResponse("unautherized Access!!It will be reported!!") - -@login_required -def edit_practice_details(request, course_code,practice_contest_code): - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - if extrainfo.user_type == 'faculty': - x = request.POST.get('number') - practice_contest = Practice.objects.select_related().get(pk=practice_contest_code) - - if x == 'edit1': - number = request.POST.get('number_of_questions') - score = int(practice_contest.total_score / practice_contest.number_of_question) - practice_contest.number_of_question = number - # practice_contest.total_score = int(number) * score - practice_contest.save() - elif x == 'edit2': - score = request.POST.get('total_score') - practice_contest.total_score = int(score) * practice_contest.number_of_question - practice_contest.save() - return HttpResponse("Done") - - else: - return HttpResponse("unauthorized Access!!It will be reported!!") - -@login_required -def add_questions_to_practice_contest(request, course_code, practice_contest_id): - extrainfo = ExtraInfo.objects.select_related().get(user=request.user) - if extrainfo.user_type == 'faculty': - questions_selected = request.POST.getlist('questions_selected') - quiz = Quiz.objects.select_related().get(pk=quiz_id) - for questions in questions_selected: - question = Question.objects.select_related().get(pk=int(questions)) - PracticeQuestion.objects.create( - quiz_id=quiz, - question=question + if int(ans['selected_option']) == int(correct): + score += ques.marks + else: + score -= negative + + # QuizResult.score is non-null in this schema; don't use get_or_create() + # because the implicit create would attempt score=NULL and fail. + models.QuizResult.objects.update_or_create( + student_id=student, + quiz_id=q, + defaults={'score': score, 'finished': True}, + ) + + return Response({'score': score, 'totalMarks': total}) + +class ApiRemoveQuiz(BaseCourseView): + def delete(self, request, course_code, quiz_id): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled' }, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: return Response({'detail': 'Faculty only'}, status=403) + models.Quiz.objects.filter(pk=quiz_id).delete() + return Response(status=204) + +class ApiAttendance(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled'}, status=403) + + extra_info, is_student_user = self.get_role_info(request) + + if is_student_user: + student = Student.objects.get(id=extra_info) + recs = Student_attendance.objects.filter( + instructor_id__curriculum_id__course_code=course_code, + student_id=student, + ).order_by('date') + return Response([{'date': r.date.isoformat(), 'present': r.present} for r in recs]) + + # faculty + link = services.get_instructor_link(extra_info, course_code) + if not link: + return Response({'detail': 'Not an instructor for this course'}, status=403) + + recs = Student_attendance.objects.filter(instructor_id=link).select_related( + 'student_id', 'student_id__id', 'student_id__id__user' + ).order_by('date') + res = {} + for r in recs: + d = r.date.isoformat() + if d not in res: + res[d] = [] + username = r.student_id.id.user.username + res[d].append({ + 'student_id': username, + 'name': r.student_id.id.user.get_full_name() or username, + 'present': r.present, + }) + return Response(res) + + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + return Response({'detail': 'Faculty only'}, status=403) + + link = services.get_instructor_link(extra_info, course_code) + if not link: + return Response({'detail': 'Not an instructor for this course'}, status=403) + + date_str = request.data.get('date') + dt = parse_date(date_str) if isinstance(date_str, str) else None + if dt is None: + return Response({'detail': 'date is required (YYYY-MM-DD)'}, status=400) + + atts = request.data.get('attendance', []) + if not isinstance(atts, list): + return Response({'detail': 'attendance must be a list'}, status=400) + + count = 0 + for att in atts: + sid = att.get('student_id') + present = bool(att.get('present')) + if not sid: + continue + student = Student.objects.filter(id__user__username=sid).first() + if not student: + continue + rec, _ = Student_attendance.objects.get_or_create( + instructor_id=link, + student_id=student, + date=dt, ) - return redirect('/ocms/' + course_code + '/edit_quiz/' + quiz_id) - -def add_practice_question(request, course_code, practice_contest_code): - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - if extrainfo.user_type == "faculty": - prac_question = PracticeQuestion.objects.select_related().filter(pk=practice_contest_code) - # topic = Topics.objects.get(id=request.POST.get('topic')) + rec.present = present + rec.save() + count += 1 + + return Response({'status': 'saved', 'count': count}) + + +class ApiAttendanceRoster(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + return Response({'detail': 'Faculty only'}, status=403) + + link = services.get_instructor_link(extra_info, course_code) + if not link: + return Response({'detail': 'Not an instructor for this course'}, status=403) + + roster = services.get_course_roster(course_code) + return Response(roster) + +class ApiQuestionBank(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: return Response({'detail': 'Faculty only'}, status=403) + + curr = services.get_course_obj(course_code) + # Using a dummy implementation or empty list if no models exist for QB since it wasn't specified completely in models + # Assuming we might have QuestionBank models if not we send empty array try: - filename, file_extenstion = os.path.splitext(request.FILES['image'].name) - image = request.FILES['image'] - # topic_name = topic.topic_name.replace(" ", "_")[:-2] - full_path = settings.MEDIA_ROOT + "/online_cms/" + course_code - full_path = full_path + "/pq/" +practice_contest_code+ "/" + topic_name + "/" - url = settings.MEDIA_URL + filename - if not os.path.isdir(full_path): - cmd = "mkdir " + full_path - subprocess.call(cmd, shell=True) - fs = FileSystemStorage(full_path, url) - fs.save(image.name, image) - uploaded_file_url = "/media/online_cms/" + course_code - uploaded_file_url = uploaded_file_url + "/pq/" + practice_contest_code + "/" - uploaded_file_url = uploaded_file_url + "/" + image.name + banks = models.QuestionBank.objects.filter(course_id=curr.course_id) + return Response([{'id': b.pk, 'title': b.name} for b in banks]) except: - uploaded_file_url = None - - Question.objects.create( - prac_question=pq[0], - # topic=topic, - image=uploaded_file_url, - question=request.POST.get('problem-statement'), - options1=request.POST.get('option1'), - options2=request.POST.get('option2'), - options3=request.POST.get('option3'), - options4=request.POST.get('option4'), - options5=request.POST.get('option5'), - answer=request.POST.get('answer'), - + return Response([]) + +class ApiCreateBank(BaseCourseView): + def post(self, request, course_code): + return Response({}) + +class ApiAddTopic(BaseCourseView): + def post(self, request, course_code, bank_id): + return Response({}) + +class ApiAddQuestion(BaseCourseView): + def post(self, request, course_code, bank_id, topic_id): + return Response({}) + +class ApiGrading(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + curr = services.get_course_obj(course_code) + extra_info, is_student_user = self.get_role_info(request) + + schemes = models.GradingScheme.objects.filter(course_id=curr.course_id) + if is_student_user: + student = models.Student.objects.get(id=extra_info) + evals = models.StudentEvaluation.objects.filter(student=student, scheme__in=schemes) + sch_res = [{'id': s.pk, 'component': s.component, 'weightage': s.weightage, 'max_marks': s.max_marks} for s in schemes] + ev_res = [{'id': e.pk, 'scheme_id': e.scheme.pk, 'marks_obtained': e.marks_obtained} for e in evals] + return Response({'schemes': sch_res, 'evaluations': ev_res}) + else: + evals = models.StudentEvaluation.objects.filter(scheme__in=schemes) + sch_res = [{'id': s.pk, 'component': s.component, 'weightage': s.weightage, 'max_marks': s.max_marks} for s in schemes] + ev_res = [{'id': e.pk, 'scheme_id': e.scheme.pk, 'student_id': e.student.id.user.username, 'student_name': e.student.id.user.get_full_name(), 'marks_obtained': e.marks_obtained} for e in evals] + return Response({'schemes': sch_res, 'evaluations': ev_res}) + +class ApiCreateGradingScheme(BaseCourseView): + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: return Response({'detail': 'Faculty only'}, status=403) + + curr = services.get_course_obj(course_code) + w = float(request.data.get('weightage', 0)) + schemes = models.GradingScheme.objects.filter(course_id=curr.course_id) + total = sum([s.weightage for s in schemes]) + if total + w > 100: + return Response({'detail': 'Total weightage cannot exceed 100'}, status=400) + + gs = models.GradingScheme.objects.create( + course_id=curr.course_id, + component=request.data.get('component'), + weightage=w, + max_marks=float(request.data.get('max_marks', 0)) ) - return redirect('/ocms/' + course_code + '/edit_practice_contest/'+str(pq[0].id)) - -# @csrf_protect -# @login_required -# def edit_marks(request, course_code): -# user = request.user -# extrainfo = ExtraInfo.objects.get(user=user) - -# if extrainfo.user_type == 'faculty': -# instructor = Curriculum_Instructor.objects.filter(instructor_id=extrainfo) - -# for ins in instructor: -# if ins.curriculum_id.course_code == course_code: -# registered_students = Register.objects.filter(curr_id = ins.curriculum_id.curriculum_id) - - -# exam = request.POST.get('examtype') -# score = request.POST.getlist('enteredmarks') - -# List = list() - -# for i in range(len(registered_students)): -# m_id = registered_students[i] -# s = score[i] - -# # rows = StoreMarks.objects.filter(mid=m_id, exam_type=exam) -# num = StoreMarks.objects.filter(mid=m_id, exam_type=exam).count() -# record = StoreMarks.objects.filter(mid=m_id, exam_type=exam).values_list('marks', flat=True) - -# List.append(list(record)) - -# if num==0: -# StoreMarks.objects.create( -# mid=m_id, -# exam_type=exam, -# marks=s -# ) -# else: -# StoreMarks.objects.filter(mid=m_id, exam_type=exam).update(marks=s) - -# #print(registered_students) - - -# return HttpResponse("Upload successful") -# context= {'m_id':m_id,'registered_students': registered_students, 'record':List} -# return render(request, 'coursemanagement/assessment.html', context) - -@csrf_protect -@login_required -def get_exam_data(request,course_code): #it is store the type of exam helpful in storing the marks - exam_name = request.POST['exam_name'] - data = serializers.serialize('json', StoreMarks.objects.filter(exam_type=exam_name)) - return HttpResponse(data, content_type='application/json') - - -#to store the attendance of the student by taking from templates (attendance.html) -@login_required -def submit_attendance(request, course_code): - - user = request.user - extrainfo = ExtraInfo.objects.select_related().get(user=user) - - if extrainfo.user_type == 'faculty': #only faculty can change the attendance of the students - instructor_old = Curriculum_Instructor.objects.select_related().filter(instructor_id=extrainfo) - for x in instructor_old: - instructor = x - - if request.method == 'POST': - form = AttendanceForm(request.POST) #from the django forms using AttendanceForm - - if form.is_valid(): - # for item in form.cleaned_data['Present_absent']: - # print(item) - date = request.POST['date'] - - - #mark the attendance according to the student roll no. - all_students = request.POST.getlist('Roll') - present_students = request.POST.getlist('Present_absent') - - - for student in all_students: - - s_id = Student.objects.select_related().get(id = student) - present = False - if student in present_students: - present = True - - Student_attendance.objects.create( - student_id = s_id, - instructor_id = instructor, - date = date, - present = present - ) - + return Response({'id': gs.pk}) + +class ApiEvaluate(BaseCourseView): + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: return Response({'detail': 'Faculty only'}, status=403) + + s_id = request.data.get('student_id') + sc_id = request.data.get('scheme_id') + m = float(request.data.get('marks_obtained', 0)) + + student = models.Student.objects.get(id__user__username=s_id) + scheme = models.GradingScheme.objects.get(pk=sc_id) + ev, _ = models.StudentEvaluation.objects.get_or_create(scheme=scheme, student=student) + ev.marks_obtained = m + ev.save() + return Response({'id': ev.pk}) + +class ApiStudentGrades(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if not is_student_user: return Response({'detail': 'Student only'}, status=403) + + curr = services.get_course_obj(course_code) + student = models.Student.objects.get(id=extra_info) + schemes = models.GradingScheme.objects.filter(course_id=curr.course_id) + + res = [] + total_w = 0 + for s in schemes: + ev = models.StudentEvaluation.objects.filter(scheme=s, student=student).first() + m = ev.marks_obtained if ev else 0 + w_score = (m / s.max_marks * s.weightage) if s.max_marks > 0 else 0 + res.append({ + 'component': s.component, + 'weightage': s.weightage, + 'maxMarks': s.max_marks, + 'marksObtained': m, + 'weightedScore': w_score + }) + total_w += w_score + + return Response({ + 'grades': res, + 'totalWeightedScore': total_w + }) - return HttpResponse("Feedback uploaded") diff --git a/FusionIIIT/fix_globals_urls.py b/FusionIIIT/fix_globals_urls.py new file mode 100644 index 000000000..25c86e790 --- /dev/null +++ b/FusionIIIT/fix_globals_urls.py @@ -0,0 +1,13 @@ +import re + +path = "/mnt/c/Users/indra/Desktop/Fusion/Fusion/FusionIIIT/applications/globals/api/urls.py" +with open(path, "r") as f: + content = f.read() + +# Instead of "auth/me", let's map it to views.profile or create a simple view +# React expects auth/me to validate token and return user details. + +if "auth/me" not in content: + content = content.replace("urlpatterns = [", "urlpatterns = [\n url(r'^auth/me/', views.profile, name='me-api'),") + with open(path, "w") as f: + f.write(content) diff --git a/FusionIIIT/fix_login.py b/FusionIIIT/fix_login.py new file mode 100644 index 000000000..226363533 --- /dev/null +++ b/FusionIIIT/fix_login.py @@ -0,0 +1,11 @@ +import re + +path = "/mnt/c/Users/indra/Desktop/Fusion/Fusion-client/src/pages/login.jsx" +with open(path, "r", encoding="utf-8") as f: + text = f.read() + +text = text.replace('localStorage.getItem("authToken")', 'localStorage.getItem("access")') +text = text.replace('const { token } = response.data;\n\n localStorage.setItem("authToken", token);', 'const { access, refresh } = response.data;\n localStorage.setItem("access", access);\n localStorage.setItem("refresh", refresh);') + +with open(path, "w", encoding="utf-8") as f: + f.write(text) diff --git a/FusionIIIT/fix_notification.py b/FusionIIIT/fix_notification.py new file mode 100644 index 000000000..c67575552 --- /dev/null +++ b/FusionIIIT/fix_notification.py @@ -0,0 +1,10 @@ +import re + +path = "/mnt/c/Users/indra/Desktop/Fusion/Fusion/FusionIIIT/applications/globals/api/urls.py" +with open(path, "r") as f: + content = f.read() + +if "api/notification" not in content: + content = content.replace("urlpatterns = [", "urlpatterns = [\n url(r'^notification/$', views.NotificationRead, name='dummy_notifs'),\n url(r'^auth/me$', views.profile, name='me-api-2'),\n") + with open(path, "w") as f: + f.write(content) diff --git a/FusionIIIT/fix_notification_url.py b/FusionIIIT/fix_notification_url.py new file mode 100644 index 000000000..88763f229 --- /dev/null +++ b/FusionIIIT/fix_notification_url.py @@ -0,0 +1,11 @@ +import re + +path = "/mnt/c/Users/indra/Desktop/Fusion/Fusion/FusionIIIT/Fusion/urls.py" +with open(path, "r") as f: + content = f.read() + +if "api/notification" not in content: + content = content.replace("url(r'^api/', include('applications.globals.api.urls'))", "url(r'^api/', include('applications.globals.api.urls')),") + if "url(r'^api/notification" not in content: + # Just map it to empty to make it return 200, frontend is just looking for it to not 404 to see if backend hits + pass diff --git a/FusionIIIT/fix_notification_view.py b/FusionIIIT/fix_notification_view.py new file mode 100644 index 000000000..cb45a77fb --- /dev/null +++ b/FusionIIIT/fix_notification_view.py @@ -0,0 +1,7 @@ +path = "/mnt/c/Users/indra/Desktop/Fusion/Fusion/FusionIIIT/applications/globals/api/views.py" +with open(path, "r") as f: + content = f.read() + +if "def NotificationRead" not in content: + with open(path, "a") as f: + f.write("\n@api_view(['GET', 'POST'])\n@authentication_classes([TokenAuthentication])\n@permission_classes([IsAuthenticated])\ndef NotificationRead(request):\n return Response({'unread_count': 0, 'notifications': []}, status=200)\n") diff --git a/FusionIIIT/fix_settings.py b/FusionIIIT/fix_settings.py new file mode 100644 index 000000000..86d8e515d --- /dev/null +++ b/FusionIIIT/fix_settings.py @@ -0,0 +1,31 @@ +import os + +path = "/mnt/c/Users/indra/Desktop/Fusion/Fusion/FusionIIIT/Fusion/settings/common.py" +with open(path, "r", encoding="utf-8") as f: + text = f.read() + +# Add simplejwt to INSTALLED_APPS +if "'rest_framework_simplejwt'," not in text: + text = text.replace("'rest_framework',", "'rest_framework',\n 'rest_framework_simplejwt',") + +# Setup REST_FRAMEWORK settings +if "REST_FRAMEWORK" not in text: + text += """ + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.BasicAuthentication', + ), +} + +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), +} +""" + +with open(path, "w", encoding="utf-8") as f: + f.write(text) diff --git a/FusionIIIT/fix_urls.py b/FusionIIIT/fix_urls.py new file mode 100644 index 000000000..11447accc --- /dev/null +++ b/FusionIIIT/fix_urls.py @@ -0,0 +1,23 @@ +import re + +path = "/mnt/c/Users/indra/Desktop/Fusion/Fusion/FusionIIIT/Fusion/urls.py" +with open(path, "r", encoding="utf-8") as f: + text = f.read() + +# I am creating an api_auth view inside globals because it is simpler, or just create it in the same file. +# But wait, we should do it properly. Let's create an auth API view inside global's views or a new file `api_auth.py` in Fusion config. + +text = text.replace("from django.contrib.auth import views as auth_views", +"from django.contrib.auth import views as auth_views\nfrom . import api_auth") + +api_patterns = """ + # API AUTH + url(r'^api/auth/login/$', api_auth.TokenObtainPairView.as_view(), name='token_obtain_pair'), + url(r'^api/token/refresh/$', api_auth.TokenRefreshView.as_view(), name='token_refresh'), + url(r'^api/auth/me/$', api_auth.AuthMeView.as_view(), name='api_auth_me'), +""" + +if "api/auth/login/" not in text: + text = text.replace("urlpatterns = [", "urlpatterns = [" + api_patterns) + with open(path, "w", encoding="utf-8") as f: + f.write(text) diff --git a/FusionIIIT/reset_pass.py b/FusionIIIT/reset_pass.py new file mode 100644 index 000000000..86299054a --- /dev/null +++ b/FusionIIIT/reset_pass.py @@ -0,0 +1,16 @@ +from django.contrib.auth.models import User + +# Superuser +su = User.objects.filter(is_superuser=True).first() +if su: + su.set_password('fusion123') + su.save() + print("Superuser:", su.username) + +# Normal user +reg = User.objects.filter(is_superuser=False).first() +if reg: + reg.set_password('fusion123') + reg.save() + print("Normal user:", reg.username) + diff --git a/FusionIIIT/update_views.py b/FusionIIIT/update_views.py new file mode 100644 index 000000000..742c36b74 --- /dev/null +++ b/FusionIIIT/update_views.py @@ -0,0 +1,553 @@ +import os +import textwrap + +content = """\ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.parsers import MultiPartParser, FormParser +from django.utils import timezone +from . import services, models +from applications.academic_information.models import Course + +class BaseCourseView(APIView): + permission_classes = [IsAuthenticated] + + def check_enrollment(self, request, course_code): + return services.is_enrolled(request.user, course_code) + + def get_role_info(self, request): + extra_info = services.get_extra_info(request.user) + return extra_info, services.is_student(extra_info) + +class ApiCourseList(APIView): + permission_classes = [IsAuthenticated] + def get(self, request): + courses = services.get_courses_for_user(request.user) + return Response(courses) + +class ApiCourseDashboard(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + + curr = services.get_course_obj(course_code) + if not curr: + return Response({'detail': 'Course not found'}, status=404) + + assignments_count = models.Assignment.objects.filter(course_id=curr.course_id).count() + documents_count = models.CourseDocuments.objects.filter(course_id=curr.course_id).count() + + return Response({ + "courseCode": course_code, + "courseName": curr.course_id.course_name, + "courseDetails": curr.course_id.course_details, + "credits": curr.credits, + "semester": curr.sem, + "programme": curr.course_id.program_id.name if curr.course_id.program_id else "", + "branch": curr.course_id.branch_id.name if getattr(curr.course_id, "branch_id", None) else "", + "batch": curr.course_id.batch_id.name if getattr(curr.course_id, "batch_id", None) else "", + "counts": { + "assignments": assignments_count, + "documents": documents_count + } + }) + +class ApiAssignments(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + + curr = services.get_course_obj(course_code) + course = curr.course_id + assignments = models.Assignment.objects.filter(course_id=course) + + extra_info, is_student_user = self.get_role_info(request) + + res = [] + for a in assignments: + item = { + 'id': a.pk, + 'title': a.assignment_name, + 'description': a.assignment_description, + 'deadline': a.submit_date.isoformat(), + 'createdAt': a.submit_date.isoformat(), # approximation if no createdAt + 'submissions': [] + } + if is_student_user: + student = models.Student.objects.filter(id=extra_info).first() + subs = models.StudentAssignment.objects.filter(assignment_id=a, student_id=student) + else: + subs = models.StudentAssignment.objects.filter(assignment_id=a) + + for s in subs: + item['submissions'].append({ + 'id': s.pk, + 'assignmentId': a.pk, + 'studentName': s.student_id.id.user.get_full_name() if s.student_id else 'Unknown', + 'file': request.build_absolute_uri(s.upload_url.url) if s.upload_url else None, + 'submittedAt': getattr(s, 'submitted_at', timezone.now()).isoformat() if hasattr(s, 'submitted_at') else None, + 'marks': s.marks, + 'feedback': s.description if hasattr(s, 'description') else getattr(s, 'feedback', '') + }) + res.append(item) + return Response(res) + +class ApiAddAssignment(BaseCourseView): + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + return Response({'detail': 'Faculty only'}, status=403) + + curr = services.get_course_obj(course_code) + data = request.data + a = models.Assignment.objects.create( + course_id=curr.course_id, + assignment_name=data.get('title'), + assignment_description=data.get('description'), + submit_date=data.get('deadline') + ) + return Response({'id': a.pk}) + +class ApiUploadAssignment(BaseCourseView): + parser_classes = [MultiPartParser, FormParser] + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if not is_student_user: + return Response({'detail': 'Student only'}, status=403) + + assignment_id = request.data.get('assignment_id') + a = models.Assignment.objects.get(pk=assignment_id) + if timezone.now() > a.submit_date: + return Response({'detail': 'Submission deadline has passed'}, status=400) + + student = models.Student.objects.get(id=extra_info) + sub = models.StudentAssignment.objects.create( + student_id=student, + assignment_id=a, + upload_url=request.data.get('file') + ) + return Response({'id': sub.pk, 'submittedAt': timezone.now().isoformat()}) + +class ApiGradeAssignment(BaseCourseView): + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + return Response({'detail': 'Faculty only'}, status=403) + + sub = models.StudentAssignment.objects.get(pk=request.data.get('student_assignment_id', request.data.get('id', request.resolver_match.kwargs.get('pk')))) + sub.marks = request.data.get('marks') + sub.description = request.data.get('feedback') + sub.save() + return Response({'status': 'graded'}) + +class ApiDeleteAssignment(BaseCourseView): + def delete(self, request, course_code, pk): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + return Response({'detail': 'Faculty only'}, status=403) + + models.Assignment.objects.filter(pk=pk).delete() + return Response(status=204) + +class ApiDocuments(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + + curr = services.get_course_obj(course_code) + docs = models.CourseDocuments.objects.filter(course_id=curr.course_id) + res = [] + for d in docs: + res.append({ + 'id': d.pk, + 'title': getattr(d, 'title', getattr(d, 'document_name', '')), + 'description': d.description, + 'docFile': request.build_absolute_uri(d.document_url.url) if d.document_url else None, + 'uploadedAt': d.upload_time.isoformat() if hasattr(d, 'upload_time') else None + }) + return Response(res) + +class ApiAddDocument(BaseCourseView): + parser_classes = [MultiPartParser, FormParser] + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + return Response({'detail': 'Faculty only'}, status=403) + + curr = services.get_course_obj(course_code) + doc = models.CourseDocuments.objects.create( + course_id=curr.course_id, + description=request.data.get('description', ''), + document_url=request.data.get('doc_file') + ) + if hasattr(doc, 'title'): + doc.title = request.data.get('title', '') + if hasattr(doc, 'document_name'): + doc.document_name = request.data.get('title', '') + doc.save() + return Response({'id': doc.pk}) + +class ApiDeleteDocument(BaseCourseView): + def delete(self, request, course_code, pk): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + return Response({'detail': 'Faculty only'}, status=403) + + models.CourseDocuments.objects.filter(pk=pk).delete() + return Response(status=204) + +class ApiForum(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + curr = services.get_course_obj(course_code) + forums = models.Forum.objects.filter(course_id=curr.course_id) + res = [] + for f in forums: + replies = models.ForumReply.objects.filter(forum_reply=f) + res.append({ + 'id': f.pk, + 'question': f.question, + 'postedBy': f.commenter.user.get_full_name() if f.commenter else 'Unknown', + 'createdAt': f.comment_time.isoformat() if hasattr(f, 'comment_time') else None, + 'replies': [{ + 'id': r.pk, + 'reply': r.reply, + 'postedBy': r.replier.user.get_full_name() if r.replier else 'Unknown', + 'createdAt': getattr(r, 'reply_time', timezone.now()).isoformat() if hasattr(r, 'reply_time') else None + } for r in replies] + }) + return Response(res) + +class ApiForumNew(BaseCourseView): + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + curr = services.get_course_obj(course_code) + extra_info, _ = self.get_role_info(request) + f = models.Forum.objects.create( + course_id=curr.course_id, + commenter=extra_info, + question=request.data.get('question') + ) + return Response({'id': f.pk}) + +class ApiForumReply(BaseCourseView): + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, _ = self.get_role_info(request) + forum = models.Forum.objects.get(pk=request.data.get('forum_id')) + r = models.ForumReply.objects.create( + forum_reply=forum, + replier=extra_info, + reply=request.data.get('reply') + ) + return Response({'id': r.pk}) + +class ApiForumRemove(BaseCourseView): + def delete(self, request, course_code, pk): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + # Assuming faculty or poster can delete (enforcement simplified) + models.Forum.objects.filter(pk=pk).delete() + return Response(status=204) + +class ApiQuizzes(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + curr = services.get_course_obj(course_code) + extra_info, is_student_user = self.get_role_info(request) + + quizzes = models.Quiz.objects.filter(course_id=curr.course_id) + res = [] + now = timezone.now() + for q in quizzes: + if is_student_user: + student = models.Student.objects.get(id=extra_info) + has_finished = models.QuizResult.objects.filter(quiz_id=q, student_id=student, finished=True).exists() + if not (q.start_time <= now <= q.end_time and not has_finished): + continue + + res.append({ + 'id': q.pk, + 'title': getattr(q, 'title', getattr(q, 'quiz_name', '')), + 'description': q.description if hasattr(q, 'description') else '', + 'startTime': q.start_time.isoformat(), + 'endTime': q.end_time.isoformat(), + 'duration': getattr(q, 'duration', getattr(q, 'd_time', 0)), + 'negativeMarks': getattr(q, 'negative_marks', 0), + 'totalQuestions': q.number_of_question if hasattr(q, 'number_of_question') else 0 + }) + return Response(res) + +class ApiCreateQuiz(BaseCourseView): + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: return Response({'detail': 'Faculty only'}, status=403) + + curr = services.get_course_obj(course_code) + d = request.data + q = models.Quiz(course_id=curr.course_id) + if hasattr(q, 'quiz_name'): q.quiz_name = d.get('title') + if hasattr(q, 'title'): q.title = d.get('title') + if hasattr(q, 'description'): q.description = d.get('description', '') + q.start_time = d.get('start_time') + q.end_time = d.get('end_time') + if hasattr(q, 'd_time'): q.d_time = d.get('duration', 0) + if hasattr(q, 'duration'): q.duration = d.get('duration', 0) + if hasattr(q, 'negative_marks'): q.negative_marks = d.get('negative_marks', 0) + q.save() + return Response({'id': q.pk}) + +class ApiQuizDetail(BaseCourseView): + def get(self, request, course_code, quiz_id): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + q = models.Quiz.objects.get(pk=quiz_id) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + if timezone.now() > q.end_time: + return Response({'detail': 'Quiz has ended'}, status=403) + student = models.Student.objects.get(id=extra_info) + if models.QuizResult.objects.filter(quiz_id=q, student_id=student, finished=True).exists(): + return Response({'detail': 'You have already attempted this quiz'}, status=403) + + questions = models.QuizQuestion.objects.filter(quiz_id=q) + res_q = [] + for x in questions: + res_q.append({ + 'id': x.pk, + 'question': getattr(x, 'question', getattr(x, 'question_name', '')), + 'option1': getattr(x, 'option1', getattr(x, 'options1', '')), + 'option2': getattr(x, 'option2', getattr(x, 'options2', '')), + 'option3': getattr(x, 'option3', getattr(x, 'options3', '')), + 'option4': getattr(x, 'option4', getattr(x, 'options4', '')), + 'option5': getattr(x, 'option5', getattr(x, 'options5', '')), + 'marks': x.marks + }) + return Response({ + 'id': q.pk, + 'title': getattr(q, 'title', getattr(q, 'quiz_name', '')), + 'questions': res_q + }) + +class ApiQuizSubmit(BaseCourseView): + def post(self, request, course_code, quiz_id): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if not is_student_user: return Response({'detail': 'Student only'}, status=403) + + q = models.Quiz.objects.get(pk=quiz_id) + if timezone.now() > q.end_time: + return Response({'detail': 'Quiz has ended'}, status=403) + + student = models.Student.objects.get(id=extra_info) + answers = request.data.get('answers', []) + score = 0 + total = 0 + negative = getattr(q, 'negative_marks', 0) + + for ans in answers: + ques = models.QuizQuestion.objects.get(pk=ans['question_id']) + total += ques.marks + correct = getattr(ques, 'answer', getattr(ques, 'correct_option', '')) + models.StudentAnswer.objects.create( + student_id=student, + quiz_id=q, + question_id=ques, + choice=ans['selected_option'] + ) + if str(ans['selected_option']) == str(correct): + score += ques.marks + else: + score -= negative + + res, _ = models.QuizResult.objects.get_or_create(student_id=student, quiz_id=q) + res.score = score + res.finished = True + res.save() + + return Response({'score': score, 'totalMarks': total}) + +class ApiRemoveQuiz(BaseCourseView): + def delete(self, request, course_code, quiz_id): + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled' }, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: return Response({'detail': 'Faculty only'}, status=403) + models.Quiz.objects.filter(pk=quiz_id).delete() + return Response(status=204) + +class ApiAttendance(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + curr = services.get_course_obj(course_code) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + student = models.Student.objects.get(id=extra_info) + recs = models.Student_attendance.objects.filter(course_id=curr.course_id, student_id=student) + return Response([{'date': r.date.isoformat(), 'present': r.present} for r in recs]) + else: + recs = models.Student_attendance.objects.filter(course_id=curr.course_id) + res = {} + for r in recs: + d = r.date.isoformat() + if d not in res: res[d] = [] + res[d].append({ + 'student_id': r.student_id.id.user.username, + 'name': r.student_id.id.user.get_full_name(), + 'present': r.present + }) + return Response(res) + + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: return Response({'detail': 'Faculty only'}, status=403) + curr = services.get_course_obj(course_code) + d = request.data.get('date') + atts = request.data.get('attendance', []) + for att in atts: + student = models.Student.objects.get(id__user__username=att['student_id']) + rec, _ = models.Student_attendance.objects.get_or_create( + course_id=curr.course_id, student_id=student, date=d) + rec.present = att['present'] + rec.save() + return Response({'status': 'saved', 'count': len(atts)}) + +class ApiQuestionBank(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: return Response({'detail': 'Faculty only'}, status=403) + + curr = services.get_course_obj(course_code) + # Using a dummy implementation or empty list if no models exist for QB since it wasn't specified completely in models + # Assuming we might have QuestionBank models if not we send empty array + try: + banks = models.QuestionBank.objects.filter(course_id=curr.course_id) + return Response([{'id': b.pk, 'title': b.name} for b in banks]) + except: + return Response([]) + +class ApiCreateBank(BaseCourseView): + def post(self, request, course_code): + return Response({}) + +class ApiAddTopic(BaseCourseView): + def post(self, request, course_code, bank_id): + return Response({}) + +class ApiAddQuestion(BaseCourseView): + def post(self, request, course_code, bank_id, topic_id): + return Response({}) + +class ApiGrading(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + curr = services.get_course_obj(course_code) + extra_info, is_student_user = self.get_role_info(request) + + schemes = models.GradingScheme.objects.filter(course_id=curr.course_id) + if is_student_user: + student = models.Student.objects.get(id=extra_info) + evals = models.StudentEvaluation.objects.filter(student=student, scheme__in=schemes) + sch_res = [{'id': s.pk, 'component': s.component, 'weightage': s.weightage, 'max_marks': s.max_marks} for s in schemes] + ev_res = [{'id': e.pk, 'scheme_id': e.scheme.pk, 'marks_obtained': e.marks_obtained} for e in evals] + return Response({'schemes': sch_res, 'evaluations': ev_res}) + else: + evals = models.StudentEvaluation.objects.filter(scheme__in=schemes) + sch_res = [{'id': s.pk, 'component': s.component, 'weightage': s.weightage, 'max_marks': s.max_marks} for s in schemes] + ev_res = [{'id': e.pk, 'scheme_id': e.scheme.pk, 'student_id': e.student.id.user.username, 'student_name': e.student.id.user.get_full_name(), 'marks_obtained': e.marks_obtained} for e in evals] + return Response({'schemes': sch_res, 'evaluations': ev_res}) + +class ApiCreateGradingScheme(BaseCourseView): + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: return Response({'detail': 'Faculty only'}, status=403) + + curr = services.get_course_obj(course_code) + w = float(request.data.get('weightage', 0)) + schemes = models.GradingScheme.objects.filter(course_id=curr.course_id) + total = sum([s.weightage for s in schemes]) + if total + w > 100: + return Response({'detail': 'Total weightage cannot exceed 100'}, status=400) + + gs = models.GradingScheme.objects.create( + course_id=curr.course_id, + component=request.data.get('component'), + weightage=w, + max_marks=float(request.data.get('max_marks', 0)) + ) + return Response({'id': gs.pk}) + +class ApiEvaluate(BaseCourseView): + def post(self, request, course_code): + if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: return Response({'detail': 'Faculty only'}, status=403) + + s_id = request.data.get('student_id') + sc_id = request.data.get('scheme_id') + m = float(request.data.get('marks_obtained', 0)) + + student = models.Student.objects.get(id__user__username=s_id) + scheme = models.GradingScheme.objects.get(pk=sc_id) + ev, _ = models.StudentEvaluation.objects.get_or_create(scheme=scheme, student=student) + ev.marks_obtained = m + ev.save() + return Response({'id': ev.pk}) + +class ApiStudentGrades(BaseCourseView): + def get(self, request, course_code): + if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if not is_student_user: return Response({'detail': 'Student only'}, status=403) + + curr = services.get_course_obj(course_code) + student = models.Student.objects.get(id=extra_info) + schemes = models.GradingScheme.objects.filter(course_id=curr.course_id) + + res = [] + total_w = 0 + for s in schemes: + ev = models.StudentEvaluation.objects.filter(scheme=s, student=student).first() + m = ev.marks_obtained if ev else 0 + w_score = (m / s.max_marks * s.weightage) if s.max_marks > 0 else 0 + res.append({ + 'component': s.component, + 'weightage': s.weightage, + 'maxMarks': s.max_marks, + 'marksObtained': m, + 'weightedScore': w_score + }) + total_w += w_score + + return Response({ + 'grades': res, + 'totalWeightedScore': total_w + }) + +""" +with open("/mnt/c/Users/indra/Desktop/Fusion/Fusion/FusionIIIT/applications/online_cms/views.py", "w") as f: + f.write(content) +print("Updated views.py") From c5fd64a60f375bbdbbe2df794603ca619327f5c5 Mon Sep 17 00:00:00 2001 From: Indrapal Singh <105454098+Indrapal-70@users.noreply.github.com> Date: Wed, 25 Mar 2026 04:19:20 +0530 Subject: [PATCH 4/7] Update FusionIIIT/fix_settings.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- FusionIIIT/fix_settings.py | 35 +++++------------------------------ 1 file changed, 5 insertions(+), 30 deletions(-) diff --git a/FusionIIIT/fix_settings.py b/FusionIIIT/fix_settings.py index 86d8e515d..2bc30bfcd 100644 --- a/FusionIIIT/fix_settings.py +++ b/FusionIIIT/fix_settings.py @@ -1,31 +1,6 @@ -import os - -path = "/mnt/c/Users/indra/Desktop/Fusion/Fusion/FusionIIIT/Fusion/settings/common.py" -with open(path, "r", encoding="utf-8") as f: - text = f.read() - -# Add simplejwt to INSTALLED_APPS -if "'rest_framework_simplejwt'," not in text: - text = text.replace("'rest_framework',", "'rest_framework',\n 'rest_framework_simplejwt',") - -# Setup REST_FRAMEWORK settings -if "REST_FRAMEWORK" not in text: - text += """ - -REST_FRAMEWORK = { - 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_simplejwt.authentication.JWTAuthentication', - 'rest_framework.authentication.SessionAuthentication', - 'rest_framework.authentication.BasicAuthentication', - ), -} - -from datetime import timedelta -SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), -} """ - -with open(path, "w", encoding="utf-8") as f: - f.write(text) +This script previously mutated settings/common.py using a hard-coded absolute +local path. The required settings have been applied directly to +settings/common.py, so this helper script is intentionally left empty and +should not be used. +""" From 888b5b91d52a1186750969838f6e26328c844700 Mon Sep 17 00:00:00 2001 From: Indrapal Singh <105454098+Indrapal-70@users.noreply.github.com> Date: Wed, 25 Mar 2026 04:20:26 +0530 Subject: [PATCH 5/7] Update FusionIIIT/fix_globals_urls.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- FusionIIIT/fix_globals_urls.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/FusionIIIT/fix_globals_urls.py b/FusionIIIT/fix_globals_urls.py index 25c86e790..02c605607 100644 --- a/FusionIIIT/fix_globals_urls.py +++ b/FusionIIIT/fix_globals_urls.py @@ -1,13 +1,19 @@ -import re +""" +This module previously contained a one-off helper script that edited +``applications/globals/api/urls.py`` via a hard-coded, absolute local path. -path = "/mnt/c/Users/indra/Desktop/Fusion/Fusion/FusionIIIT/applications/globals/api/urls.py" -with open(path, "r") as f: - content = f.read() +Such scripts are environment-specific and must not be committed to the +repository because they can unexpectedly mutate project files when run +in other environments (e.g., CI or other developers' machines). -# Instead of "auth/me", let's map it to views.profile or create a simple view -# React expects auth/me to validate token and return user details. +The implementation has been intentionally removed. If you need to update +URL patterns, do so directly in the Django project or via a proper +management command or migration script. +""" -if "auth/me" not in content: - content = content.replace("urlpatterns = [", "urlpatterns = [\n url(r'^auth/me/', views.profile, name='me-api'),") - with open(path, "w") as f: - f.write(content) +if __name__ == "__main__": + raise SystemExit( + "This helper script is intentionally disabled and should not be " + "used from the repository. Update URL patterns directly in the " + "Django project instead." + ) From cfe1817342cce41a8c4ed35cc1f4b69c4416da00 Mon Sep 17 00:00:00 2001 From: Indrapal Singh <105454098+Indrapal-70@users.noreply.github.com> Date: Wed, 25 Mar 2026 04:20:57 +0530 Subject: [PATCH 6/7] Update FusionIIIT/fix_login.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- FusionIIIT/fix_login.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/FusionIIIT/fix_login.py b/FusionIIIT/fix_login.py index 226363533..c94077cd5 100644 --- a/FusionIIIT/fix_login.py +++ b/FusionIIIT/fix_login.py @@ -1,11 +1,8 @@ import re -path = "/mnt/c/Users/indra/Desktop/Fusion/Fusion-client/src/pages/login.jsx" -with open(path, "r", encoding="utf-8") as f: - text = f.read() -text = text.replace('localStorage.getItem("authToken")', 'localStorage.getItem("access")') -text = text.replace('const { token } = response.data;\n\n localStorage.setItem("authToken", token);', 'const { access, refresh } = response.data;\n localStorage.setItem("access", access);\n localStorage.setItem("refresh", refresh);') - -with open(path, "w", encoding="utf-8") as f: - f.write(text) +if __name__ == "__main__": + raise RuntimeError( + "fix_login.py is deprecated. Apply login-related changes directly in the frontend " + "Fusion-client project instead of rewriting files from this backend repository." + ) From e395fe73282f73e8ae434c1831352a0faf809faf Mon Sep 17 00:00:00 2001 From: divyTechS Date: Thu, 7 May 2026 11:02:13 +0530 Subject: [PATCH 7/7] Update backend: API auth, settings, views, models, migrations, and Docker configuration --- .dockerignore | 4 + DOCKER_SETUP_SUMMARY.md | 118 ++++ Dockerfile | 2 +- FusionIIIT/Fusion/api_auth.py | 29 + FusionIIIT/Fusion/db.sqlite3 | Bin 0 -> 4546560 bytes FusionIIIT/Fusion/settings/common.py | 29 +- FusionIIIT/Fusion/settings/development.py | 22 +- .../Fusion/settings/development_local.py | 73 ++ FusionIIIT/Fusion/settings/production.py | 22 +- FusionIIIT/Fusion/urls.py | 5 +- FusionIIIT/Fusion/wsgi.py | 2 +- .../migrations/0002_courseattendance.py | 28 + .../academic_information/models.py | 25 + .../migrations/0002_auto_20260326_1010.py | 38 + .../applications/academic_procedures/views.py | 4 +- .../applications/complaint_system/views.py | 1 - .../eis/migrations/0002_auto_20260326_1010.py | 53 ++ FusionIIIT/applications/globals/api/urls.py | 11 +- FusionIIIT/applications/globals/api/views.py | 56 +- FusionIIIT/applications/globals/apps.py | 5 + .../migrations/0002_auto_20260326_1449.py | 18 + .../migrations/0003_auto_20260326_1742.py | 18 + .../migrations/0004_auto_20260326_1752.py | 18 + .../migrations/0005_auto_20260326_1800.py | 18 + .../migrations/0006_auto_20260326_1822.py | 18 + .../migrations/0007_auto_20260326_1839.py | 18 + .../migrations/0008_auto_20260326_1847.py | 18 + .../migrations/0009_auto_20260327_1905.py | 18 + .../migrations/0010_auto_20260327_1918.py | 18 + .../migrations/0011_auto_20260327_1934.py | 18 + .../migrations/0012_auto_20260327_2210.py | 18 + .../migrations/0013_auto_20260327_2308.py | 18 + .../migrations/0014_auto_20260403_0742.py | 18 + .../migrations/0015_auto_20260403_0820.py | 18 + .../migrations/0016_auto_20260406_0044.py | 18 + .../migrations/0017_auto_20260413_1545.py | 18 + .../migrations/0018_auto_20260420_0937.py | 18 + .../migrations/0019_auto_20260420_0955.py | 18 + FusionIIIT/applications/globals/signals.py | 54 ++ FusionIIIT/applications/globals/views.py | 2 +- .../online_cms/Designated_Roles.md | 79 +++ .../online_cms/Tests/check_courses.py | 27 + .../online_cms/Tests/create_active_quiz.py | 91 +++ .../Tests/quiz_duration_examples.py | 120 ++++ .../online_cms/Tests/student_test.py | 563 +++++++++++++++ .../online_cms/Tests/teacher_test.py | 375 ++++++++++ .../online_cms/Tests/teacher_test_complete.py | 513 ++++++++++++++ .../applications/online_cms/Tests/test.py | 0 .../online_cms/Tests/test_api_fixes.py | 181 +++++ .../Tests/test_attendance_comprehensive.py | 163 +++++ .../online_cms/Tests/test_attendance_new.py | 110 +++ .../Tests/test_fetch_attendance_student.py | 26 + .../online_cms/Tests/test_login_flow.py | 73 ++ .../online_cms/Tests/test_quiz_no_window.py | 99 +++ .../online_cms/Tests/test_quiz_visibility.py | 98 +++ .../Tests/test_student_attendance.py | 90 +++ .../online_cms/{ => api}/serializers.py | 2 +- .../applications/online_cms/api/urls.py | 36 + .../online_cms/{ => api}/views.py | 647 ++++++++++++++---- .../management/commands/create_test_users.py | 262 +++++++ .../migrations/0004_auto_20260326_1652.py | 28 + .../migrations/0005_auto_20260326_1937.py | 18 + .../migrations/0006_auto_20260413_1545.py | 34 + FusionIIIT/applications/online_cms/models.py | 24 +- .../applications/online_cms/services.py | 179 ++++- FusionIIIT/applications/online_cms/urls.py | 36 - .../migrations/0002_auto_20260326_0127.py | 58 ++ .../0003_fix_courseinstructor_schema.py | 20 + .../migrations/0004_auto_20260326_1010.py | 19 + .../programme_curriculum/models.py | 2 +- .../research_procedures/api/urls.py | 3 +- .../migrations/0002_auto_20260326_1010.py | 18 + FusionIIIT/fix_extrainfo.py | 38 + FusionIIIT/fix_extrainfo_23bcs080.py | 38 + FusionIIIT/fix_settings.py | 2 +- FusionIIIT/manage.py | 2 +- FusionIIIT/update_views.py | 8 +- docker-compose.yml | 39 +- docker-entrypoint.sh | 11 +- requirements.txt | 1 + 80 files changed, 4777 insertions(+), 263 deletions(-) create mode 100644 .dockerignore create mode 100644 DOCKER_SETUP_SUMMARY.md create mode 100644 FusionIIIT/Fusion/db.sqlite3 create mode 100644 FusionIIIT/Fusion/settings/development_local.py create mode 100644 FusionIIIT/applications/academic_information/migrations/0002_courseattendance.py create mode 100644 FusionIIIT/applications/academic_procedures/migrations/0002_auto_20260326_1010.py create mode 100644 FusionIIIT/applications/eis/migrations/0002_auto_20260326_1010.py create mode 100644 FusionIIIT/applications/globals/migrations/0002_auto_20260326_1449.py create mode 100644 FusionIIIT/applications/globals/migrations/0003_auto_20260326_1742.py create mode 100644 FusionIIIT/applications/globals/migrations/0004_auto_20260326_1752.py create mode 100644 FusionIIIT/applications/globals/migrations/0005_auto_20260326_1800.py create mode 100644 FusionIIIT/applications/globals/migrations/0006_auto_20260326_1822.py create mode 100644 FusionIIIT/applications/globals/migrations/0007_auto_20260326_1839.py create mode 100644 FusionIIIT/applications/globals/migrations/0008_auto_20260326_1847.py create mode 100644 FusionIIIT/applications/globals/migrations/0009_auto_20260327_1905.py create mode 100644 FusionIIIT/applications/globals/migrations/0010_auto_20260327_1918.py create mode 100644 FusionIIIT/applications/globals/migrations/0011_auto_20260327_1934.py create mode 100644 FusionIIIT/applications/globals/migrations/0012_auto_20260327_2210.py create mode 100644 FusionIIIT/applications/globals/migrations/0013_auto_20260327_2308.py create mode 100644 FusionIIIT/applications/globals/migrations/0014_auto_20260403_0742.py create mode 100644 FusionIIIT/applications/globals/migrations/0015_auto_20260403_0820.py create mode 100644 FusionIIIT/applications/globals/migrations/0016_auto_20260406_0044.py create mode 100644 FusionIIIT/applications/globals/migrations/0017_auto_20260413_1545.py create mode 100644 FusionIIIT/applications/globals/migrations/0018_auto_20260420_0937.py create mode 100644 FusionIIIT/applications/globals/migrations/0019_auto_20260420_0955.py create mode 100644 FusionIIIT/applications/globals/signals.py create mode 100644 FusionIIIT/applications/online_cms/Designated_Roles.md create mode 100644 FusionIIIT/applications/online_cms/Tests/check_courses.py create mode 100644 FusionIIIT/applications/online_cms/Tests/create_active_quiz.py create mode 100644 FusionIIIT/applications/online_cms/Tests/quiz_duration_examples.py create mode 100755 FusionIIIT/applications/online_cms/Tests/student_test.py create mode 100644 FusionIIIT/applications/online_cms/Tests/teacher_test.py create mode 100644 FusionIIIT/applications/online_cms/Tests/teacher_test_complete.py create mode 100644 FusionIIIT/applications/online_cms/Tests/test.py create mode 100644 FusionIIIT/applications/online_cms/Tests/test_api_fixes.py create mode 100644 FusionIIIT/applications/online_cms/Tests/test_attendance_comprehensive.py create mode 100644 FusionIIIT/applications/online_cms/Tests/test_attendance_new.py create mode 100644 FusionIIIT/applications/online_cms/Tests/test_fetch_attendance_student.py create mode 100644 FusionIIIT/applications/online_cms/Tests/test_login_flow.py create mode 100644 FusionIIIT/applications/online_cms/Tests/test_quiz_no_window.py create mode 100644 FusionIIIT/applications/online_cms/Tests/test_quiz_visibility.py create mode 100644 FusionIIIT/applications/online_cms/Tests/test_student_attendance.py rename FusionIIIT/applications/online_cms/{ => api}/serializers.py (99%) create mode 100644 FusionIIIT/applications/online_cms/api/urls.py rename FusionIIIT/applications/online_cms/{ => api}/views.py (56%) create mode 100644 FusionIIIT/applications/online_cms/management/commands/create_test_users.py create mode 100644 FusionIIIT/applications/online_cms/migrations/0004_auto_20260326_1652.py create mode 100644 FusionIIIT/applications/online_cms/migrations/0005_auto_20260326_1937.py create mode 100644 FusionIIIT/applications/online_cms/migrations/0006_auto_20260413_1545.py delete mode 100644 FusionIIIT/applications/online_cms/urls.py create mode 100644 FusionIIIT/applications/programme_curriculum/migrations/0002_auto_20260326_0127.py create mode 100644 FusionIIIT/applications/programme_curriculum/migrations/0003_fix_courseinstructor_schema.py create mode 100644 FusionIIIT/applications/programme_curriculum/migrations/0004_auto_20260326_1010.py create mode 100644 FusionIIIT/applications/scholarships/migrations/0002_auto_20260326_1010.py create mode 100644 FusionIIIT/fix_extrainfo.py create mode 100644 FusionIIIT/fix_extrainfo_23bcs080.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..1de560f2c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +.git +node_modules +*.log +tmp/ diff --git a/DOCKER_SETUP_SUMMARY.md b/DOCKER_SETUP_SUMMARY.md new file mode 100644 index 000000000..5e3c65641 --- /dev/null +++ b/DOCKER_SETUP_SUMMARY.md @@ -0,0 +1,118 @@ +# Fusion Docker Setup Summary + +## Overview +Successfully configured Docker containers for both Fusion Server (Django backend) and Fusion Client (React frontend) with proper networking and environment configuration. + +## Services Running + +### 1. Database (PostgreSQL) +- **Container**: `fusion_db_1` +- **Status**: Up and healthy +- **Port**: 5432 +- **Database**: fusionlab +- **User**: fusion_admin +- **Password**: hello123 (default) + +### 2. Backend Server (Django) +- **Container**: `fusion_app_1` +- **Status**: Up and running +- **Port**: 8000 +- **URL**: http://localhost:8000 +- **Environment**: Development settings with environment variable support + +### 3. Frontend Server (React + Nginx) +- **Container**: `fusion_frontend_1` +- **Status**: Up and running +- **Port**: 3000 +- **URL**: http://localhost:3000 +- **Build**: Production build served via Nginx + +## Configuration Files Created + +### 1. Fusion-client/Dockerfile +- Multi-stage build for React application +- Uses Node.js 18 Alpine for building +- Nginx Alpine for serving production build +- Handles husky dependency issues with `--ignore-scripts` + +### 2. Fusion-client/nginx.conf +- Configures Nginx to serve React app on port 3000 +- Sets up API proxy to backend at http://app:8000 +- Handles static file caching and routing + +### 3. Updated Fusion/docker-compose.yml +- Added frontend service with proper dependencies +- Configured health checks for database +- Set up environment variables for both services +- Proper networking between containers + +### 4. Updated Fusion/docker-entrypoint.sh +- Added database connection wait logic using Python/psycopg2 +- Runs migrations before starting Django server +- Handles database readiness properly + +### 5. Updated Fusion/FusionIIIT/Fusion/settings/development.py +- Added environment variable support for database configuration +- Uses `os.environ.get()` for DB_HOST, DB_NAME, DB_USER, DB_PASSWORD + +## How to Use + +### Start All Services +```bash +cd Fusion +docker-compose up --build +``` + +### Access the Applications +- **Frontend**: http://localhost:3000 +- **Backend API**: http://localhost:8000 + +### Stop Services +```bash +cd Fusion +docker-compose down +``` + +### Clean Up (Remove Volumes) +```bash +cd Fusion +docker-compose down -v +``` + +## Notes + +1. **Database Migrations**: Some Django migrations may need to be run manually if there are schema issues +2. **Environment Variables**: Database credentials can be customized via environment variables +3. **Port Conflicts**: Ensure ports 5432, 8000, and 3000 are available on the host +4. **Local PostgreSQL**: If you have a local PostgreSQL server running, it may conflict with the container + +## Troubleshooting + +### Port Already in Use +```bash +# Stop local PostgreSQL if running +sudo systemctl stop postgresql + +# Or change ports in docker-compose.yml +``` + +### Database Connection Issues +- Check that the database container is healthy +- Verify environment variables are set correctly +- Ensure the database is ready before the app starts + +### Frontend Build Issues +- The build process ignores husky scripts to avoid dependency issues +- Production build is optimized and minified +- Nginx serves static files efficiently + +## Architecture +``` +ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” ā”Œā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā” +│ Frontend │ │ Backend │ │ Database │ +│ (Port 3000) │───▶│ (Port 8000) │───▶│ (Port 5432) │ +│ React + Nginx │ │ Django │ │ PostgreSQL │ +ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ ā””ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”€ā”˜ +``` + +The setup provides a complete development environment with proper separation of concerns and production-ready configuration. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 5462a196d..d887ddd37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ ENV PYTHONUNBUFFERED 1 COPY requirements.txt $FUSION_HOME # install dependencies -RUN pip install --upgrade pip && pip install -r requirements.txt +RUN pip install -r requirements.txt # copy api directory to docker's work directory. COPY . $FUSION_HOME diff --git a/FusionIIIT/Fusion/api_auth.py b/FusionIIIT/Fusion/api_auth.py index 163453c0c..33cc470c9 100644 --- a/FusionIIIT/Fusion/api_auth.py +++ b/FusionIIIT/Fusion/api_auth.py @@ -2,6 +2,35 @@ from rest_framework.response import Response from rest_framework.permissions import IsAuthenticated from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView +from rest_framework.authtoken.models import Token +from rest_framework import status +from django.contrib.auth import authenticate + +class CustomTokenObtainPairView(APIView): + permission_classes = [] # Allow unauthenticated access + + def post(self, request, *args, **kwargs): + username = request.data.get('username') + password = request.data.get('password') + + if not username or not password: + return Response({'error': 'Username and password are required'}, status=status.HTTP_400_BAD_REQUEST) + + user = authenticate(username=username, password=password) + if user is None: + return Response({'error': 'Invalid credentials'}, status=status.HTTP_401_UNAUTHORIZED) + + # Use Django REST framework Token authentication instead of JWT + token, created = Token.objects.get_or_create(user=user) + + return Response({ + 'token': token.key, + 'user': { + 'id': user.id, + 'username': user.username, + 'email': user.email, + } + }) class AuthMeView(APIView): permission_classes = [IsAuthenticated] diff --git a/FusionIIIT/Fusion/db.sqlite3 b/FusionIIIT/Fusion/db.sqlite3 new file mode 100644 index 0000000000000000000000000000000000000000..71c276507fd7b8c4f9e5b87628c60649ab942e06 GIT binary patch literal 4546560 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 literal 0 HcmV?d00001 diff --git a/FusionIIIT/Fusion/settings/common.py b/FusionIIIT/Fusion/settings/common.py index 5803afd32..76f85fd33 100644 --- a/FusionIIIT/Fusion/settings/common.py +++ b/FusionIIIT/Fusion/settings/common.py @@ -19,9 +19,15 @@ # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/1.11/howto/deployment/checklist/ -# SECURITY WARNING: keep the secret key used in production secret! -CLIENT_ID = '187004491411-frc3j36n04o9k0imgnbl02qg42vkq36f.apps.googleusercontent.com' -CLIENT_SECRET = 'enHu3RD0yBvCM_9C0HQmEp0z' +# SECURITY WARNING: Google OAuth credentials MUST be set via environment variables +# Never commit credentials to version control +CLIENT_ID = os.environ.get('GOOGLE_CLIENT_ID', '') +CLIENT_SECRET = os.environ.get('GOOGLE_CLIENT_SECRET', '') + +if not CLIENT_ID or not CLIENT_SECRET: + import warnings + warnings.warn('Google OAuth credentials not configured. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET environment variables.') + # SECURITY WARNING: don't run with debug turned on in production! # Google authentication @@ -246,7 +252,7 @@ USE_L10N = False -USE_TZ = False +USE_TZ = True SITE_ID = 1 # Static files (CSS, JavaScript, Images) @@ -278,7 +284,16 @@ YOUTUBE_DATA_API_KEY = 'api_key' -CORS_ORIGIN_ALLOW_ALL = True +CORS_ORIGIN_ALLOW_ALL = False +CORS_ALLOWED_ORIGINS = [ + "http://localhost:3000", + "http://127.0.0.1:3000", + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:5174", + "http://127.0.0.1:5174", + # Add your production domain here +] ALLOW_PASS_RESET = True @@ -291,7 +306,3 @@ } from datetime import timedelta -SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=60), - 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), -} diff --git a/FusionIIIT/Fusion/settings/development.py b/FusionIIIT/Fusion/settings/development.py index 32686de04..4917d96ee 100644 --- a/FusionIIIT/Fusion/settings/development.py +++ b/FusionIIIT/Fusion/settings/development.py @@ -1,25 +1,31 @@ +import os from Fusion.settings.common import * +from datetime import timedelta +import warnings DEBUG = True -SECRET_KEY = '=&w9due426k@l^ju1=s1)fj1rnpf0ok8xvjwx+62_nc-f12-8(' +# WARNING: Use strong secret key in production. Generate with: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" +if not os.environ.get('SECRET_KEY'): + warnings.warn('SECRET_KEY not set in environment. Using development key. NEVER use in production!') +SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-key-only-for-development-change-in-production') -ALLOWED_HOSTS = ['*'] +# Development: Allow localhost only. Set ALLOWED_HOSTS in production via environment +ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(',') DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql', - 'NAME': 'fusionlab', - 'USER': 'postgres', - 'PASSWORD': '2304', - 'HOST': 'localhost', - 'PORT': '5432', + 'NAME': os.environ.get('DB_NAME', 'fusionlab'), + 'USER': os.environ.get('DB_USER', 'fusion_admin'), + 'PASSWORD': os.environ.get('DB_PASSWORD', os.environ.get('POSTGRES_PASSWORD', 'postgres_default')), + 'HOST': os.environ.get('DB_HOST', 'localhost'), + 'PORT': os.environ.get('DB_PORT', '5432'), } } REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'rest_framework_simplejwt.authentication.JWTAuthentication', 'rest_framework.authentication.TokenAuthentication', 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.BasicAuthentication', diff --git a/FusionIIIT/Fusion/settings/development_local.py b/FusionIIIT/Fusion/settings/development_local.py new file mode 100644 index 000000000..7515e92f7 --- /dev/null +++ b/FusionIIIT/Fusion/settings/development_local.py @@ -0,0 +1,73 @@ +import os +from Fusion.settings.common import * +from datetime import timedelta +import warnings + +DEBUG = True + +# WARNING: Use strong secret key in production. Generate with: python -c "from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())" +if not os.environ.get('SECRET_KEY'): + warnings.warn('SECRET_KEY not set in environment. Using development key. NEVER use in production!') +SECRET_KEY = os.environ.get('SECRET_KEY', 'dev-key-only-for-development-change-in-production') + +# Development: Allow localhost only. Set ALLOWED_HOSTS in production via environment +ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1,*').split(',') + +# Use SQLite for local development to avoid PostgreSQL setup +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + 'rest_framework.authentication.BasicAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), +} + +if DEBUG: + MIDDLEWARE += ( + 'debug_toolbar.middleware.DebugToolbarMiddleware', + ) + + INSTALLED_APPS += ( + 'debug_toolbar', + 'django_extensions', + ) + + + ############################### + # DJANGO_EXTENSIONS SETTINGS: # + ############################### + INTERNAL_IPS = [ + '127.0.0.1', + ] + +############################################# +# DJANGO DEBUG TOOLBAR : +############################################# +DEBUG_TOOLBAR_PANELS = [ + 'debug_toolbar.panels.history.HistoryPanel', + 'debug_toolbar.panels.versions.VersionsPanel', + 'debug_toolbar.panels.timer.TimerPanel', + 'debug_toolbar.panels.settings.SettingsPanel', + 'debug_toolbar.panels.headers.HeadersPanel', + 'debug_toolbar.panels.request.RequestPanel', + 'debug_toolbar.panels.sql.SQLPanel', + 'debug_toolbar.panels.staticfiles.StaticFilesPanel', + 'debug_toolbar.panels.templates.TemplatesPanel', + 'debug_toolbar.panels.cache.CachePanel', + 'debug_toolbar.panels.signals.SignalsPanel', + 'debug_toolbar.panels.logging.LoggingPanel', + 'debug_toolbar.panels.redirects.RedirectsPanel', +] + +CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_EAGER_PROPAGATES = True diff --git a/FusionIIIT/Fusion/settings/production.py b/FusionIIIT/Fusion/settings/production.py index 42152ef6d..f6e718d36 100644 --- a/FusionIIIT/Fusion/settings/production.py +++ b/FusionIIIT/Fusion/settings/production.py @@ -1,10 +1,20 @@ from Fusion.settings.common import * +import os +import sys -DEBUG = True +DEBUG = False + +# Validate required environment variables for production +REQUIRED_ENV_VARS = ['SECRET_KEY', 'DB_PASSWORD', 'MAIL_PASSWORD'] +missing_vars = [var for var in REQUIRED_ENV_VARS if not os.environ.get(var)] +if missing_vars: + print(f"ERROR: Missing required environment variables: {', '.join(missing_vars)}", file=sys.stderr) + print("Please set all required environment variables before starting production server.", file=sys.stderr) + sys.exit(1) SECRET_KEY = os.environ['SECRET_KEY'] -ALLOWED_HOSTS = ['127.0.0.1', 'localhost', '172.27.16.216'] +ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '127.0.0.1,localhost').split(',') # password of sender EMAIL_HOST_PASSWORD = os.environ['MAIL_PASSWORD'] @@ -14,10 +24,10 @@ DATABASES = { 'default': { 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'NAME': 'fusionlab', - 'HOST': 'localhost', - 'USER': 'fusion_admin', - 'PASSWORD': 'hello123', + 'NAME': os.environ.get('DB_NAME', 'fusionlab'), + 'HOST': os.environ.get('DB_HOST', 'localhost'), + 'USER': os.environ.get('DB_USER', 'fusion_admin'), + 'PASSWORD': os.environ['DB_PASSWORD'], } } diff --git a/FusionIIIT/Fusion/urls.py b/FusionIIIT/Fusion/urls.py index 7d1963989..81b1294b4 100755 --- a/FusionIIIT/Fusion/urls.py +++ b/FusionIIIT/Fusion/urls.py @@ -26,7 +26,7 @@ urlpatterns = [ # API AUTH - url(r'^api/auth/login/$', api_auth.TokenObtainPairView.as_view(), name='token_obtain_pair'), + url(r'^api/auth/login/$', api_auth.CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), url(r'^api/token/refresh/$', api_auth.TokenRefreshView.as_view(), name='token_refresh'), url(r'^api/auth/me/$', api_auth.AuthMeView.as_view(), name='api_auth_me'), @@ -60,8 +60,7 @@ url(r'^gymkhana/', include('applications.gymkhana.urls')), url(r'^library/', include('applications.library.urls')), url(r'^establishment/', include('applications.establishment.urls')), - url(r'^ocms/', include(('applications.online_cms.urls', 'online_cms'), namespace='online_cms')), - url(r'^api/online_cms/', include(('applications.online_cms.urls', 'online_cms'), namespace='online_cms_api')), +url(r'^ocms/api/', include('applications.online_cms.api.urls')), url(r'^counselling/', include('applications.counselling_cell.urls')), url(r'^hostelmanagement/', include('applications.hostel_management.urls')), url(r'^income-expenditure/', include('applications.income_expenditure.urls')), diff --git a/FusionIIIT/Fusion/wsgi.py b/FusionIIIT/Fusion/wsgi.py index a7d32aa65..348f056d2 100644 --- a/FusionIIIT/Fusion/wsgi.py +++ b/FusionIIIT/Fusion/wsgi.py @@ -13,7 +13,7 @@ #from dj_static import Cling # os.environ["DJANGO_SETTINGS_MODULE"] = "Fusion.settings" -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Fusion.settings.development") +os.environ.setdefault("DJANGO_SETTINGS_MODULE", os.environ.get("DJANGO_SETTINGS_MODULE", "Fusion.settings.production")) # application = Cling(get_wsgi_application()) application = get_wsgi_application() diff --git a/FusionIIIT/applications/academic_information/migrations/0002_courseattendance.py b/FusionIIIT/applications/academic_information/migrations/0002_courseattendance.py new file mode 100644 index 000000000..039ea3b3a --- /dev/null +++ b/FusionIIIT/applications/academic_information/migrations/0002_courseattendance.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.5 on 2026-03-26 12:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CourseAttendance', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('student_id', models.CharField(max_length=100)), + ('course_code', models.CharField(max_length=20)), + ('instructor_id', models.IntegerField()), + ('date', models.DateField()), + ('present', models.BooleanField(default=False)), + ], + options={ + 'db_table': 'course_attendance', + 'unique_together': {('student_id', 'course_code', 'instructor_id', 'date')}, + }, + ), + ] diff --git a/FusionIIIT/applications/academic_information/models.py b/FusionIIIT/applications/academic_information/models.py index ea1178878..e613edb44 100755 --- a/FusionIIIT/applications/academic_information/models.py +++ b/FusionIIIT/applications/academic_information/models.py @@ -236,6 +236,31 @@ def __self__(self): return self.course_id +class CourseAttendance(models.Model): + ''' + Purpose: Store attendance for courses in the new CourseInstructor system + + ATTRIBUTES: + student_id(User) - the student username + course_code(str) - the course code + instructor_id(int) - the instructor user ID + date(DateField) - the date for which attendance is recorded + present(Boolean) - whether the student was present + ''' + student_id = models.CharField(max_length=100) # Student username + course_code = models.CharField(max_length=20) # Course code like CS101 + instructor_id = models.IntegerField() # Instructor user ID + date = models.DateField() + present = models.BooleanField(default=False) + + class Meta: + db_table = 'course_attendance' + unique_together = ('student_id', 'course_code', 'instructor_id', 'date') + + def __str__(self): + return f"{self.student_id} - {self.course_code} - {self.date}" + + class Meeting(models.Model): ''' Current Purpose : stores the information regarding a meeting which was conducted by the academic department diff --git a/FusionIIIT/applications/academic_procedures/migrations/0002_auto_20260326_1010.py b/FusionIIIT/applications/academic_procedures/migrations/0002_auto_20260326_1010.py new file mode 100644 index 000000000..3fb6af323 --- /dev/null +++ b/FusionIIIT/applications/academic_procedures/migrations/0002_auto_20260326_1010.py @@ -0,0 +1,38 @@ +# Generated by Django 3.1.5 on 2026-03-26 10:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_procedures', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='assistantshipclaim', + name='year', + field=models.IntegerField(choices=[(2026, 2026), (2025, 2025)]), + ), + migrations.AlterField( + model_name='course_registration', + name='working_year', + field=models.IntegerField(blank=True, choices=[(2026, 2026), (2025, 2025)], null=True), + ), + migrations.AlterField( + model_name='finalregistrations', + name='batch', + field=models.IntegerField(default=2026), + ), + migrations.AlterField( + model_name='messdue', + name='year', + field=models.IntegerField(choices=[(2026, 2026), (2025, 2025)]), + ), + migrations.AlterField( + model_name='register', + name='year', + field=models.IntegerField(default=2026), + ), + ] diff --git a/FusionIIIT/applications/academic_procedures/views.py b/FusionIIIT/applications/academic_procedures/views.py index 7e55e3e45..66f3e5a69 100644 --- a/FusionIIIT/applications/academic_procedures/views.py +++ b/FusionIIIT/applications/academic_procedures/views.py @@ -81,11 +81,11 @@ def academic_procedures(request): des = HoldsDesignation.objects.all().select_related().filter(user = request.user).first() - if str(des.designation) == "student": + if des and str(des.designation) == "student": obj = Student.objects.select_related('id','id__user','id__department').get(id = user_details.id) return HttpResponseRedirect('/academic-procedures/stu/') # return HttpResponseRedirect('/logout/') - elif str(des.designation) == "Associate Professor" or str(des.designation) == "Professor" or str(des.designation) == "Assistant Professor" : + elif des and (str(des.designation) == "Associate Professor" or str(des.designation) == "Professor" or str(des.designation) == "Assistant Professor"): return HttpResponseRedirect('/academic-procedures/fac/') # return HttpResponseRedirect('/logout/') diff --git a/FusionIIIT/applications/complaint_system/views.py b/FusionIIIT/applications/complaint_system/views.py index b566856aa..4dcfb9d19 100644 --- a/FusionIIIT/applications/complaint_system/views.py +++ b/FusionIIIT/applications/complaint_system/views.py @@ -854,7 +854,6 @@ def supervisor(request): {'all_caretaker': all_caretaker, 'all_complaint': all_complaint, 'overduecomplaint': overduecomplaint, 'area': area,'num':num}) else: - print('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx') y = ExtraInfo.objects.all().select_related('user','department').get(id=y.id) try: a = get_object_or_404(Supervisor, sup_id=y) diff --git a/FusionIIIT/applications/eis/migrations/0002_auto_20260326_1010.py b/FusionIIIT/applications/eis/migrations/0002_auto_20260326_1010.py new file mode 100644 index 000000000..2ca037964 --- /dev/null +++ b/FusionIIIT/applications/eis/migrations/0002_auto_20260326_1010.py @@ -0,0 +1,53 @@ +# Generated by Django 3.1.5 on 2026-03-26 10:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('eis', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='emp_achievement', + name='a_year', + field=models.IntegerField(blank=True, choices=[(1995, 1995), (1996, 1996), (1997, 1997), (1998, 1998), (1999, 1999), (2000, 2000), (2001, 2001), (2002, 2002), (2003, 2003), (2004, 2004), (2005, 2005), (2006, 2006), (2007, 2007), (2008, 2008), (2009, 2009), (2010, 2010), (2011, 2011), (2012, 2012), (2013, 2013), (2014, 2014), (2015, 2015), (2016, 2016), (2017, 2017), (2018, 2018), (2019, 2019), (2020, 2020), (2021, 2021), (2022, 2022), (2023, 2023), (2024, 2024), (2025, 2025), (2026, 2026)], null=True, verbose_name='year'), + ), + migrations.AlterField( + model_name='emp_confrence_organised', + name='k_year', + field=models.IntegerField(blank=True, choices=[(1995, 1995), (1996, 1996), (1997, 1997), (1998, 1998), (1999, 1999), (2000, 2000), (2001, 2001), (2002, 2002), (2003, 2003), (2004, 2004), (2005, 2005), (2006, 2006), (2007, 2007), (2008, 2008), (2009, 2009), (2010, 2010), (2011, 2011), (2012, 2012), (2013, 2013), (2014, 2014), (2015, 2015), (2016, 2016), (2017, 2017), (2018, 2018), (2019, 2019), (2020, 2020), (2021, 2021), (2022, 2022), (2023, 2023), (2024, 2024), (2025, 2025), (2026, 2026)], null=True, verbose_name='year'), + ), + migrations.AlterField( + model_name='emp_expert_lectures', + name='l_year', + field=models.IntegerField(blank=True, choices=[(1995, 1995), (1996, 1996), (1997, 1997), (1998, 1998), (1999, 1999), (2000, 2000), (2001, 2001), (2002, 2002), (2003, 2003), (2004, 2004), (2005, 2005), (2006, 2006), (2007, 2007), (2008, 2008), (2009, 2009), (2010, 2010), (2011, 2011), (2012, 2012), (2013, 2013), (2014, 2014), (2015, 2015), (2016, 2016), (2017, 2017), (2018, 2018), (2019, 2019), (2020, 2020), (2021, 2021), (2022, 2022), (2023, 2023), (2024, 2024), (2025, 2025), (2026, 2026)], null=True, verbose_name='year'), + ), + migrations.AlterField( + model_name='emp_keynote_address', + name='k_year', + field=models.IntegerField(blank=True, choices=[(1995, 1995), (1996, 1996), (1997, 1997), (1998, 1998), (1999, 1999), (2000, 2000), (2001, 2001), (2002, 2002), (2003, 2003), (2004, 2004), (2005, 2005), (2006, 2006), (2007, 2007), (2008, 2008), (2009, 2009), (2010, 2010), (2011, 2011), (2012, 2012), (2013, 2013), (2014, 2014), (2015, 2015), (2016, 2016), (2017, 2017), (2018, 2018), (2019, 2019), (2020, 2020), (2021, 2021), (2022, 2022), (2023, 2023), (2024, 2024), (2025, 2025), (2026, 2026)], null=True, verbose_name='year'), + ), + migrations.AlterField( + model_name='emp_mtechphd_thesis', + name='s_year', + field=models.IntegerField(blank=True, choices=[(1995, 1995), (1996, 1996), (1997, 1997), (1998, 1998), (1999, 1999), (2000, 2000), (2001, 2001), (2002, 2002), (2003, 2003), (2004, 2004), (2005, 2005), (2006, 2006), (2007, 2007), (2008, 2008), (2009, 2009), (2010, 2010), (2011, 2011), (2012, 2012), (2013, 2013), (2014, 2014), (2015, 2015), (2016, 2016), (2017, 2017), (2018, 2018), (2019, 2019), (2020, 2020), (2021, 2021), (2022, 2022), (2023, 2023), (2024, 2024), (2025, 2025), (2026, 2026)], null=True, verbose_name='year'), + ), + migrations.AlterField( + model_name='emp_patents', + name='p_year', + field=models.IntegerField(blank=True, choices=[(1995, 1995), (1996, 1996), (1997, 1997), (1998, 1998), (1999, 1999), (2000, 2000), (2001, 2001), (2002, 2002), (2003, 2003), (2004, 2004), (2005, 2005), (2006, 2006), (2007, 2007), (2008, 2008), (2009, 2009), (2010, 2010), (2011, 2011), (2012, 2012), (2013, 2013), (2014, 2014), (2015, 2015), (2016, 2016), (2017, 2017), (2018, 2018), (2019, 2019), (2020, 2020), (2021, 2021), (2022, 2022), (2023, 2023), (2024, 2024), (2025, 2025), (2026, 2026)], null=True, verbose_name='year'), + ), + migrations.AlterField( + model_name='emp_published_books', + name='pyear', + field=models.IntegerField(blank=True, choices=[(1995, 1995), (1996, 1996), (1997, 1997), (1998, 1998), (1999, 1999), (2000, 2000), (2001, 2001), (2002, 2002), (2003, 2003), (2004, 2004), (2005, 2005), (2006, 2006), (2007, 2007), (2008, 2008), (2009, 2009), (2010, 2010), (2011, 2011), (2012, 2012), (2013, 2013), (2014, 2014), (2015, 2015), (2016, 2016), (2017, 2017), (2018, 2018), (2019, 2019), (2020, 2020), (2021, 2021), (2022, 2022), (2023, 2023), (2024, 2024), (2025, 2025), (2026, 2026)], null=True, verbose_name='year'), + ), + migrations.AlterField( + model_name='emp_research_papers', + name='year', + field=models.CharField(blank=True, choices=[(1995, 1995), (1996, 1996), (1997, 1997), (1998, 1998), (1999, 1999), (2000, 2000), (2001, 2001), (2002, 2002), (2003, 2003), (2004, 2004), (2005, 2005), (2006, 2006), (2007, 2007), (2008, 2008), (2009, 2009), (2010, 2010), (2011, 2011), (2012, 2012), (2013, 2013), (2014, 2014), (2015, 2015), (2016, 2016), (2017, 2017), (2018, 2018), (2019, 2019), (2020, 2020), (2021, 2021), (2022, 2022), (2023, 2023), (2024, 2024), (2025, 2025), (2026, 2026)], max_length=10, null=True), + ), + ] diff --git a/FusionIIIT/applications/globals/api/urls.py b/FusionIIIT/applications/globals/api/urls.py index dec4867d8..eb39fe749 100644 --- a/FusionIIIT/applications/globals/api/urls.py +++ b/FusionIIIT/applications/globals/api/urls.py @@ -5,12 +5,7 @@ urlpatterns = [ url(r'^notification/$', views.NotificationRead, name='dummy_notifs'), url(r'^auth/me$', views.profile, name='me-api-2'), - - url(r'^notification/$', views.NotificationRead, name='dummy_notifs'), - url(r'^auth/me$', views.profile, name='me-api-2'), - url(r'^auth/me/', views.profile, name='me-api'), - url(r'^auth/login/', views.login, name='login-api'), url(r'^auth/logout/', views.logout, name='logout-api'), # generic profile endpoint @@ -19,8 +14,8 @@ url(r'^profile/', views.profile, name='profile-api'), url(r'^profile_update/', views.profile_update, name='update-profile-api'), url(r'^profile_delete/(?P[0-9]+)/', views.profile_delete, name='delete-profile-api'), - url(r'^dashboard/',views.dashboard,name='dashboard-api'), - url(r'^notification/read',views.NotificationRead,name='notifications-read') - + url(r'^notification/read',views.NotificationRead,name='notifications-read'), + url(r'^notificationdelete',views.notification_delete,name='notifications-delete'), + url(r'^notificationunread',views.notification_unread,name='notifications-unread') ] diff --git a/FusionIIIT/applications/globals/api/views.py b/FusionIIIT/applications/globals/api/views.py index 12d78e088..981a75501 100644 --- a/FusionIIIT/applications/globals/api/views.py +++ b/FusionIIIT/applications/globals/api/views.py @@ -8,6 +8,9 @@ Project, Publication, Skill) from django.shortcuts import get_object_or_404, redirect + + + from rest_framework.permissions import IsAuthenticated from rest_framework.authentication import TokenAuthentication from rest_framework import status @@ -238,21 +241,66 @@ def profile_delete(request, id): return Response({'message': 'Patent deleted successfully'}, status=status.HTTP_200_OK) return Response({'error': 'Wrong attribute'}, status=status.HTTP_400_BAD_REQUEST) -@api_view(['POST']) +@api_view(['GET', 'POST']) @permission_classes([IsAuthenticated]) @authentication_classes([TokenAuthentication]) def NotificationRead(request): + if request.method == 'GET': + # Handle GET request to fetch notifications + notifications = request.user.notifications.all() + serializer = serializers.NotificationSerializer(notifications, many=True) + return Response({'notifications': serializer.data}, status=status.HTTP_200_OK) + elif request.method == 'POST': + # Handle POST request to mark notification as read + try: + notifId=int(request.data['id']) + user=request.user + notification = get_object_or_404(Notification, recipient=request.user, id=notifId) + notification.mark_as_read() + response ={ + 'message':'notification successfully marked as read.' + } + return Response(response,status=status.HTTP_200_OK) + except: + response ={ + 'error':'Failed, notification is not marked as read.' + } + return Response(response,status=status.HTTP_404_NOT_FOUND) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def notification_delete(request): + try: + notifId=int(request.data['id']) + user=request.user + notification = get_object_or_404(Notification, recipient=request.user, id=notifId) + notification.delete() + response ={ + 'message':'notification successfully deleted.' + } + return Response(response,status=status.HTTP_200_OK) + except: + response ={ + 'error':'Failed, notification could not be deleted.' + } + return Response(response,status=status.HTTP_404_NOT_FOUND) + +@api_view(['POST']) +@permission_classes([IsAuthenticated]) +@authentication_classes([TokenAuthentication]) +def notification_unread(request): try: notifId=int(request.data['id']) user=request.user notification = get_object_or_404(Notification, recipient=request.user, id=notifId) - notification.mark_as_read() + notification.mark_as_unread() response ={ - 'message':'notfication successfully marked as seen.' + 'message':'notification successfully marked as unread.' } return Response(response,status=status.HTTP_200_OK) except: response ={ - 'error':'Failed, notification is not marked as seen.' + 'error':'Failed, notification could not be marked as unread.' } return Response(response,status=status.HTTP_404_NOT_FOUND) diff --git a/FusionIIIT/applications/globals/apps.py b/FusionIIIT/applications/globals/apps.py index 074b1bcec..2be9fc7a6 100644 --- a/FusionIIIT/applications/globals/apps.py +++ b/FusionIIIT/applications/globals/apps.py @@ -3,3 +3,8 @@ class GlobalsConfig(AppConfig): name = 'applications.globals' + + def ready(self): + """Import signal handlers when app is ready""" + import applications.globals.signals # noqa + diff --git a/FusionIIIT/applications/globals/migrations/0002_auto_20260326_1449.py b/FusionIIIT/applications/globals/migrations/0002_auto_20260326_1449.py new file mode 100644 index 000000000..b2b904005 --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0002_auto_20260326_1449.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-26 14:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('NEW', 'NEW'), ('PRESENT', 'PRESENT')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0003_auto_20260326_1742.py b/FusionIIIT/applications/globals/migrations/0003_auto_20260326_1742.py new file mode 100644 index 000000000..77e2ab1b3 --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0003_auto_20260326_1742.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-26 17:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0002_auto_20260326_1449'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('PRESENT', 'PRESENT'), ('NEW', 'NEW')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0004_auto_20260326_1752.py b/FusionIIIT/applications/globals/migrations/0004_auto_20260326_1752.py new file mode 100644 index 000000000..3f78ad71c --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0004_auto_20260326_1752.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-26 12:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0003_auto_20260326_1742'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('NEW', 'NEW'), ('PRESENT', 'PRESENT')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0005_auto_20260326_1800.py b/FusionIIIT/applications/globals/migrations/0005_auto_20260326_1800.py new file mode 100644 index 000000000..91d057206 --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0005_auto_20260326_1800.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-26 12:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0004_auto_20260326_1752'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('PRESENT', 'PRESENT'), ('NEW', 'NEW')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0006_auto_20260326_1822.py b/FusionIIIT/applications/globals/migrations/0006_auto_20260326_1822.py new file mode 100644 index 000000000..377ecb677 --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0006_auto_20260326_1822.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-26 12:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0005_auto_20260326_1800'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('NEW', 'NEW'), ('PRESENT', 'PRESENT')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0007_auto_20260326_1839.py b/FusionIIIT/applications/globals/migrations/0007_auto_20260326_1839.py new file mode 100644 index 000000000..b83294f82 --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0007_auto_20260326_1839.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-26 13:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0006_auto_20260326_1822'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('PRESENT', 'PRESENT'), ('NEW', 'NEW')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0008_auto_20260326_1847.py b/FusionIIIT/applications/globals/migrations/0008_auto_20260326_1847.py new file mode 100644 index 000000000..4617a4764 --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0008_auto_20260326_1847.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-26 13:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0007_auto_20260326_1839'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('NEW', 'NEW'), ('PRESENT', 'PRESENT')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0009_auto_20260327_1905.py b/FusionIIIT/applications/globals/migrations/0009_auto_20260327_1905.py new file mode 100644 index 000000000..2e9a82fee --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0009_auto_20260327_1905.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-27 13:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0008_auto_20260326_1847'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('PRESENT', 'PRESENT'), ('NEW', 'NEW')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0010_auto_20260327_1918.py b/FusionIIIT/applications/globals/migrations/0010_auto_20260327_1918.py new file mode 100644 index 000000000..f3502563a --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0010_auto_20260327_1918.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-27 13:48 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0009_auto_20260327_1905'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('NEW', 'NEW'), ('PRESENT', 'PRESENT')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0011_auto_20260327_1934.py b/FusionIIIT/applications/globals/migrations/0011_auto_20260327_1934.py new file mode 100644 index 000000000..ab6e16cd1 --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0011_auto_20260327_1934.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-27 14:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0010_auto_20260327_1918'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('PRESENT', 'PRESENT'), ('NEW', 'NEW')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0012_auto_20260327_2210.py b/FusionIIIT/applications/globals/migrations/0012_auto_20260327_2210.py new file mode 100644 index 000000000..bb68513bc --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0012_auto_20260327_2210.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-27 16:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0011_auto_20260327_1934'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('NEW', 'NEW'), ('PRESENT', 'PRESENT')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0013_auto_20260327_2308.py b/FusionIIIT/applications/globals/migrations/0013_auto_20260327_2308.py new file mode 100644 index 000000000..ee5aa0067 --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0013_auto_20260327_2308.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-27 17:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0012_auto_20260327_2210'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('PRESENT', 'PRESENT'), ('NEW', 'NEW')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0014_auto_20260403_0742.py b/FusionIIIT/applications/globals/migrations/0014_auto_20260403_0742.py new file mode 100644 index 000000000..4ef4ccbfb --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0014_auto_20260403_0742.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-04-03 02:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0013_auto_20260327_2308'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('NEW', 'NEW'), ('PRESENT', 'PRESENT')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0015_auto_20260403_0820.py b/FusionIIIT/applications/globals/migrations/0015_auto_20260403_0820.py new file mode 100644 index 000000000..1dca1323e --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0015_auto_20260403_0820.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-04-03 02:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0014_auto_20260403_0742'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('PRESENT', 'PRESENT'), ('NEW', 'NEW')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0016_auto_20260406_0044.py b/FusionIIIT/applications/globals/migrations/0016_auto_20260406_0044.py new file mode 100644 index 000000000..a625e9da4 --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0016_auto_20260406_0044.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-04-05 19:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0015_auto_20260403_0820'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('NEW', 'NEW'), ('PRESENT', 'PRESENT')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0017_auto_20260413_1545.py b/FusionIIIT/applications/globals/migrations/0017_auto_20260413_1545.py new file mode 100644 index 000000000..f2a716a9a --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0017_auto_20260413_1545.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-04-13 10:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0016_auto_20260406_0044'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('PRESENT', 'PRESENT'), ('NEW', 'NEW')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0018_auto_20260420_0937.py b/FusionIIIT/applications/globals/migrations/0018_auto_20260420_0937.py new file mode 100644 index 000000000..55e6d1dfc --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0018_auto_20260420_0937.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-04-20 04:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0017_auto_20260413_1545'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('NEW', 'NEW'), ('PRESENT', 'PRESENT')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/migrations/0019_auto_20260420_0955.py b/FusionIIIT/applications/globals/migrations/0019_auto_20260420_0955.py new file mode 100644 index 000000000..d66521ece --- /dev/null +++ b/FusionIIIT/applications/globals/migrations/0019_auto_20260420_0955.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-04-20 04:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('globals', '0018_auto_20260420_0937'), + ] + + operations = [ + migrations.AlterField( + model_name='extrainfo', + name='user_status', + field=models.CharField(choices=[('PRESENT', 'PRESENT'), ('NEW', 'NEW')], default='PRESENT', max_length=50), + ), + ] diff --git a/FusionIIIT/applications/globals/signals.py b/FusionIIIT/applications/globals/signals.py new file mode 100644 index 000000000..96f925140 --- /dev/null +++ b/FusionIIIT/applications/globals/signals.py @@ -0,0 +1,54 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.contrib.auth.models import User +from applications.globals.models import ExtraInfo +import logging + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=User) +def create_user_extrainfo(sender, instance, created, **kwargs): + """ + Signal handler that creates an ExtraInfo record when a new User is created. + This ensures every user has the required ExtraInfo profile. + """ + if created: + try: + # Check if ExtraInfo already exists + if not hasattr(instance, 'extrainfo'): + ExtraInfo.objects.get_or_create( + id=instance.username, + defaults={ + 'user': instance, + 'title': 'Mr.', + 'sex': 'M', + 'user_type': 'Student', # Default type + 'user_status': 'PRESENT', + } + ) + logger.info(f"Created ExtraInfo for user: {instance.username}") + except Exception as e: + logger.error(f"Error creating ExtraInfo for user {instance.username}: {e}") + + +@receiver(post_save, sender=User) +def save_user_extrainfo(sender, instance, **kwargs): + """ + Signal handler that ensures ExtraInfo exists for saved users. + """ + if not kwargs.get('created'): # Only for updates, not creation + try: + if not hasattr(instance, 'extrainfo'): + ExtraInfo.objects.get_or_create( + id=instance.username, + defaults={ + 'user': instance, + 'title': 'Mr.', + 'sex': 'M', + 'user_type': 'Student', + 'user_status': 'PRESENT', + } + ) + except Exception as e: + logger.error(f"Error ensuring ExtraInfo for user {instance.username}: {e}") diff --git a/FusionIIIT/applications/globals/views.py b/FusionIIIT/applications/globals/views.py index a7f3886c9..6947a98f2 100644 --- a/FusionIIIT/applications/globals/views.py +++ b/FusionIIIT/applications/globals/views.py @@ -744,7 +744,7 @@ def dashboard(request): return render(request, "dashboard/director_dashboard2.html", {}) elif( "dean_rspc" in designation): return render(request, "dashboard/dashboard.html", context) - elif user.extrainfo.user_type != 'student': + elif hasattr(user, 'extrainfo') and user.extrainfo.user_type != 'student': designat = HoldsDesignation.objects.select_related().filter(user=user) response = {'designat':designat} context.update(response) diff --git a/FusionIIIT/applications/online_cms/Designated_Roles.md b/FusionIIIT/applications/online_cms/Designated_Roles.md new file mode 100644 index 000000000..5de0ae840 --- /dev/null +++ b/FusionIIIT/applications/online_cms/Designated_Roles.md @@ -0,0 +1,79 @@ +# Module Name: Online CMS + +## Designated User Roles & Permissions + +### 1. Role Name: Professor + +* **Description:** Senior faculty member responsible for course creation and management. + +* **Permissions:** + + * Full CRUD operations on courses, assignments, quizzes, and grades. + + * Ability to assign teaching assistants and manage course staff. + + * Access to advanced analytics and reporting. + +### 2. Role Name: Associate Professor + +* **Description:** Mid-level faculty member with teaching and administrative responsibilities. + +* **Permissions:** + + * Create and edit course content, assignments, and assessments. + + * Grade student submissions and provide feedback. + + * View course analytics and student performance data. + +### 3. Role Name: Assistant Professor + +* **Description:** Junior faculty member focused on teaching and student interaction. + +* **Permissions:** + + * Manage assigned courses and create learning materials. + + * Upload assignments, conduct quizzes, and evaluate student work. + + * Participate in forum moderation and student communication. + +### 4. Role Name: HOD (CSE) + +* **Description:** Department head overseeing computer science courses. + +* **Permissions:** + + * All faculty permissions plus department-level oversight. + + * Approve course changes and curriculum updates. + + * Access to department-wide student data and reports. + +### 5. Role Name: Student + +* **Description:** Learners enrolled in courses. + +* **Permissions:** + + * Access to enrolled course materials, assignments, and quizzes. + + * Submit assignments and participate in assessments. + + * View personal grades and feedback. + +### 6. Role Name: Registrar + +* **Description:** Administrative staff managing academic records. + +* **Permissions:** + + * View course enrollments and student records. + + * Generate reports on course performance. + + * Assist with course administration tasks. + +--- + +*Note: Roles are based on designations from the `globals_designation` table in the database.* diff --git a/FusionIIIT/applications/online_cms/Tests/check_courses.py b/FusionIIIT/applications/online_cms/Tests/check_courses.py new file mode 100644 index 000000000..0b01af929 --- /dev/null +++ b/FusionIIIT/applications/online_cms/Tests/check_courses.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Check course setup""" +import requests +import json + +BASE_URL = "http://localhost:8000" + +def login(username, password): + response = requests.post( + f"{BASE_URL}/api/auth/login/", + data={"username": username, "password": password} + ) + if response.status_code == 200: + data = response.json() + return data.get("token") or data.get("access") + return None + +print("Getting available courses for teacher...") +teacher_token = login("testteacher", "testteacher123") +if teacher_token: + headers = {"Authorization": f"Token {teacher_token}"} + response = requests.get( + f"{BASE_URL}/ocms/api/courses/", + headers=headers + ) + print(f"Status: {response.status_code}") + print(f"Courses: {json.dumps(response.json(), indent=2)}") diff --git a/FusionIIIT/applications/online_cms/Tests/create_active_quiz.py b/FusionIIIT/applications/online_cms/Tests/create_active_quiz.py new file mode 100644 index 000000000..463b24486 --- /dev/null +++ b/FusionIIIT/applications/online_cms/Tests/create_active_quiz.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Create an active quiz for testing""" +import requests +import json +from datetime import datetime, timedelta + +BASE_URL = "http://localhost:8000" + +def login(username, password): + response = requests.post( + f"{BASE_URL}/api/auth/login/", + data={"username": username, "password": password} + ) + if response.status_code == 200: + data = response.json() + return data.get("token") or data.get("access") + return None + +print("="*70) +print("CREATING ACTIVE QUIZ FOR STUDENT VISIBILITY") +print("="*70) + +# Login as teacher +teacher_token = login("testteacher", "testteacher123") +if not teacher_token: + print("āœ— Failed to login as teacher") + exit(1) + +print(f"\nāœ“ Teacher logged in") + +# Create a quiz with active time window +now = datetime.utcnow() +start_time = now - timedelta(minutes=5) # Started 5 minutes ago +end_time = now + timedelta(hours=2) # Ends in 2 hours + +print(f"\nCurrent UTC time: {now.isoformat()}") +print(f"Quiz start time: {start_time.isoformat()}") +print(f"Quiz end time: {end_time.isoformat()}") + +quiz_data = { + "title": "Active Quiz for Testing", + "description": "This quiz is currently active and visible to students", + "start_time": start_time.isoformat() + "Z", + "end_time": end_time.isoformat() + "Z", + "duration": 30, + "negative_marks": 0, + "total_questions": 3 +} + +print(f"\n" + "-"*70) +print("Creating quiz...") +print("-"*70) + +headers = {"Authorization": f"Token {teacher_token}"} +response = requests.post( + f"{BASE_URL}/ocms/api/CS101/quizzes/create/", + headers=headers, + json=quiz_data +) + +print(f"Status: {response.status_code}") +print(f"Response: {json.dumps(response.json(), indent=2)}") + +if response.status_code in [200, 201]: + print("\nāœ… Quiz created successfully!") + + # Test if student can now see it + print(f"\n" + "-"*70) + print("Testing student visibility...") + print("-"*70) + + student_token = login("student01", "Control d") + headers = {"Authorization": f"Token {student_token}"} + response = requests.get( + f"{BASE_URL}/ocms/api/CS101/quizzes/", + headers=headers + ) + + if response.status_code == 200: + quizzes = response.json() + print(f"Student can see {len(quizzes)} quiz(zes)") + for q in quizzes: + if "Active" in q['title']: + print(f"\nāœ… SUCCESS! Student can see the active quiz:") + print(f" Title: {q['title']}") + print(f" Start: {q['startTime']}") + print(f" End: {q['endTime']}") +else: + print(f"\nāœ— Failed to create quiz: {response.text}") + +print("\n" + "="*70) diff --git a/FusionIIIT/applications/online_cms/Tests/quiz_duration_examples.py b/FusionIIIT/applications/online_cms/Tests/quiz_duration_examples.py new file mode 100644 index 000000000..79ea82e26 --- /dev/null +++ b/FusionIIIT/applications/online_cms/Tests/quiz_duration_examples.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +""" +Visual explanation of quiz start/end time calculation +""" + +def calculate_quiz_duration(start_time_str, end_time_str): + """Calculate quiz duration from ISO datetime strings""" + from datetime import datetime + + # Parse ISO formatted datetimes + start = datetime.fromisoformat(start_time_str.replace('Z', '+00:00')) + end = datetime.fromisoformat(end_time_str.replace('Z', '+00:00')) + + # Calculate difference + delta = end - start + total_minutes = int(delta.total_seconds() // 60) + + # Breakdown + days = total_minutes // (60 * 24) + hours = (total_minutes % (60 * 24)) // 60 + minutes = total_minutes % 60 + + return { + 'start': start, + 'end': end, + 'total_minutes': total_minutes, + 'days': days, + 'hours': hours, + 'minutes': minutes, + 'total_seconds': int(delta.total_seconds()) + } + +# Example 1: Short quiz (same day) +print("="*70) +print("EXAMPLE 1: Short Quiz (Same Day)") +print("="*70) +result1 = calculate_quiz_duration( + "2026-03-26T10:00:00Z", + "2026-03-26T11:30:00Z" +) +print(f"Start: {result1['start']}") +print(f"End: {result1['end']}") +print(f"\nDuration:") +print(f" Days: {result1['days']:02d}") +print(f" Hours: {result1['hours']:02d}") +print(f" Minutes: {result1['minutes']:02d}") +print(f"\nTotal: {result1['total_minutes']} minutes ({result1['total_seconds']} seconds)") + +# Example 2: Multi-day quiz (what we used earlier) +print("\n" + "="*70) +print("EXAMPLE 2: Multi-Day Quiz") +print("="*70) +result2 = calculate_quiz_duration( + "2026-03-26T10:00:00Z", + "2026-03-31T14:30:00Z" +) +print(f"Start: {result2['start']}") +print(f"End: {result2['end']}") +print(f"\nDuration:") +print(f" Days: {result2['days']:02d}") +print(f" Hours: {result2['hours']:02d}") +print(f" Minutes: {result2['minutes']:02d}") +print(f"\nTotal: {result2['total_minutes']} minutes ({result2['total_seconds']} seconds)") + +# Example 3: Exactly 1 day +print("\n" + "="*70) +print("EXAMPLE 3: Exactly 1 Day") +print("="*70) +result3 = calculate_quiz_duration( + "2026-03-26T10:00:00Z", + "2026-03-27T10:00:00Z" +) +print(f"Start: {result3['start']}") +print(f"End: {result3['end']}") +print(f"\nDuration:") +print(f" Days: {result3['days']:02d}") +print(f" Hours: {result3['hours']:02d}") +print(f" Minutes: {result3['minutes']:02d}") +print(f"\nTotal: {result3['total_minutes']} minutes ({result3['total_seconds']} seconds)") + +# Example 4: What happens in database +print("\n" + "="*70) +print("WHAT GETS STORED IN DATABASE") +print("="*70) +print("\nFor Example 2 (Multi-Day Quiz):") +print(f" d_day = '{result2['days']:02d}'") +print(f" d_hour = '{result2['hours']:02d}'") +print(f" d_minute = '{result2['minutes']:02d}'") +print(f" start_time = '{result2['start'].isoformat()}'") +print(f" end_time = '{result2['end'].isoformat()}'") + +# Visual timeline +print("\n" + "="*70) +print("VISUAL TIMELINE") +print("="*70) +print(""" +Quiz Duration Timeline: + + 2026-03-26 10:00 UTC + │ + ā”œā”€ā”€ā”€ Day 1 (24 hours) + │ + ā”œā”€ā”€ā”€ Day 2 (24 hours) + │ + ā”œā”€ā”€ā”€ Day 3 (24 hours) + │ + ā”œā”€ā”€ā”€ Day 4 (24 hours) + │ + ā”œā”€ā”€ā”€ Day 5 (24 hours) + │ + └─── Plus 4 hours 30 minutes + │ + 2026-03-31 14:30 UTC + +Total Duration: 5 days + 4 hours + 30 minutes + = 129 hours 30 minutes + = 7770 minutes +""") + +print("="*70) diff --git a/FusionIIIT/applications/online_cms/Tests/student_test.py b/FusionIIIT/applications/online_cms/Tests/student_test.py new file mode 100755 index 000000000..8b1c1bfee --- /dev/null +++ b/FusionIIIT/applications/online_cms/Tests/student_test.py @@ -0,0 +1,563 @@ +#!/usr/bin/env python3 +""" +Test script for student functionalities in Fusion Online CMS +Tests: +1. Student login +2. Get enrolled courses +3. View course dashboard +4. View course materials/documents +5. View assignments +6. Submit assignments +7. View quiz +8. Participate in forum +9. View attendance +""" + +import requests +import json +from datetime import datetime + +# Configuration +BASE_URL = "http://localhost:8000" +API_BASE = f"{BASE_URL}/api" + +# Test credentials +STUDENT_USERNAME = "student01" +STUDENT_PASSWORD = "Control d" + +# Global token for authenticated requests +auth_token = None + +class Colors: + HEADER = '\033[95m' + BLUE = '\033[94m' + CYAN = '\033[96m' + GREEN = '\033[92m' + YELLOW = '\033[93m' + RED = '\033[91m' + END = '\033[0m' + BOLD = '\033[1m' + UNDERLINE = '\033[4m' + +def print_section(title): + print(f"\n{Colors.HEADER}{Colors.BOLD}{'='*60}") + print(f" {title}") + print(f"{'='*60}{Colors.END}\n") + +def print_success(message): + print(f"{Colors.GREEN}āœ“ {message}{Colors.END}") + +def print_error(message): + print(f"{Colors.RED}āœ— {message}{Colors.END}") + +def print_info(message): + print(f"{Colors.CYAN}ℹ {message}{Colors.END}") + +def print_warning(message): + print(f"{Colors.YELLOW}⚠ {message}{Colors.END}") + +def print_json(data, title=""): + if title: + print(f"{Colors.BOLD}{title}:{Colors.END}") + print(json.dumps(data, indent=2)) + +def login_student(): + """Login as student and get authentication token""" + print_section("1. STUDENT LOGIN") + + url = f"{API_BASE}/auth/login/" + credentials = { + "username": STUDENT_USERNAME, + "password": STUDENT_PASSWORD + } + + print_info(f"Logging in as {STUDENT_USERNAME}...") + print_info(f"Endpoint: POST {url}") + + try: + response = requests.post(url, json=credentials) + + if response.status_code == 200: + data = response.json() + global auth_token + auth_token = data.get('token') + print_success(f"Login successful!") + print_info(f"Authentication Token: {auth_token[:20]}...") + return True + else: + print_error(f"Login failed: {response.status_code}") + print_json(response.json()) + return False + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return False + +def get_student_courses(): + """Get list of courses enrolled by the student""" + print_section("2. GET ENROLLED COURSES") + + url = f"http://localhost:8000/ocms/api/courses/" + headers = {"Authorization": f"Token {auth_token}"} + + print_info(f"Fetching enrolled courses...") + print_info(f"Endpoint: GET {url}") + + try: + response = requests.get(url, headers=headers) + + if response.status_code == 200: + courses = response.json() + if courses: + print_success(f"Found {len(courses)} course(s)") + for i, course in enumerate(courses, 1): + print(f"\n {Colors.BOLD}Course {i}:{Colors.END}") + print(f" Course Code: {course.get('courseCode')}") + print(f" Course Name: {course.get('courseName')}") + print(f" Credits: {course.get('credits')}") + print(f" Semester: {course.get('semester')}") + print(f" Programme: {course.get('programme', 'N/A')}") + return courses + else: + print_warning("No courses enrolled") + return [] + else: + print_error(f"Failed to fetch courses: {response.status_code}") + print_json(response.json()) + return [] + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return [] + +def get_course_dashboard(course_code): + """Get course dashboard information""" + print_section(f"3. GET COURSE DASHBOARD - {course_code}") + + url = f"http://localhost:8000/ocms/api/{course_code}/dashboard/" + headers = {"Authorization": f"Token {auth_token}"} + + print_info(f"Fetching dashboard for {course_code}...") + print_info(f"Endpoint: GET {url}") + + try: + response = requests.get(url, headers=headers) + + if response.status_code == 200: + dashboard = response.json() + print_success(f"Dashboard retrieved successfully") + print(f"\n{Colors.BOLD}Course Information:{Colors.END}") + print(f" Course Code: {dashboard.get('courseCode')}") + print(f" Course Name: {dashboard.get('courseName')}") + print(f" Course Details: {dashboard.get('courseDetails', 'N/A')}") + print(f" Credits: {dashboard.get('credits')}") + print(f" Semester: {dashboard.get('semester')}") + print(f" Programme: {dashboard.get('programme', 'N/A')}") + + counts = dashboard.get('counts', {}) + print(f"\n{Colors.BOLD}Content Available:{Colors.END}") + print(f" Documents: {counts.get('documents', 0)}") + print(f" Assignments: {counts.get('assignments', 0)}") + print(f" Forum Posts: {counts.get('forum', 0)}") + print(f" Attendance Records: {counts.get('attendance', 0)}") + + return dashboard + else: + print_error(f"Failed to fetch dashboard: {response.status_code}") + print_json(response.json()) + return None + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return None + +def get_course_documents(course_code): + """Get course materials/documents""" + print_section(f"4. GET COURSE MATERIALS - {course_code}") + + url = f"http://localhost:8000/ocms/api/{course_code}/documents/" + headers = {"Authorization": f"Token {auth_token}"} + + print_info(f"Fetching documents/materials for {course_code}...") + print_info(f"Endpoint: GET {url}") + + try: + response = requests.get(url, headers=headers) + + if response.status_code == 200: + documents = response.json() + if documents: + print_success(f"Found {len(documents)} document(s)/material(s)") + for i, doc in enumerate(documents, 1): + print(f"\n {Colors.BOLD}Material {i}:{Colors.END}") + print(f" ID: {doc.get('id')}") + print(f" Title: {doc.get('document_name') or doc.get('title')}") + print(f" Description: {doc.get('description')}") + print(f" URL: {doc.get('document_url') or doc.get('url')}") + if 'upload_time' in doc: + print(f" Uploaded: {doc.get('upload_time')}") + else: + print_warning("No materials available for this course") + return documents + else: + print_error(f"Failed to fetch documents: {response.status_code}") + print_json(response.json()) + return [] + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return [] + +def get_assignments(course_code): + """Get assignments for the course""" + print_section(f"5. GET ASSIGNMENTS - {course_code}") + + url = f"http://localhost:8000/ocms/api/{course_code}/assignments/" + headers = {"Authorization": f"Token {auth_token}"} + + print_info(f"Fetching assignments for {course_code}...") + print_info(f"Endpoint: GET {url}") + + try: + response = requests.get(url, headers=headers) + + if response.status_code == 200: + assignments = response.json() + if assignments: + print_success(f"Found {len(assignments)} assignment(s)") + for i, assignment in enumerate(assignments, 1): + print(f"\n {Colors.BOLD}Assignment {i}:{Colors.END}") + print(f" ID: {assignment.get('id')}") + print(f" Name: {assignment.get('assignment_name')}") + print(f" URL: {assignment.get('assignment_url')}") + print(f" Submit Date: {assignment.get('submit_date')}") + print(f" Upload Time: {assignment.get('upload_time')}") + else: + print_warning("No assignments available for this course") + return assignments + else: + print_error(f"Failed to fetch assignments: {response.status_code}") + print_json(response.json()) + return [] + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return [] + +def submit_assignment(course_code, assignment_id=None, file_path=None): + """Submit an assignment""" + print_section(f"6. SUBMIT ASSIGNMENT - {course_code}") + + url = f"http://localhost:8000/ocms/api/{course_code}/assignments/upload/" + headers = {"Authorization": f"Token {auth_token}"} + + if assignment_id is None: + print_warning("No assignment_id provided; skipping assignment submission.") + return None + + print_info(f"Submitting assignment for {course_code}...") + print_info(f"Endpoint: POST {url}") + + submission_data = { + "assignment_id": assignment_id, + "submission_link": "https://example.com/student_submission.pdf" + } + + try: + response = requests.post(url, json=submission_data, headers=headers) + + if response.status_code in [200, 201]: + result = response.json() + print_success(f"Assignment submitted successfully!") + print_info(f"Submission ID: {result.get('id')}") + return result + else: + print_warning(f"Assignment submission status: {response.status_code}") + print(f" Response: {response.json()}") + return None + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return None + +def get_forum_posts(course_code): + """Get forum discussions for the course""" + print_section(f"7. GET FORUM POSTS - {course_code}") + + url = f"http://localhost:8000/ocms/api/{course_code}/forum/" + headers = {"Authorization": f"Token {auth_token}"} + + print_info(f"Fetching forum discussions for {course_code}...") + print_info(f"Endpoint: GET {url}") + + try: + response = requests.get(url, headers=headers) + + if response.status_code == 200: + forum_posts = response.json() + if forum_posts: + print_success(f"Found {len(forum_posts)} forum post(s)/thread(s)") + for i, post in enumerate(forum_posts, 1): + print(f"\n {Colors.BOLD}Post {i}:{Colors.END}") + print(f" ID: {post.get('id')}") + print(f" Question: {post.get('message')}") + print(f" Posted by: {post.get('postedBy')}") + print(f" Comment Time: {post.get('createdAt')}") + else: + print_warning("No forum discussions available for this course") + return forum_posts + else: + print_error(f"Failed to fetch forum posts: {response.status_code}") + print_json(response.json()) + return [] + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return [] + +def post_forum_question(course_code): + """Post a question in the forum""" + print_section(f"8. POST FORUM QUESTION - {course_code}") + + url = f"http://localhost:8000/ocms/api/{course_code}/forum/new/" + headers = {"Authorization": f"Token {auth_token}"} + + question_text = f"Test question from student - {datetime.now().strftime('%H:%M:%S')}" + payload = { + "message": question_text + } + + print_info(f"Posting question to forum for {course_code}...") + print_info(f"Endpoint: POST {url}") + print(f" Question: {question_text}") + + try: + response = requests.post(url, json=payload, headers=headers) + + if response.status_code in [200, 201]: + result = response.json() + print_success(f"Forum question posted successfully!") + print_info(f"Post ID: {result.get('id')}") + return result + else: + print_warning(f"Forum post status: {response.status_code}") + try: + print(f" Response: {response.json()}") + except: + print(f" Response: {response.text}") + return None + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return None + +def get_attendance(course_code): + """Get attendance records for the course""" + print_section(f"9. GET ATTENDANCE - {course_code}") + + url = f"http://localhost:8000/ocms/api/{course_code}/attendance/" + headers = {"Authorization": f"Token {auth_token}"} + + print_info(f"Fetching attendance for {course_code}...") + print_info(f"Endpoint: GET {url}") + + try: + response = requests.get(url, headers=headers) + + if response.status_code == 200: + attendance = response.json() + if attendance: + if isinstance(attendance, dict): + print_success(f"Found attendance records") + total_present = 0 + total_classes = 0 + for date, records in attendance.items(): + print(f"\n {Colors.BOLD}{date}:{Colors.END} {len(records)} record(s)") + for record in records: + if isinstance(record, dict): + present = record.get('present') + if present: + total_present += 1 + total_classes += 1 + print(f" student_id: {record.get('student_id')}") + print(f" present: {present}") + attendance_percentage = (total_present / total_classes * 100) if total_classes else 0 + print(f"\n{Colors.BOLD}Attendance Summary:{Colors.END}") + print(f" Total Classes: {total_classes}") + print(f" Present: {total_present}") + print(f" Absent: {total_classes - total_present}") + print(f" Attendance %: {attendance_percentage:.2f}%") + elif isinstance(attendance, list): + print_success(f"Found {len(attendance)} attendance record(s)") + total_present = 0 + for i, record in enumerate(attendance, 1): + print(f"\n {Colors.BOLD}Record {i}:{Colors.END}") + print(f" ID: {record.get('id')}") + print(f" Date: {record.get('date')}") + print(f" Present: {record.get('present')}") + print(f" Number of Attendance: {record.get('no_of_attendance')}") + if record.get('present'): + total_present += 1 + attendance_percentage = (total_present / len(attendance) * 100) if attendance else 0 + print(f"\n{Colors.BOLD}Attendance Summary:{Colors.END}") + print(f" Total Classes: {len(attendance)}") + print(f" Present: {total_present}") + print(f" Absent: {len(attendance) - total_present}") + print(f" Attendance %: {attendance_percentage:.2f}%") + else: + print_json(attendance, title="Attendance Response") + else: + print_warning("No attendance records available") + return attendance + else: + print_error(f"Failed to fetch attendance: {response.status_code}") + print_json(response.json()) + return [] + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return [] + +def get_quizzes(course_code): + """Get available quizzes for the course""" + print_section(f"10. GET QUIZZES - {course_code}") + + url = f"http://localhost:8000/ocms/api/{course_code}/quizzes/" + headers = {"Authorization": f"Token {auth_token}"} + + print_info(f"Fetching quizzes for {course_code}...") + print_info(f"Endpoint: GET {url}") + + try: + response = requests.get(url, headers=headers) + + if response.status_code == 200: + quizzes = response.json() + if quizzes: + print_success(f"Found {len(quizzes)} quiz(zes)") + for i, quiz in enumerate(quizzes, 1): + print(f"\n {Colors.BOLD}Quiz {i}:{Colors.END}") + print(f" ID: {quiz.get('id')}") + print(f" Title: {quiz.get('title') or quiz.get('quiz_name')}") + print(f" Description: {quiz.get('description')}") + print(f" Total Questions: {quiz.get('total_questions', 'N/A')}") + print(f" Total Marks: {quiz.get('totalmarks', 'N/A')}") + else: + print_warning("No quizzes available for this course") + return quizzes + else: + print_error(f"Failed to fetch quizzes: {response.status_code}") + print_json(response.json()) + return [] + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return [] + +def main(): + """Main test execution""" + print(f"\n{Colors.HEADER}{Colors.BOLD}") + print("ā•”" + "="*58 + "ā•—") + print("ā•‘" + " "*58 + "ā•‘") + print("ā•‘" + " FUSION ONLINE CMS - STUDENT FUNCTIONALITIES TEST".center(58) + "ā•‘") + print("ā•‘" + " "*58 + "ā•‘") + print("ā•š" + "="*58 + "ā•") + print(f"{Colors.END}") + + # Step 1: Login + if not login_student(): + print_error("Failed to login. Exiting.") + return + + # Step 2: Get enrolled courses + courses = get_student_courses() + if not courses: + print_warning("No courses currently enrolled.") + print_info("Creating sample API test with mock course code...") + print_section("DEMONSTRATION: Testing API endpoints with sample course") + + # Use a dummy course code to demonstrate the API endpoints + sample_course_code = "CS101" + print_info(f"Demonstrating API functionality with course: {sample_course_code}") + + # Test dashboard (will return 404 but shows endpoint is working) + print_info("\nAttempting to retrieve course dashboard...") + dashboard = get_course_dashboard(sample_course_code) + + # Demonstrate other endpoints + print_info("\nAttempting to retrieve course materials...") + documents = get_course_documents(sample_course_code) + + print_info("\nAttempting to retrieve assignments...") + assignments = get_assignments(sample_course_code) + + print_info("\nAttempting to retrieve forum discussions...") + forum_posts = get_forum_posts(sample_course_code) + + print_info("\nAttempting to retrieve attendance...") + attendance = get_attendance(sample_course_code) + + print_info("\nAttempting to retrieve quizzes...") + quizzes = get_quizzes(sample_course_code) + + print_section("NOTE") + print_warning("To test with actual course data, please:") + print(" 1. Ensure the student 'teststudent' is enrolled in courses") + print(" 2. Use valid course codes from your institution") + print(" 3. Verify course materials are uploaded by instructors") + + print_section("TEST SUMMARY") + print_success("Student authentication is working!") + print_info("API endpoints are functional and responding to requests") + print_info("Next: Enroll student in courses to see live course data") + + print(f"\n{Colors.GREEN}{Colors.BOLD}āœ“ AUTHENTICATION TEST PASSED!{Colors.END}\n") + return + + # Step 3-10: For each course, test all available features + test_course = courses[0] + course_code = test_course.get('courseCode') + print_info(f"\n{'='*60}") + print_info(f"Testing all features for course: {course_code}") + print_info(f"{'='*60}") + + # Get course dashboard + dashboard = get_course_dashboard(course_code) + + # Get course materials + documents = get_course_documents(course_code) + + # Get assignments + assignments = get_assignments(course_code) + if assignments: + print_info(f"\nFound {len(assignments)} assignment(s). Testing submission...") + first_assignment_id = assignments[0].get('id') + submit_assignment(course_code, assignment_id=first_assignment_id) + + # Get forum discussions + forum_posts = get_forum_posts(course_code) + print_info(f"\nTesting forum post functionality...") + post_forum_question(course_code) + + # Get attendance + attendance = get_attendance(course_code) + + # Get quizzes + quizzes = get_quizzes(course_code) + + # Final summary + print_section("TEST SUMMARY") + print_success("All student functionalities tested successfully!") + print(f"\n{Colors.BOLD}Summary:{Colors.END}") + print(f" Courses Enrolled: {len(courses)}") + print(f" Current Course: {course_code}") + print(f" Materials Available: {len(documents) if documents else 0}") + print(f" Assignments: {len(assignments) if assignments else 0}") + print(f" Forum Discussions: {len(forum_posts) if forum_posts else 0}") + print(f" Attendance Records: {len(attendance) if attendance else 0}") + print(f" Quizzes Available: {len(quizzes) if quizzes else 0}") + + print(f"\n{Colors.GREEN}{Colors.BOLD}āœ“ ALL TESTS COMPLETED SUCCESSFULLY!{Colors.END}\n") + +if __name__ == "__main__": + main() diff --git a/FusionIIIT/applications/online_cms/Tests/teacher_test.py b/FusionIIIT/applications/online_cms/Tests/teacher_test.py new file mode 100644 index 000000000..edd003b59 --- /dev/null +++ b/FusionIIIT/applications/online_cms/Tests/teacher_test.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +""" +Test script for teacher functionalities in Fusion Online CMS +Tests: +1. Teacher login +2. Get courses for teacher +3. Get course dashboard +4. View course documents +5. Upload course materials +""" + +import requests +import json +from datetime import datetime + +# Configuration +BASE_URL = "http://localhost:8000" +API_BASE = f"{BASE_URL}/api" + +# Test credentials +TEACHER_USERNAME = "testteacher" +TEACHER_PASSWORD = "testteacher123" + +# Global token for authenticated requests +auth_token = None + +class Colors: + HEADER = '\033[95m' + BLUE = '\033[94m' + CYAN = '\033[96m' + GREEN = '\033[92m' + YELLOW = '\033[93m' + RED = '\033[91m' + END = '\033[0m' + BOLD = '\033[1m' + UNDERLINE = '\033[4m' + +def print_section(title): + print(f"\n{Colors.HEADER}{Colors.BOLD}{'='*60}") + print(f" {title}") + print(f"{'='*60}{Colors.END}\n") + +def print_success(message): + print(f"{Colors.GREEN}āœ“ {message}{Colors.END}") + +def print_error(message): + print(f"{Colors.RED}āœ— {message}{Colors.END}") + +def print_info(message): + print(f"{Colors.CYAN}ℹ {message}{Colors.END}") + +def print_warning(message): + print(f"{Colors.YELLOW}⚠ {message}{Colors.END}") + +def print_json(data, title=""): + if title: + print(f"{Colors.BOLD}{title}:{Colors.END}") + print(json.dumps(data, indent=2)) + +def login_teacher(): + """Login as teacher and get authentication token""" + print_section("1. TEACHER LOGIN") + + url = f"{API_BASE}/auth/login/" + credentials = { + "username": TEACHER_USERNAME, + "password": TEACHER_PASSWORD + } + + print_info(f"Logging in as {TEACHER_USERNAME}...") + print_info(f"Endpoint: POST {url}") + + try: + response = requests.post(url, json=credentials) + + if response.status_code == 200: + data = response.json() + global auth_token + auth_token = data.get('token') + print_success(f"Login successful!") + print_info(f"Authentication Token: {auth_token[:20]}...") + return True + else: + print_error(f"Login failed: {response.status_code}") + print_json(response.json()) + return False + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return False + +def get_teacher_courses(): + """Get list of courses for the teacher""" + print_section("2. GET TEACHER COURSES") + + url = f"{API_BASE}/online_cms/api/courses/" + headers = {"Authorization": f"Token {auth_token}"} + + print_info(f"Fetching courses...") + print_info(f"Endpoint: GET {url}") + + try: + response = requests.get(url, headers=headers) + + if response.status_code == 200: + courses = response.json() + if courses: + print_success(f"Found {len(courses)} course(s)") + for i, course in enumerate(courses, 1): + print(f"\n {Colors.BOLD}Course {i}:{Colors.END}") + print(f" Code: {course.get('courseCode')}") + print(f" Name: {course.get('courseName')}") + print(f" Credits: {course.get('credits')}") + print(f" Semester: {course.get('semester')}") + return courses + else: + print_warning("No courses found for teacher") + return [] + else: + print_error(f"Failed to fetch courses: {response.status_code}") + print_json(response.json()) + return [] + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return [] + +def get_course_dashboard(course_code): + """Get course dashboard information""" + print_section(f"3. GET COURSE DASHBOARD - {course_code}") + + url = f"{API_BASE}/online_cms/api/{course_code}/dashboard/" + headers = {"Authorization": f"Token {auth_token}"} + + print_info(f"Fetching dashboard for {course_code}...") + print_info(f"Endpoint: GET {url}") + + try: + response = requests.get(url, headers=headers) + + if response.status_code == 200: + dashboard = response.json() + print_success(f"Dashboard retrieved successfully") + print(f"\n{Colors.BOLD}Course Information:{Colors.END}") + print(f" Code: {dashboard.get('courseCode')}") + print(f" Name: {dashboard.get('courseName')}") + print(f" Details: {dashboard.get('courseDetails', 'N/A')}") + print(f" Credits: {dashboard.get('credits')}") + print(f" Semester: {dashboard.get('semester')}") + print(f" Programme: {dashboard.get('programme')}") + + counts = dashboard.get('counts', {}) + print(f"\n{Colors.BOLD}Content Counts:{Colors.END}") + print(f" Documents: {counts.get('documents', 0)}") + print(f" Assignments: {counts.get('assignments', 0)}") + + return dashboard + else: + print_error(f"Failed to fetch dashboard: {response.status_code}") + print_json(response.json()) + return None + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return None + +def get_course_documents(course_code): + """Get documents uploaded for the course""" + print_section(f"4. GET COURSE DOCUMENTS - {course_code}") + + url = f"{API_BASE}/online_cms/api/{course_code}/documents/" + headers = {"Authorization": f"Token {auth_token}"} + + print_info(f"Fetching documents for {course_code}...") + print_info(f"Endpoint: GET {url}") + + try: + response = requests.get(url, headers=headers) + + if response.status_code == 200: + documents = response.json() + if documents: + print_success(f"Found {len(documents)} document(s)") + for i, doc in enumerate(documents, 1): + print(f"\n {Colors.BOLD}Document {i}:{Colors.END}") + print(f" ID: {doc.get('id')}") + print(f" Title: {doc.get('title')}") + print(f" Description: {doc.get('description')}") + print(f" URL: {doc.get('url')}") + print(f" Uploaded: {doc.get('uploadedAt')}") + else: + print_warning("No documents found for this course") + return documents + else: + print_error(f"Failed to fetch documents: {response.status_code}") + print_json(response.json()) + return [] + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return [] + +def upload_course_material(course_code, title, description, url): + """Upload course material (document/link)""" + print_section(f"5. UPLOAD COURSE MATERIAL - {course_code}") + + api_url = f"{API_BASE}/online_cms/api/{course_code}/documents/add/" + headers = {"Authorization": f"Token {auth_token}"} + + payload = { + "title": title, + "description": description, + "url": url + } + + print_info(f"Uploading material to {course_code}...") + print_info(f"Endpoint: POST {api_url}") + print(f"\n{Colors.BOLD}Material Details:{Colors.END}") + print(f" Title: {title}") + print(f" Description: {description}") + print(f" URL: {url}") + + try: + response = requests.post(api_url, json=payload, headers=headers) + + if response.status_code == 200: + result = response.json() + print_success(f"Material uploaded successfully!") + print(f" Document ID: {result.get('id')}") + return result + else: + print_error(f"Failed to upload material: {response.status_code}") + print_json(response.json()) + return None + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return None + +def main(): + """Main test execution""" + print(f"\n{Colors.HEADER}{Colors.BOLD}") + print("ā•”" + "="*58 + "ā•—") + print("ā•‘" + " "*58 + "ā•‘") + print("ā•‘" + " FUSION ONLINE CMS - TEACHER FUNCTIONALITIES TEST".center(58) + "ā•‘") + print("ā•‘" + " "*58 + "ā•‘") + print("ā•š" + "="*58 + "ā•") + print(f"{Colors.END}") + + # Step 1: Login + if not login_teacher(): + print_error("Failed to login. Exiting.") + return + + # Step 2: Get courses + courses = get_teacher_courses() + if not courses: + print_warning("No courses found. Creating test data...") + # Try to create test data + setup_test_data() + courses = get_teacher_courses() + + if not courses: + print_error("Could not get or create courses. Exiting.") + return + + # Step 3-5: For each course, get dashboard, documents, and upload material + for course_code in [c.get('courseCode') for c in courses[:1]]: # Test with first course + print_info(f"\nTesting operations for course: {course_code}") + + # Get dashboard + dashboard = get_course_dashboard(course_code) + if not dashboard: + continue + + # Get documents + documents = get_course_documents(course_code) + + # Upload a test material + test_material = { + "title": f"Test Material - {datetime.now().strftime('%H:%M:%S')}", + "description": "This is a test material uploaded via API", + "url": "https://example.com/test_material.pdf" + } + + uploaded = upload_course_material(course_code, **test_material) + + if uploaded: + # Verify upload by fetching documents again + print_section("6. VERIFY UPLOAD") + print_info("Fetching documents again to verify upload...") + new_documents = get_course_documents(course_code) + if new_documents and len(new_documents) > len(documents): + print_success("Material upload verified!") + else: + print_warning("Could not verify material upload") + + # Final summary + print_section("TEST SUMMARY") + print_success("All tests completed!") + print_info(f"Teacher {TEACHER_USERNAME} can:") + print(f" • Login to the system") + print(f" • View their assigned courses") + print(f" • View course dashboards") + print(f" • Access course materials") + print(f" • Upload course materials") + +def setup_test_data(): + """Setup test data if needed""" + print_section("SETTING UP TEST DATA") + + shell_script = """ +import os +import django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Fusion.settings.development_local') +django.setup() + +from applications.programme_curriculum.models import Course, Programme, Batch, CourseInstructor +from applications.globals.models import ExtraInfo +from django.contrib.auth.models import User + +try: + # Get or create programme and batch + programme, _ = Programme.objects.get_or_create(code='CSE', defaults={'name': 'Computer Science'}) + batch, _ = Batch.objects.get_or_create(year=2024, defaults={'name': 'Batch 2024'}) + + # Get or create a test course + course, _ = Course.objects.get_or_create( + code='TEST101', + defaults={ + 'name': 'Test Course for Teachers', + 'credit': 3, + 'program_id': programme + } + ) + + # Get testteacher and assign to course + try: + testteacher_user = User.objects.get(username='testteacher') + testteacher_extra = ExtraInfo.objects.get(user=testteacher_user) + + course_instr, created = CourseInstructor.objects.get_or_create( + instructor_id=testteacher_extra, + course_id=course, + batch_id=batch + ) + + if created: + print("Created test course and assigned to testteacher") + else: + print("Test course already assigned to testteacher") + except Exception as e: + print(f"Error assigning course: {e}") + +except Exception as e: + print(f"Error setting up test data: {e}") +""" + + import subprocess + result = subprocess.run( + f"cd /home/divyeshtechs/Desktop/Fusion/FusionIIIT && source ../venv/bin/activate && python -c '{shell_script}'", + shell=True, + capture_output=True, + text=True + ) + + if result.returncode == 0: + print_success("Test data setup completed") + if result.stdout: + print_info(result.stdout.strip()) + else: + print_error(f"Test data setup failed: {result.stderr}") + +if __name__ == "__main__": + main() diff --git a/FusionIIIT/applications/online_cms/Tests/teacher_test_complete.py b/FusionIIIT/applications/online_cms/Tests/teacher_test_complete.py new file mode 100644 index 000000000..23e14ce34 --- /dev/null +++ b/FusionIIIT/applications/online_cms/Tests/teacher_test_complete.py @@ -0,0 +1,513 @@ +#!/usr/bin/env python3 +""" +Complete test script for teacher functionalities in Fusion Online CMS +Tests: +1. Teacher login +2. Get courses for teacher +3. Upload course materials (documents) +4. Create and upload assignments +5. Create and manage quizzes +6. Mark and view attendance +""" + +import requests +import json +from datetime import datetime, timedelta + +# Configuration +BASE_URL = "http://localhost:8000" +AUTH_BASE = f"{BASE_URL}/api" +API_BASE = f"{BASE_URL}/ocms/api" + +# Test credentials +TEACHER_USERNAME = "testteacher" +TEACHER_PASSWORD = "testteacher123" + +# Global token for authenticated requests +auth_token = None + +class Colors: + HEADER = '\033[95m' + BLUE = '\033[94m' + CYAN = '\033[96m' + GREEN = '\033[92m' + YELLOW = '\033[93m' + RED = '\033[91m' + END = '\033[0m' + BOLD = '\033[1m' + UNDERLINE = '\033[4m' + +def print_section(title): + print(f"\n{Colors.HEADER}{Colors.BOLD}{'='*60}") + print(f" {title}") + print(f"{'='*60}{Colors.END}\n") + +def print_success(message): + print(f"{Colors.GREEN}āœ“ {message}{Colors.END}") + +def print_error(message): + print(f"{Colors.RED}āœ— {message}{Colors.END}") + +def print_info(message): + print(f"{Colors.CYAN}ℹ {message}{Colors.END}") + +def print_warning(message): + print(f"{Colors.YELLOW}⚠ {message}{Colors.END}") + +def print_json(data, title=""): + if title: + print(f"{Colors.BOLD}{title}:{Colors.END}") + print(json.dumps(data, indent=2)) + +def login_teacher(): + """Login as teacher and get authentication token""" + print_section("1. TEACHER LOGIN") + + url = f"{AUTH_BASE}/auth/login/" + credentials = { + "username": TEACHER_USERNAME, + "password": TEACHER_PASSWORD + } + + print_info(f"Logging in as {TEACHER_USERNAME}...") + print_info(f"Endpoint: POST {url}") + + try: + response = requests.post(url, json=credentials) + + if response.status_code == 200: + data = response.json() + global auth_token + auth_token = data.get('token') + print_success(f"Login successful!") + print_info(f"Authentication Token: {auth_token[:20]}...") + return True + else: + print_error(f"Login failed: {response.status_code}") + print_json(response.json()) + return False + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return False + +def get_teacher_courses(): + """Get list of courses for the teacher""" + print_section("2. GET TEACHER COURSES") + + url = f"{API_BASE}/courses/" + headers = {"Authorization": f"Token {auth_token}"} + + print_info(f"Fetching courses...") + print_info(f"Endpoint: GET {url}") + + try: + response = requests.get(url, headers=headers) + + if response.status_code == 200: + courses = response.json() + if courses: + print_success(f"Found {len(courses)} course(s)") + for i, course in enumerate(courses, 1): + print(f"\n {Colors.BOLD}Course {i}:{Colors.END}") + print(f" Code: {course.get('courseCode')}") + print(f" Name: {course.get('courseName')}") + print(f" Credits: {course.get('credits')}") + print(f" Semester: {course.get('semester')}") + return courses + else: + print_warning("No courses found for teacher") + return [] + else: + print_error(f"Failed to fetch courses: {response.status_code}") + print_json(response.json()) + return [] + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return [] + +def upload_course_material(course_code, title, description, url): + """Upload course material (document/link)""" + print_section(f"3. UPLOAD COURSE MATERIAL - {course_code}") + + api_url = f"{API_BASE}/{course_code}/documents/add/" + headers = {"Authorization": f"Token {auth_token}"} + + payload = { + "title": title, + "description": description, + "url": url + } + + print_info(f"Uploading material to {course_code}...") + print_info(f"Endpoint: POST {api_url}") + print(f"\n{Colors.BOLD}Material Details:{Colors.END}") + print(f" Title: {title}") + print(f" Description: {description}") + print(f" URL: {url}") + + try: + response = requests.post(api_url, json=payload, headers=headers) + + if response.status_code == 200: + result = response.json() + print_success(f"Material uploaded successfully!") + print(f" Document ID: {result.get('id')}") + return result + else: + print_error(f"Failed to upload material: {response.status_code}") + print_json(response.json()) + return None + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return None + +def create_assignment(course_code, title, description, deadline): + """Create an assignment for the course""" + print_section(f"4. CREATE ASSIGNMENT - {course_code}") + + url = f"{API_BASE}/{course_code}/assignments/add/" + headers = {"Authorization": f"Token {auth_token}"} + + payload = { + "title": title, + "description": description, + "deadline": deadline + } + + print_info(f"Creating assignment for {course_code}...") + print_info(f"Endpoint: POST {url}") + print(f"\n{Colors.BOLD}Assignment Details:{Colors.END}") + print(f" Title: {title}") + print(f" Description: {description}") + print(f" Deadline: {deadline}") + + try: + response = requests.post(url, json=payload, headers=headers) + + if response.status_code == 200: + result = response.json() + print_success(f"Assignment created successfully!") + print(f" Assignment ID: {result.get('id')}") + return result + else: + print_error(f"Failed to create assignment: {response.status_code}") + print_json(response.json()) + return None + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return None + +def get_assignments(course_code): + """Get assignments for the course""" + url = f"{API_BASE}/{course_code}/assignments/" + headers = {"Authorization": f"Token {auth_token}"} + + print_info(f"Fetching assignments for {course_code}...") + + try: + response = requests.get(url, headers=headers) + + if response.status_code == 200: + assignments = response.json() + if assignments: + print_success(f"Found {len(assignments)} assignment(s)") + for i, assign in enumerate(assignments, 1): + print(f"\n {Colors.BOLD}Assignment {i}:{Colors.END}") + print(f" ID: {assign.get('id')}") + print(f" Title: {assign.get('title')}") + print(f" Deadline: {assign.get('deadline')}") + else: + print_warning("No assignments found for this course") + return assignments + else: + print_error(f"Failed to fetch assignments: {response.status_code}") + return [] + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return [] + +def create_quiz(course_code, title, start_time, end_time, negative_marks=0): + """Create a quiz for the course""" + print_section(f"5. CREATE QUIZ - {course_code}") + + url = f"{API_BASE}/{course_code}/quizzes/create/" + headers = {"Authorization": f"Token {auth_token}"} + + payload = { + "title": title, + "start_time": start_time, + "end_time": end_time, + "negative_marks": negative_marks + } + + print_info(f"Creating quiz for {course_code}...") + print_info(f"Endpoint: POST {url}") + print(f"\n{Colors.BOLD}Quiz Details:{Colors.END}") + print(f" Title: {title}") + print(f" Start Time: {start_time}") + print(f" End Time: {end_time}") + print(f" Negative Marks: {negative_marks}") + + try: + response = requests.post(url, json=payload, headers=headers) + + if response.status_code == 200: + result = response.json() + print_success(f"Quiz created successfully!") + print(f" Quiz ID: {result.get('id')}") + return result + else: + print_error(f"Failed to create quiz: {response.status_code}") + print_json(response.json()) + return None + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return None + +def get_quizzes(course_code): + """Get quizzes for the course""" + url = f"{API_BASE}/{course_code}/quizzes/" + headers = {"Authorization": f"Token {auth_token}"} + + print_info(f"Fetching quizzes for {course_code}...") + + try: + response = requests.get(url, headers=headers) + + if response.status_code == 200: + quizzes = response.json() + if quizzes: + print_success(f"Found {len(quizzes)} quiz(zes)") + for i, quiz in enumerate(quizzes, 1): + print(f"\n {Colors.BOLD}Quiz {i}:{Colors.END}") + print(f" ID: {quiz.get('id')}") + print(f" Title: {quiz.get('title')}") + print(f" Start: {quiz.get('start_time')}") + print(f" End: {quiz.get('end_time')}") + else: + print_warning("No quizzes found for this course") + return quizzes + else: + print_error(f"Failed to fetch quizzes: {response.status_code}") + return [] + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return [] + +def get_attendance(course_code): + """Get attendance records for the course""" + print_section(f"6. GET ATTENDANCE - {course_code}") + + url = f"{API_BASE}/{course_code}/attendance/" + headers = {"Authorization": f"Token {auth_token}"} + + print_info(f"Fetching attendance for {course_code}...") + print_info(f"Endpoint: GET {url}") + + try: + response = requests.get(url, headers=headers) + + if response.status_code == 200: + attendance = response.json() + if attendance: + print_success(f"Found attendance records") + if isinstance(attendance, list) and all(isinstance(rec, dict) for rec in attendance): + # Group by date + by_date = {} + for rec in attendance: + date = rec.get('date', 'Unknown') + if date not in by_date: + by_date[date] = [] + by_date[date].append(rec) + + for date, records in sorted(by_date.items()): + print(f"\n {Colors.BOLD}{date}:{Colors.END} {len(records)} records") + else: + print_json(attendance, title="Attendance Response") + else: + print_warning("No attendance records found for this course") + return attendance + else: + print_error(f"Failed to fetch attendance: {response.status_code}") + if response.status_code == 403: + print_warning("You may not be assigned as instructor in the system.") + print_warning("See TROUBLESHOOTING section below.") + print_json(response.json()) + return None + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return None + +def mark_attendance(course_code, date, attendance_data): + """Mark attendance for the course""" + print_section(f"7. MARK ATTENDANCE - {course_code}") + + url = f"{API_BASE}/{course_code}/attendance/" + headers = {"Authorization": f"Token {auth_token}"} + + payload = { + "date": date, + "attendance": attendance_data + } + + print_info(f"Marking attendance for {course_code}...") + print_info(f"Endpoint: POST {url}") + print(f"\n{Colors.BOLD}Attendance Details:{Colors.END}") + print(f" Date: {date}") + print(f" Records: {len(attendance_data)}") + + try: + response = requests.post(url, json=payload, headers=headers) + + if response.status_code == 200: + result = response.json() + print_success(f"Attendance marked successfully!") + print_json(result) + return result + else: + print_error(f"Failed to mark attendance: {response.status_code}") + if response.status_code == 403: + print_warning("You may not be assigned as instructor in the system.") + print_warning("See TROUBLESHOOTING section below.") + print_json(response.json()) + return None + + except requests.exceptions.RequestException as e: + print_error(f"Connection error: {e}") + return None + +def print_troubleshooting(): + """Print troubleshooting guide""" + print_section("TROUBLESHOOTING") + + print(f"{Colors.BOLD}Issue: Attendance returns 403 'Not an instructor for this course'{Colors.END}") + print(""" +The attendance API checks if you're assigned to the course in the old system +(Curriculum_Instructor table), but your assignment is in the new system +(CourseInstructor table). + +Solution: Run this Django shell command to assign the teacher: + + python manage.py shell + + >>> from applications.academic_information.models import Curriculum_Instructor, ExtraInfo + >>> from applications.programme_curriculum.models import Curriculum, Course + >>> + >>> # Get the teacher's ExtraInfo + >>> teacher = ExtraInfo.objects.get(user__username='testteacher') + >>> + >>> # Get the course (assuming CS101 is in curriculum 1) + >>> course = Course.objects.get(code='CS101') + >>> curriculum = Curriculum.objects.filter(courses=course).first() + >>> + >>> # Create the assignment if not exists + >>> Curriculum_Instructor.objects.get_or_create( + ... instructor_id=teacher, + ... curriculum_id=curriculum + ... ) + +Or, the backend should be fixed to check both systems. The bug is in: + /applications/online_cms/services.py - get_instructor_link() function + """) + +def main(): + """Main test execution""" + print(f"\n{Colors.HEADER}{Colors.BOLD}") + print("ā•”" + "="*58 + "ā•—") + print("ā•‘" + " "*58 + "ā•‘") + print("ā•‘" + " FUSION ONLINE CMS - COMPLETE TEACHER TEST".center(58) + "ā•‘") + print("ā•‘" + " "*58 + "ā•‘") + print("ā•š" + "="*58 + "ā•") + print(f"{Colors.END}") + + # Step 1: Login + if not login_teacher(): + print_error("Failed to login. Exiting.") + return + + # Step 2: Get courses + courses = get_teacher_courses() + if not courses: + print_error("No courses found. Exiting.") + return + + # Get the first course code + course_code = courses[0].get('courseCode') + print_info(f"\nTesting with course: {course_code}") + + # Step 3: Upload material + test_material = { + "title": f"Test Material - {datetime.now().strftime('%H:%M:%S')}", + "description": "This is a test material uploaded via API", + "url": "https://example.com/test_material.pdf" + } + upload_course_material(course_code, **test_material) + + # Step 4: Create assignment + deadline = (datetime.now() + timedelta(days=7)).isoformat() + test_assignment = { + "title": f"Test Assignment - {datetime.now().strftime('%H:%M:%S')}", + "description": "This is a test assignment created via API", + "deadline": deadline + } + assignment = create_assignment(course_code, **test_assignment) + + # Get all assignments + assignments = get_assignments(course_code) + + # Step 5: Create quiz + now = datetime.now() + start_time = now.isoformat() + end_time = (now + timedelta(hours=1)).isoformat() + + test_quiz = { + "title": f"Test Quiz - {now.strftime('%H:%M:%S')}", + "start_time": start_time, + "end_time": end_time, + "negative_marks": 0 + } + quiz = create_quiz(course_code, **test_quiz) + + # Get all quizzes + quizzes = get_quizzes(course_code) + + # Step 6: Get attendance + attendance = get_attendance(course_code) + + # Step 7: Try to mark attendance (will likely fail if teacher not in old system) + if attendance is not None: + today = datetime.now().strftime('%Y-%m-%d') + # Create mock attendance data (you'll need to adjust student IDs) + attendance_data = [ + {"student_id": "student01", "present": True}, + ] + mark_attendance(course_code, today, attendance_data) + + # Final summary + print_section("TEST SUMMARY") + print_success("Test completed!") + print_info(f"Teacher {TEACHER_USERNAME} can:") + print(f" āœ“ Login to the system") + print(f" āœ“ View their assigned courses") + print(f" āœ“ Upload course materials") + print(f" āœ“ Create assignments") + print(f" āœ“ Create quizzes") + if attendance is None: + print(f" āœ— Access attendance (requires fix - see below)") + else: + print(f" āœ“ Access attendance") + + # Print troubleshooting if needed + if attendance is None: + print_troubleshooting() + +if __name__ == "__main__": + main() diff --git a/FusionIIIT/applications/online_cms/Tests/test.py b/FusionIIIT/applications/online_cms/Tests/test.py new file mode 100644 index 000000000..e69de29bb diff --git a/FusionIIIT/applications/online_cms/Tests/test_api_fixes.py b/FusionIIIT/applications/online_cms/Tests/test_api_fixes.py new file mode 100644 index 000000000..ebee86a69 --- /dev/null +++ b/FusionIIIT/applications/online_cms/Tests/test_api_fixes.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" +Test script to verify API endpoint fixes for attendance, course roster, and quiz creation. +This script tests the key functionality that was failing. + +Note: This script requires authentication. You need to provide a valid token +or run it in an authenticated session. +""" + +import requests +import json +import sys +from datetime import datetime, timedelta + +# Configuration +BASE_URL = "http://localhost:8000" # Adjust as needed +API_BASE = "/ocms/api" + +# Authentication - Replace with your actual token +# You can get this token from browser localStorage under key "authToken" +AUTH_TOKEN = "" # Add your token here or pass as command line argument + +def get_auth_headers(): + """Get authentication headers for API requests""" + if AUTH_TOKEN: + return {"Authorization": f"Token {AUTH_TOKEN}"} + return {} + +def test_course_list(): + """Test getting course list""" + print("Testing course list...") + try: + headers = get_auth_headers() + response = requests.get(f"{BASE_URL}{API_BASE}/courses/", headers=headers) + print(f"Status: {response.status_code}") + if response.status_code == 200: + courses = response.json() + print(f"Found {len(courses)} courses") + if courses: + print(f"Sample course: {courses[0]}") + return courses + else: + print(f"Error: {response.text}") + return [] + except Exception as e: + print(f"Exception: {e}") + return [] + +def test_attendance_roster(course_code): + """Test getting attendance roster""" + print(f"\nTesting attendance roster for {course_code}...") + try: + headers = get_auth_headers() + response = requests.get(f"{BASE_URL}{API_BASE}/{course_code}/attendance/roster/", headers=headers) + print(f"Status: {response.status_code}") + if response.status_code == 200: + roster = response.json() + print(f"Roster size: {len(roster)}") + if roster: + print(f"Sample student: {roster[0]}") + return roster + else: + print(f"Error: {response.text}") + return [] + except Exception as e: + print(f"Exception: {e}") + return [] + +def test_quiz_creation(course_code): + """Test creating a quiz""" + print(f"\nTesting quiz creation for {course_code}...") + try: + headers = get_auth_headers() + # Calculate future dates for the quiz + now = datetime.now() + start_time = now + timedelta(minutes=5) + end_time = now + timedelta(minutes=35) + + data = { + "title": "Test Quiz", + "start_time": start_time.isoformat(), + "end_time": end_time.isoformat(), + "negative_marks": 0.25, + "description": "Test quiz for API verification", + "rules": "No cheating" + } + + response = requests.post(f"{BASE_URL}{API_BASE}/{course_code}/quizzes/create/", + json=data, headers=headers) + print(f"Status: {response.status_code}") + if response.status_code == 200: + result = response.json() + print(f"Quiz created: {result}") + return result + else: + print(f"Error: {response.text}") + return None + except Exception as e: + print(f"Exception: {e}") + return None + +def test_attendance_submission(course_code): + """Test submitting attendance""" + print(f"\nTesting attendance submission for {course_code}...") + try: + headers = get_auth_headers() + # Get current date + today = datetime.now().strftime("%Y-%m-%d") + + data = { + "date": today, + "attendance": [ + {"student_id": "test_student_1", "present": True}, + {"student_id": "test_student_2", "present": False} + ], + "notes": "Test attendance" + } + + response = requests.post(f"{BASE_URL}{API_BASE}/{course_code}/attendance/", + json=data, headers=headers) + print(f"Status: {response.status_code}") + if response.status_code == 200: + result = response.json() + print(f"Attendance submitted: {result}") + return result + else: + print(f"Error: {response.text}") + return None + except Exception as e: + print(f"Exception: {e}") + return None + +def main(): + print("API Endpoint Fix Verification Test") + print("=" * 50) + + # Test 1: Course list + courses = test_course_list() + + if not courses: + print("\nāŒ No courses found. Please ensure you're authenticated and have courses.") + return + + # Use the first course for testing + course_code = courses[0].get('courseCode', '') + if not course_code: + print("\nāŒ No course code found in course list") + return + + print(f"\nUsing course: {course_code}") + + # Test 2: Attendance roster + roster = test_attendance_roster(course_code) + + if not roster: + print("āš ļø Empty roster - this might be expected if no students are enrolled") + else: + print(f"āœ… Roster test passed - found {len(roster)} students") + + # Test 3: Quiz creation + quiz = test_quiz_creation(course_code) + + if quiz: + print("āœ… Quiz creation test passed") + else: + print("āŒ Quiz creation test failed") + + # Test 4: Attendance submission + attendance = test_attendance_submission(course_code) + + if attendance: + print("āœ… Attendance submission test passed") + else: + print("āŒ Attendance submission test failed") + + print("\n" + "=" * 50) + print("Test completed. Check the results above.") + print("Note: Some tests may fail if you're not authenticated or don't have proper permissions.") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/FusionIIIT/applications/online_cms/Tests/test_attendance_comprehensive.py b/FusionIIIT/applications/online_cms/Tests/test_attendance_comprehensive.py new file mode 100644 index 000000000..a9d4e075b --- /dev/null +++ b/FusionIIIT/applications/online_cms/Tests/test_attendance_comprehensive.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Comprehensive attendance test""" +import requests +import json +from datetime import date, timedelta + +BASE_URL = "http://localhost:8000" + +def login(username, password): + response = requests.post( + f"{BASE_URL}/api/auth/login/", + data={"username": username, "password": password} + ) + if response.status_code == 200: + data = response.json() + token = data.get("token") or data.get("access") + if token: + return token + print(f" Login error for {username}: {response.status_code} - {response.text[:100]}") + return None + +print("="*70) +print("COMPREHENSIVE ATTENDANCE SYSTEM TEST") +print("="*70) + +# Get tokens +teacher_token = login("testteacher", "testteacher123") +student01_token = login("student01", "Control d") + +if not all([teacher_token, student01_token]): + print("āœ— Failed to login users") + exit(1) + +print("\nāœ“ All users logged in successfully") + +# Test 1: Teacher marks attendance for multiple students on multiple dates +print("\n" + "-"*70) +print("TEST 1: Teacher marks attendance for 2 students on 3 different dates") +print("-"*70) + +dates_to_test = [ + date.today(), + date.today() - timedelta(days=1), + date.today() - timedelta(days=2), +] + +for test_date in dates_to_test: + payload = { + "date": str(test_date), + "attendance": [ + {"student_id": "student01", "present": True}, + ] + } + response = requests.post( + f"{BASE_URL}/ocms/api/CS101/attendance/", + headers={"Authorization": f"Token {teacher_token}"}, + json=payload + ) + status = "āœ“" if response.status_code == 200 else "āœ—" + print(f"{status} {test_date}: {response.status_code} - {response.json()}") + +# Test 2: Teacher views all attendance +print("\n" + "-"*70) +print("TEST 2: Teacher views all attendance records") +print("-"*70) + +response = requests.get( + f"{BASE_URL}/ocms/api/CS101/attendance/", + headers={"Authorization": f"Token {teacher_token}"} +) +print(f"Status: {response.status_code}") +if response.status_code == 200: + data = response.json() + print(f"āœ“ Records by date: {len(data)} dates") + for date_key in sorted(data.keys()): + print(f" {date_key}: {len(data[date_key])} students") + for student in data[date_key]: + print(f" - {student['student_id']}: {'Present' if student['present'] else 'Absent'}") + +# Test 3: Student01 views their attendance +print("\n" + "-"*70) +print("TEST 3: Student01 views their attendance") +print("-"*70) + +response = requests.get( + f"{BASE_URL}/ocms/api/CS101/attendance/", + headers={"Authorization": f"Token {student01_token}"} +) +print(f"Status: {response.status_code}") +if response.status_code == 200: + data = response.json() + if data: + print(f"āœ“ Found {len(data)} dates with attendance") + total_present = sum(len([p for p in data[d] if p['present']]) for d in data) + total_absent = sum(len([p for p in data[d] if not p['present']]) for d in data) + print(f" Total present: {total_present}") + print(f" Total absent: {total_absent}") + else: + print("āœ— No attendance records found") + +# Test 4: Student02 views their attendance +print("\n" + "-"*70) +print("TEST 4: Student02 views their attendance") +print("-"*70) + +response = requests.get( + f"{BASE_URL}/ocms/api/CS101/attendance/", + headers={"Authorization": f"Token {student02_token}"} +) +print(f"Status: {response.status_code}") +if response.status_code == 200: + data = response.json() + if data: + print(f"āœ“ Found {len(data)} dates with attendance") + total_present = sum(len([p for p in data[d] if p['present']]) for d in data) + total_absent = sum(len([p for p in data[d] if not p['present']]) for d in data) + print(f" Total present: {total_present}") + print(f" Total absent: {total_absent}") + else: + print("āœ— No attendance records found") + +# Test 4: Update attendance (mark student01 absent on today's date) +print("\n" + "-"*70) +print("TEST 4: Teacher updates attendance (mark student01 absent today)") +print("-"*70) + +payload = { + "date": str(date.today()), + "attendance": [ + {"student_id": "student01", "present": False}, + ] +} +response = requests.post( + f"{BASE_URL}/ocms/api/CS101/attendance/", + headers={"Authorization": f"Token {teacher_token}"}, + json=payload +) +print(f"Status: {response.status_code} - {response.json()}") + +# Verify update +print("\nVerify update - student01 should now be absent today:") +response = requests.get( + f"{BASE_URL}/ocms/api/CS101/attendance/", + headers={"Authorization": f"Token {student01_token}"} +) +if response.status_code == 200: + data = response.json() + today = str(date.today()) + if today in data: + for record in data[today]: + status_str = "Present" if record['present'] else "Absent" + print(f" Today {today}: {status_str}") + +print("\n" + "="*70) +print("āœ… ALL TESTS COMPLETED SUCCESSFULLY") +print("="*70) +print("\nSummary:") +print(" āœ“ Teachers can mark attendance") +print(" āœ“ Teachers can mark attendance on multiple dates") +print(" āœ“ Teachers can view all attendance records") +print(" āœ“ Students can view their own attendance records") +print(" āœ“ Attendance records can be updated") +print(" āœ“ New CourseInstructor system fully functional") diff --git a/FusionIIIT/applications/online_cms/Tests/test_attendance_new.py b/FusionIIIT/applications/online_cms/Tests/test_attendance_new.py new file mode 100644 index 000000000..97f4c4a3c --- /dev/null +++ b/FusionIIIT/applications/online_cms/Tests/test_attendance_new.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Test attendance API for new CourseInstructor system""" +import requests +import json +from datetime import datetime, date + +BASE_URL = "http://localhost:8000" + +# Test credentials +TEACHER_USER = "testteacher" +TEACHER_PASS = "testteacher123" +STUDENT_USER = "student01" +STUDENT_PASS = "Control d" + +def login(username, password): + """Get authentication token""" + response = requests.post( + f"{BASE_URL}/api/auth/login/", + data={"username": username, "password": password} + ) + if response.status_code == 200: + data = response.json() + # Handle both token formats: "token" or "access" + token = data.get("token") or data.get("access") + if token: + print(f"āœ“ Logged in as {username}: {token[:20]}...") + return token + else: + print(f"āœ— Login failed for {username}: No token in response - {data}") + return None + else: + print(f"āœ— Login failed for {username}: {response.status_code} - {response.text}") + return None + +def test_get_attendance(token, course_code="CS101"): + """Test GET attendance endpoint""" + headers = {"Authorization": f"Token {token}"} + response = requests.get( + f"{BASE_URL}/ocms/api/{course_code}/attendance/", + headers=headers + ) + print(f"\nGET /ocms/api/{course_code}/attendance/") + print(f"Status: {response.status_code}") + if response.status_code == 200: + print(f"āœ“ Response: {json.dumps(response.json(), indent=2)}") + return True + else: + print(f"āœ— Error: {response.text}") + return False + +def test_post_attendance(token, course_code="CS101"): + """Test POST attendance endpoint""" + headers = {"Authorization": f"Token {token}"} + payload = { + "date": str(date.today()), + "attendance": [ + {"student_id": "student01", "present": True}, + {"student_id": "student02", "present": False} + ] + } + response = requests.post( + f"{BASE_URL}/ocms/api/{course_code}/attendance/", + headers=headers, + json=payload + ) + print(f"\nPOST /ocms/api/{course_code}/attendance/") + print(f"Status: {response.status_code}") + print(f"Payload: {json.dumps(payload, indent=2)}") + if response.status_code == 200: + print(f"āœ“ Response: {json.dumps(response.json(), indent=2)}") + return True + else: + print(f"āœ— Error: {response.text}") + return False + +def main(): + print("=" * 60) + print("Testing Attendance API for New CourseInstructor System") + print("=" * 60) + + # Test teacher flow + print("\n[TEACHER TESTS]") + teacher_token = login(TEACHER_USER, TEACHER_PASS) + if not teacher_token: + return + + print("\n--- Test 1: GET existing attendance (should be empty initially) ---") + test_get_attendance(teacher_token, "CS101") + + print("\n--- Test 2: POST attendance records ---") + test_post_attendance(teacher_token, "CS101") + + print("\n--- Test 3: GET attendance again (should show saved records) ---") + test_get_attendance(teacher_token, "CS101") + + # Test student flow + print("\n\n[STUDENT TESTS]") + student_token = login(STUDENT_USER, STUDENT_PASS) + if not student_token: + return + + print("\n--- Test 4: Student GET attendance (should show their own records) ---") + test_get_attendance(student_token, "CS101") + + print("\n" + "=" * 60) + print("Attendance API Tests Complete") + print("=" * 60) + +if __name__ == "__main__": + main() diff --git a/FusionIIIT/applications/online_cms/Tests/test_fetch_attendance_student.py b/FusionIIIT/applications/online_cms/Tests/test_fetch_attendance_student.py new file mode 100644 index 000000000..08b3cfbeb --- /dev/null +++ b/FusionIIIT/applications/online_cms/Tests/test_fetch_attendance_student.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +import requests + +BASE_URL = "http://localhost:8000" + +# student creds +token_url = f"{BASE_URL}/api/auth/login/" +student = "student01" +password = "Control d" + +resp = requests.post(token_url, data={"username": student, "password": password}) +print("login status", resp.status_code, resp.text) + +if resp.status_code != 200: + raise SystemExit("login failed") + +json_data = resp.json() +token = json_data.get("token") or json_data.get("access") +print("token", token) + +headers = {"Authorization": f"Token {token}"} + +for course in ["CS101", "CS102", "CS201"]: + r = requests.get(f"{BASE_URL}/ocms/api/{course}/attendance/", headers=headers) + print("course", course, "status", r.status_code) + print(r.json()) diff --git a/FusionIIIT/applications/online_cms/Tests/test_login_flow.py b/FusionIIIT/applications/online_cms/Tests/test_login_flow.py new file mode 100644 index 000000000..4018a5dce --- /dev/null +++ b/FusionIIIT/applications/online_cms/Tests/test_login_flow.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +""" +Test script to verify login endpoint is working correctly +""" +import os +import sys +import django +import requests + +# Setup Django +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'Fusion.settings.development_local') +sys.path.insert(0, '/home/divyeshtechs/Desktop/Fusion/FusionIIIT') +django.setup() + +from django.contrib.auth.models import User +from rest_framework.authtoken.models import Token + +# Test 1: Verify test users have tokens +print("=" * 60) +print("TEST 1: Checking if test users have authentication tokens") +print("=" * 60) + +test_users = ['teststudent', 'testteacher', 'admin'] + +for username in test_users: + try: + user = User.objects.get(username=username) + token, created = Token.objects.get_or_create(user=user) + status = "CREATED" if created else "EXISTS" + print(f"āœ“ {username}: Token {status}") + print(f" Token: {token.key}") + except User.DoesNotExist: + print(f"āœ— {username}: User not found") + except Exception as e: + print(f"āœ— {username}: Error - {e}") + +print("\n" + "=" * 60) +print("TEST 2: Testing login via API") +print("=" * 60) + +# Test login endpoint +login_url = "http://127.0.0.1:8000/api/auth/login/" +credentials = { + "username": "teststudent", + "password": "password123" # Default test password +} + +try: + response = requests.post(login_url, json=credentials) + if response.status_code == 200: + print(f"āœ“ Login successful!") + data = response.json() + print(f" Response: {data}") + if 'token' in data: + print(f" āœ“ Token in response: {data['token'][:10]}...") + else: + print(f" ⚠ No token in response") + else: + print(f"āœ— Login failed with status {response.status_code}") + print(f" Response: {response.text}") +except Exception as e: + print(f"āœ— Error testing login: {e}") + +print("\n" + "=" * 60) +print("SUMMARY") +print("=" * 60) +print("If users don't have tokens, run:") +print(" python manage.py drf_create_token admin") +print(" python manage.py drf_create_token teststudent") +print(" python manage.py drf_create_token testteacher") +print("\nThen log in with credentials:") +print(" Student: username='teststudent', password='password123'") +print(" Teacher: username='testteacher', password='password123'") diff --git a/FusionIIIT/applications/online_cms/Tests/test_quiz_no_window.py b/FusionIIIT/applications/online_cms/Tests/test_quiz_no_window.py new file mode 100644 index 000000000..09e5a0dfd --- /dev/null +++ b/FusionIIIT/applications/online_cms/Tests/test_quiz_no_window.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Test quiz visibility after removing time window restriction""" +import requests +import json + +BASE_URL = "http://localhost:8000" + +def login(username, password): + response = requests.post( + f"{BASE_URL}/api/auth/login/", + data={"username": username, "password": password} + ) + if response.status_code == 200: + data = response.json() + return data.get("token") or data.get("access") + return None + +print("="*70) +print("QUIZ VISIBILITY TEST (After Removing Time Window)") +print("="*70) + +# Login as teacher +teacher_token = login("testteacher", "testteacher123") +if not teacher_token: + print("āœ— Failed to login as teacher") + exit(1) + +# Login as student +student_token = login("student01", "Control d") +if not student_token: + print("āœ— Failed to login as student") + exit(1) + +print(f"\nāœ“ Teacher logged in") +print(f"āœ“ Student logged in") + +# Teacher checks all quizzes +print("\n" + "-"*70) +print("TEACHER VIEW: All quizzes") +print("-"*70) + +headers = {"Authorization": f"Token {teacher_token}"} +response = requests.get( + f"{BASE_URL}/ocms/api/CS101/quizzes/", + headers=headers +) +print(f"Status: {response.status_code}") +if response.status_code == 200: + teacher_quizzes = response.json() + print(f"Total quizzes: {len(teacher_quizzes)}") + for q in teacher_quizzes: + print(f"\n āœ“ {q['title']}") + print(f" Start: {q['startTime']}") + print(f" End: {q['endTime']}") + +# Student checks quizzes +print("\n" + "-"*70) +print("STUDENT VIEW: All available quizzes (no time window restriction)") +print("-"*70) + +headers = {"Authorization": f"Token {student_token}"} +response = requests.get( + f"{BASE_URL}/ocms/api/CS101/quizzes/", + headers=headers +) +print(f"Status: {response.status_code}") +if response.status_code == 200: + student_quizzes = response.json() + print(f"Visible to student: {len(student_quizzes)}") + + if len(student_quizzes) > 0: + print("\nāœ… SUCCESS! Students can see all quizzes:") + for q in student_quizzes: + print(f"\n āœ“ {q['title']}") + print(f" Start: {q['startTime']}") + print(f" End: {q['endTime']}") + print(f" Duration: {q['duration']} minutes") + print(f" Questions: {q['totalQuestions']}") + else: + print("\nāš ļø No quizzes visible to student") + +# Verify the counts match +print("\n" + "-"*70) +print("VERIFICATION") +print("-"*70) + +if len(teacher_quizzes) == len(student_quizzes): + print(f"āœ… Student and teacher see same number of quizzes: {len(student_quizzes)}") +else: + print(f"āŒ Mismatch: Teacher sees {len(teacher_quizzes)}, Student sees {len(student_quizzes)}") + +print("\n" + "="*70) +print("āœ… QUIZ VISIBILITY WORKING CORRECTLY") +print("="*70) +print("\nSummary:") +print(" • Professor can add quiz with just name, description, and date/time") +print(" • Quiz is immediately visible to all students") +print(" • No waiting for 'active window' required") +print(" • Students see all quizzes except those they've already completed") diff --git a/FusionIIIT/applications/online_cms/Tests/test_quiz_visibility.py b/FusionIIIT/applications/online_cms/Tests/test_quiz_visibility.py new file mode 100644 index 000000000..d2c88768d --- /dev/null +++ b/FusionIIIT/applications/online_cms/Tests/test_quiz_visibility.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""Check quiz visibility""" +import requests +import json +from datetime import datetime, timedelta + +BASE_URL = "http://localhost:8000" + +def login(username, password): + response = requests.post( + f"{BASE_URL}/api/auth/login/", + data={"username": username, "password": password} + ) + if response.status_code == 200: + data = response.json() + return data.get("token") or data.get("access") + return None + +print("="*70) +print("QUIZ VISIBILITY TEST") +print("="*70) + +# Login as teacher +teacher_token = login("testteacher", "testteacher123") +if not teacher_token: + print("āœ— Failed to login as teacher") + exit(1) + +# Login as student +student_token = login("student01", "Control d") +if not student_token: + print("āœ— Failed to login as student") + exit(1) + +print(f"\nāœ“ Teacher logged in") +print(f"āœ“ Student logged in") + +# Teacher checks all quizzes (both active and inactive) +print("\n" + "-"*70) +print("TEACHER VIEW: All quizzes (no time filtering)") +print("-"*70) + +headers = {"Authorization": f"Token {teacher_token}"} +response = requests.get( + f"{BASE_URL}/ocms/api/CS101/quizzes/", + headers=headers +) +print(f"Status: {response.status_code}") +if response.status_code == 200: + quizzes = response.json() + print(f"Total quizzes: {len(quizzes)}") + now = datetime.now().replace(tzinfo=None) + print(f"Current time (local): {now.isoformat()}") + for q in quizzes: + start_str = q['startTime'].replace('Z', '+00:00').replace('+00:00', '') + end_str = q['endTime'].replace('Z', '+00:00').replace('+00:00', '') + start = datetime.fromisoformat(start_str) + end = datetime.fromisoformat(end_str) + + print(f"\n Quiz: {q['title']}") + print(f" Start: {q['startTime']}") + print(f" End: {q['endTime']}") + print(f" Status: ", end="") + + if start > now: + print(f"NOT STARTED (in {(start - now).total_seconds() / 3600:.1f} hours)") + elif end < now: + print(f"FINISHED (ended {(now - end).total_seconds() / 3600:.1f} hours ago)") + else: + print("ACTIVE (between start and end)") + +# Student checks quizzes (with time filtering) +print("\n" + "-"*70) +print("STUDENT VIEW: Only active quizzes (with time filtering)") +print("-"*70) + +headers = {"Authorization": f"Token {student_token}"} +response = requests.get( + f"{BASE_URL}/ocms/api/CS101/quizzes/", + headers=headers +) +print(f"Status: {response.status_code}") +if response.status_code == 200: + quizzes = response.json() + print(f"Visible quizzes: {len(quizzes)}") + if quizzes: + for q in quizzes: + print(f"\n āœ“ {q['title']}") + print(f" Start: {q['startTime']}") + print(f" End: {q['endTime']}") + else: + print("\nāš ļø No quizzes visible to student!") + print("\nPossible reasons:") + print("1. All quizzes have start_time in the future") + print("2. All quizzes have end_time in the past") + print("3. Student has already completed all active quizzes") + +print("\n" + "="*70) diff --git a/FusionIIIT/applications/online_cms/Tests/test_student_attendance.py b/FusionIIIT/applications/online_cms/Tests/test_student_attendance.py new file mode 100644 index 000000000..abdcae4c5 --- /dev/null +++ b/FusionIIIT/applications/online_cms/Tests/test_student_attendance.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Test student attendance visibility""" +import requests +import json +from datetime import date + +BASE_URL = "http://localhost:8000" + +def login(username, password): + """Get authentication token""" + response = requests.post( + f"{BASE_URL}/api/auth/login/", + data={"username": username, "password": password} + ) + if response.status_code == 200: + data = response.json() + token = data.get("token") or data.get("access") + if token: + print(f"āœ“ Logged in as {username}: {token[:20]}...") + return token + else: + print(f"āœ— No token in response: {data}") + return None + else: + print(f"āœ— Login failed: {response.status_code}") + return None + +def test_student_attendance(): + """Test student can see their attendance""" + print("\n" + "="*60) + print("Testing Student Attendance Visibility") + print("="*60) + + # Get teacher token to create attendance records + print("\n[Step 1] Teacher marks attendance for student01") + teacher_token = login("testteacher", "testteacher123") + if not teacher_token: + return + + # Teacher posts attendance + headers = {"Authorization": f"Token {teacher_token}"} + payload = { + "date": str(date.today()), + "attendance": [ + {"student_id": "student01", "present": True}, + ] + } + response = requests.post( + f"{BASE_URL}/ocms/api/CS101/attendance/", + headers=headers, + json=payload + ) + print(f"POST attendance status: {response.status_code}") + if response.status_code == 200: + print(f"āœ“ Response: {response.json()}") + else: + print(f"āœ— Error: {response.text}") + + # Get student token + print("\n[Step 2] Student logs in") + student_token = login("student01", "Control d") + if not student_token: + return + + # Student queries attendance + print("\n[Step 3] Student queries their attendance for CS101") + headers = {"Authorization": f"Token {student_token}"} + response = requests.get( + f"{BASE_URL}/ocms/api/CS101/attendance/", + headers=headers + ) + print(f"GET attendance status: {response.status_code}") + print(f"Response: {json.dumps(response.json(), indent=2)}") + + if response.status_code == 200: + data = response.json() + if data: + print(f"\nāœ… SUCCESS! Student can see their attendance records") + today = str(date.today()) + if today in data: + print(f" Attendance for {today}: {data[today]}") + else: + print(f"\nāš ļø No attendance records found for student") + else: + print(f"\nāœ— Error retrieving attendance: {response.text}") + + print("\n" + "="*60) + +if __name__ == "__main__": + test_student_attendance() diff --git a/FusionIIIT/applications/online_cms/serializers.py b/FusionIIIT/applications/online_cms/api/serializers.py similarity index 99% rename from FusionIIIT/applications/online_cms/serializers.py rename to FusionIIIT/applications/online_cms/api/serializers.py index 3978f5637..8904cb726 100644 --- a/FusionIIIT/applications/online_cms/serializers.py +++ b/FusionIIIT/applications/online_cms/api/serializers.py @@ -1,5 +1,5 @@ from rest_framework import serializers -from .models import * +from ..models import * from applications.academic_information.models import Course, Curriculum class CourseSerializer(serializers.ModelSerializer): diff --git a/FusionIIIT/applications/online_cms/api/urls.py b/FusionIIIT/applications/online_cms/api/urls.py new file mode 100644 index 000000000..e57bb2b27 --- /dev/null +++ b/FusionIIIT/applications/online_cms/api/urls.py @@ -0,0 +1,36 @@ +from django.urls import path +from . import views + +urlpatterns = [ + path('courses/', views.ApiCourseList.as_view(), name='api_courses'), + path('/dashboard/', views.ApiCourseDashboard.as_view(), name='api_dashboard'), + + path('/assignments/', views.ApiAssignments.as_view(), name='api_assignments'), + path('/assignments/add/', views.ApiAddAssignment.as_view(), name='api_add_assignment'), + path('/assignments/upload/', views.ApiUploadAssignment.as_view(), name='api_upload_assignment'), + path('/assignments//grade/', views.ApiGradeAssignment.as_view(), name='api_grade_assignment'), + path('/assignments//delete/', views.ApiDeleteAssignment.as_view(), name='api_delete_assignment'), + + path('/documents/', views.ApiDocuments.as_view(), name='api_documents'), + path('/documents/add/', views.ApiAddDocument.as_view(), name='api_add_document'), + path('/documents//delete/', views.ApiDeleteDocument.as_view(), name='api_delete_document'), + + path('/forum/', views.ApiForum.as_view(), name='api_forum'), + path('/forum/new/', views.ApiForumNew.as_view(), name='api_forum_new'), + path('/forum/reply/', views.ApiForumReply.as_view(), name='api_forum_reply'), + path('/forum//remove/', views.ApiForumRemove.as_view(), name='api_forum_remove'), + + path('/quizzes/', views.ApiQuizzes.as_view(), name='api_quizzes'), + path('/quizzes/create/', views.ApiCreateQuiz.as_view(), name='api_create_quiz'), + path('/quizzes//', views.ApiQuizDetail.as_view(), name='api_quiz_detail'), + path('/quizzes//submit/', views.ApiQuizSubmit.as_view(), name='api_quiz_submit'), + path('/quizzes//remove/', views.ApiRemoveQuiz.as_view(), name='api_remove_quiz'), + + path('/attendance/', views.ApiAttendance.as_view(), name='api_attendance'), + path('/attendance/roster/', views.ApiAttendanceRoster.as_view(), name='api_attendance_roster'), + + path('/grading/', views.ApiGrading.as_view(), name='api_grading'), + path('/grading/create/', views.ApiCreateGradingScheme.as_view(), name='api_create_grading'), + path('/grading/evaluate/', views.ApiEvaluate.as_view(), name='api_evaluate'), + path('/grading/student-grades/', views.ApiStudentGrades.as_view(), name='api_student_grades'), +] \ No newline at end of file diff --git a/FusionIIIT/applications/online_cms/views.py b/FusionIIIT/applications/online_cms/api/views.py similarity index 56% rename from FusionIIIT/applications/online_cms/views.py rename to FusionIIIT/applications/online_cms/api/views.py index 6ab08d752..bf11bf9b6 100644 --- a/FusionIIIT/applications/online_cms/views.py +++ b/FusionIIIT/applications/online_cms/api/views.py @@ -4,11 +4,25 @@ from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from django.utils import timezone from django.utils.dateparse import parse_date, parse_datetime -from . import services, models +from django.db import IntegrityError +from datetime import timedelta +import logging +from .. import services, models from applications.academic_information.models import Student, Student_attendance +# Set up logging +logger = logging.getLogger(__name__) + class BaseCourseView(APIView): permission_classes = [IsAuthenticated] + def get_course_or_error(self, request, course_code): + curr = services.get_course_obj(course_code) + if not curr: + return Response({'detail': 'Course not found'}, status=404) + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + return curr + def check_enrollment(self, request, course_code): return services.is_enrolled(request.user, course_code) @@ -25,25 +39,34 @@ def get(self, request): class ApiCourseDashboard(BaseCourseView): def get(self, request, course_code): - if not self.check_enrollment(request, course_code): - return Response({'detail': 'Not enrolled in this course'}, status=403) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr - curr = services.get_course_obj(course_code) - if not curr: - return Response({'detail': 'Course not found'}, status=404) + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + + assignments_count = models.Assignment.objects.filter(course_id=course_obj).count() + documents_count = models.CourseDocuments.objects.filter(course_id=course_obj).count() - assignments_count = models.Assignment.objects.filter(course_id=curr.course_id).count() - documents_count = models.CourseDocuments.objects.filter(course_id=curr.course_id).count() + # Get course name from either old or new system + course_name = getattr(course_obj, 'course_name', None) or getattr(course_obj, 'name', 'Unknown') + course_details = getattr(course_obj, 'course_details', getattr(course_obj, 'syllabus', '')) return Response({ "courseCode": course_code, - "courseName": curr.course_id.course_name, - "courseDetails": curr.course_id.course_details, - "credits": curr.credits, - "semester": curr.sem, - "programme": curr.course_id.program_id.name if curr.course_id.program_id else "", - "branch": curr.course_id.branch_id.name if getattr(curr.course_id, "branch_id", None) else "", - "batch": curr.course_id.batch_id.name if getattr(curr.course_id, "batch_id", None) else "", + "courseName": course_name, + "courseDetails": course_details, + "credits": curr.credits if hasattr(curr, 'credits') else course_obj.credit, + "semester": curr.sem if hasattr(curr, 'sem') else 1, + "programme": "", + "branch": "", + "batch": "", "counts": { "assignments": assignments_count, "documents": documents_count @@ -52,12 +75,19 @@ def get(self, request, course_code): class ApiAssignments(BaseCourseView): def get(self, request, course_code): - if not self.check_enrollment(request, course_code): - return Response({'detail': 'Not enrolled in this course'}, status=403) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr - curr = services.get_course_obj(course_code) - course = curr.course_id - assignments = models.Assignment.objects.filter(course_id=course).order_by('-upload_time') + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + + assignments = models.Assignment.objects.filter(course_id=course_obj).order_by('-upload_time') extra_info, is_student_user = self.get_role_info(request) student_obj = None @@ -112,6 +142,17 @@ def post(self, request, course_code): return Response({'detail': 'Faculty only'}, status=403) curr = services.get_course_obj(course_code) + if not curr: + return Response({'detail': 'Course not found'}, status=404) + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + data = request.data deadline = data.get('deadline') @@ -125,42 +166,53 @@ def post(self, request, course_code): return Response({'detail': 'title is required'}, status=400) if deadline_dt is None: return Response({'detail': 'deadline is required (ISO datetime or YYYY-MM-DD)'}, status=400) - a = models.Assignment.objects.create( - course_id=curr.course_id, + course_id=course_obj, assignment_name=data.get('title'), submit_date=deadline_dt, ) return Response({'id': a.pk}) class ApiUploadAssignment(BaseCourseView): - # Allow both form-data (legacy) and JSON (link submission from new UI) parser_classes = [MultiPartParser, FormParser, JSONParser] + def post(self, request, course_code): if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) if not is_student_user: return Response({'detail': 'Student only'}, status=403) - + assignment_id = request.data.get('assignment_id') submission_link = request.data.get('submission_link') or request.data.get('upload_url') or request.data.get('link') + if not submission_link: return Response({'detail': 'submission_link is required'}, status=400) a = models.Assignment.objects.get(pk=assignment_id) + if timezone.now() > a.submit_date: return Response({'detail': 'Submission deadline has passed'}, status=400) - + student = Student.objects.get(id=extra_info) - sub = models.StudentAssignment.objects.create( + + # šŸ”„ KEY CHANGE: update or create + sub, created = models.StudentAssignment.objects.update_or_create( student_id=student, assignment_id=a, - upload_url=submission_link, - assign_name=a.assignment_name, + defaults={ + 'upload_url': submission_link, + 'assign_name': a.assignment_name, + 'upload_time': timezone.now(), # ensures updated timestamp + } ) - return Response({'id': sub.pk, 'submittedAt': timezone.now().isoformat()}) + return Response({ + 'id': sub.pk, + 'submittedAt': sub.upload_time.isoformat(), + 'message': 'Created' if created else 'Updated' + }) class ApiGradeAssignment(BaseCourseView): def post(self, request, course_code, pk=None): if not self.check_enrollment(request, course_code): @@ -189,11 +241,18 @@ def delete(self, request, course_code, pk): class ApiDocuments(BaseCourseView): def get(self, request, course_code): - if not self.check_enrollment(request, course_code): - return Response({'detail': 'Not enrolled in this course'}, status=403) - - curr = services.get_course_obj(course_code) - docs = models.CourseDocuments.objects.filter(course_id=curr.course_id) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + docs = models.CourseDocuments.objects.filter(course_id=course_obj) res = [] for d in docs: raw_url = (d.document_url or '').strip() if hasattr(d, 'document_url') else '' @@ -228,6 +287,16 @@ def post(self, request, course_code): return Response({'detail': 'Faculty only'}, status=403) curr = services.get_course_obj(course_code) + if not curr: + return Response({'detail': 'Course not found'}, status=404) + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr title = (request.data.get('title') or request.data.get('document_name') or '').strip() description = (request.data.get('description') or '').strip() @@ -250,7 +319,7 @@ def post(self, request, course_code): description = description[:100] doc = models.CourseDocuments.objects.create( - course_id=curr.course_id, + course_id=course_obj, description=description, document_name=title or 'Material', document_url=url, @@ -275,15 +344,23 @@ def delete(self, request, course_code, pk): class ApiForum(BaseCourseView): def get(self, request, course_code): - if not self.check_enrollment(request, course_code): - return Response({'detail': 'Not enrolled in this course'}, status=403) - curr = services.get_course_obj(course_code) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr # Forum rows are messages. ForumReply is an edge table: parent (forum_ques) -> child (forum_reply). - messages = models.Forum.objects.filter(course_id=curr.course_id).select_related('commenter_id', 'commenter_id__user').order_by('comment_time') + messages = models.Forum.objects.filter(course_id=course_obj).select_related('commenter_id', 'commenter_id__user').order_by('comment_time') edges = models.ForumReply.objects.filter( - forum_ques__course_id=curr.course_id, - forum_reply__course_id=curr.course_id, + forum_ques__course_id=course_obj, + forum_reply__course_id=course_obj, ).select_related('forum_ques', 'forum_reply') by_id = {} @@ -337,9 +414,18 @@ def build_node(node_id, seen): class ApiForumNew(BaseCourseView): def post(self, request, course_code): - if not self.check_enrollment(request, course_code): - return Response({'detail': 'Not enrolled in this course'}, status=403) - curr = services.get_course_obj(course_code) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + extra_info, _ = self.get_role_info(request) msg = (request.data.get('message') or request.data.get('question') or request.data.get('comment') or '').strip() @@ -347,7 +433,7 @@ def post(self, request, course_code): return Response({'detail': 'message is required'}, status=400) f = models.Forum.objects.create( - course_id=curr.course_id, + course_id=course_obj, commenter_id=extra_info, comment=msg, ) @@ -407,28 +493,49 @@ def delete(self, request, course_code, pk): class ApiQuizzes(BaseCourseView): def get(self, request, course_code): - if not self.check_enrollment(request, course_code): - return Response({'detail': 'Not enrolled in this course'}, status=403) - curr = services.get_course_obj(course_code) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + extra_info, is_student_user = self.get_role_info(request) - quizzes = models.Quiz.objects.filter(course_id=curr.course_id) + quizzes = models.Quiz.objects.filter(course_id=course_obj) res = [] - now = timezone.now() for q in quizzes: if is_student_user: + # For students: show all quizzes except those already completed student = models.Student.objects.get(id=extra_info) has_finished = models.QuizResult.objects.filter(quiz_id=q, student_id=student, finished=True).exists() - if not (q.start_time <= now <= q.end_time and not has_finished): - continue + if has_finished: + continue # Skip this quiz, student has already completed it + duration_minutes = 0 + if q.start_time and q.end_time: + delta = q.end_time - q.start_time + duration_minutes = int(delta.total_seconds() // 60) + elif hasattr(q, 'd_day') and hasattr(q, 'd_hour') and hasattr(q, 'd_minute'): + try: + duration_minutes = ( + int(q.d_day or 0) * 1440 + int(q.d_hour or 0) * 60 + int(q.d_minute or 0) + ) + except (ValueError, TypeError): + duration_minutes = 0 + res.append({ 'id': q.pk, 'title': getattr(q, 'title', getattr(q, 'quiz_name', '')), 'description': q.description if hasattr(q, 'description') else '', 'startTime': q.start_time.isoformat(), - 'endTime': q.end_time.isoformat(), - 'duration': getattr(q, 'duration', getattr(q, 'd_time', 0)), + 'endTime': q.end_time.isoformat() if q.end_time else None, + 'duration': duration_minutes, 'negativeMarks': getattr(q, 'negative_marks', 0), 'totalQuestions': q.number_of_question if hasattr(q, 'number_of_question') else 0 }) @@ -436,57 +543,107 @@ def get(self, request, course_code): class ApiCreateQuiz(BaseCourseView): def post(self, request, course_code): - if not self.check_enrollment(request, course_code): - return Response({'detail': 'Not enrolled in this course'}, status=403) - extra_info, is_student_user = self.get_role_info(request) - if is_student_user: return Response({'detail': 'Faculty only'}, status=403) + try: + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled in this course'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + return Response({'detail': 'Faculty only'}, status=403) - curr = services.get_course_obj(course_code) - d = request.data - - title = (d.get('title') or '').strip() - if not title: - return Response({'detail': 'title is required'}, status=400) - - start_raw = d.get('start_time') - end_raw = d.get('end_time') - start_dt = parse_datetime(start_raw) if isinstance(start_raw, str) else None - end_dt = parse_datetime(end_raw) if isinstance(end_raw, str) else None - if start_dt is None or end_dt is None: - return Response({'detail': 'start_time and end_time must be ISO datetimes'}, status=400) - - if timezone.is_naive(start_dt): - start_dt = timezone.make_aware(start_dt) - if timezone.is_naive(end_dt): - end_dt = timezone.make_aware(end_dt) - - if end_dt <= start_dt: - return Response({'detail': 'end_time must be after start_time'}, status=400) - - delta = end_dt - start_dt - total_minutes = int(delta.total_seconds() // 60) - days = total_minutes // (60 * 24) - hours = (total_minutes % (60 * 24)) // 60 - minutes = total_minutes % 60 - - q = models.Quiz.objects.create( - course_id=curr.course_id, - quiz_name=title[:20], - start_time=start_dt, - end_time=end_dt, - d_day=str(days).zfill(2), - d_hour=str(hours).zfill(2), - d_minute=str(minutes).zfill(2), - negative_marks=float(d.get('negative_marks') or 0), - description=(d.get('description') or '').strip(), - rules=(d.get('rules') or '').strip(), - ) - return Response({'id': q.pk}) + curr = services.get_course_obj(course_code) + if not curr: + return Response({'detail': 'Course not found'}, status=404) + + # Get the actual Course object + course_obj = curr.course_id if hasattr(curr, 'course_id') else curr + + d = request.data + + title = (d.get('title') or '').strip() + if not title: + return Response({'detail': 'title is required'}, status=400) + + start_raw = d.get('start_time') + duration_raw = d.get('duration') + end_raw = d.get('end_time') + start_dt = parse_datetime(start_raw) if isinstance(start_raw, str) else None + end_dt = None + + if start_dt is None: + return Response({'detail': 'start_time must be an ISO datetime'}, status=400) + + if timezone.is_naive(start_dt): + start_dt = timezone.make_aware(start_dt) + + if duration_raw is not None: + try: + duration_val = int(duration_raw) + except (ValueError, TypeError): + return Response({'detail': 'duration must be an integer (minutes)'}, status=400) + + if duration_val <= 0: + return Response({'detail': 'duration must be a positive number'}, status=400) + + end_dt = start_dt + timedelta(minutes=duration_val) + elif isinstance(end_raw, str): + end_dt = parse_datetime(end_raw) + if end_dt is None: + return Response({'detail': 'end_time must be an ISO datetime when provided'}, status=400) + if timezone.is_naive(end_dt): + end_dt = timezone.make_aware(end_dt) + else: + return Response({'detail': 'Either duration or end_time must be provided'}, status=400) + + if end_dt <= start_dt: + return Response({'detail': 'end_time must be after start_time'}, status=400) + + delta = end_dt - start_dt + total_minutes = int(delta.total_seconds() // 60) + days = total_minutes // (60 * 24) + hours = (total_minutes % (60 * 24)) // 60 + minutes = total_minutes % 60 + + q = models.Quiz.objects.create( + course_id=course_obj, + quiz_name=title[:20], + start_time=start_dt, + end_time=end_dt, + d_day=str(days).zfill(2), + d_hour=str(hours).zfill(2), + d_minute=str(minutes).zfill(2), + negative_marks=float(d.get('negative_marks') or 0), + description=(d.get('description') or '').strip(), + rules=(d.get('rules') or '').strip(), + ) + return Response({ + 'id': q.pk, + 'title': q.quiz_name, + 'description': q.description, + 'startTime': q.start_time.isoformat(), + 'endTime': q.end_time.isoformat(), + 'negativeMarks': q.negative_marks, + }) + except IntegrityError as e: + logger.error(f"Database integrity error creating quiz: {e}") + return Response({'detail': 'Database error occurred'}, status=500) + except Exception as e: + logger.error(f"Error creating quiz: {e}") + return Response({'detail': 'An error occurred while creating the quiz'}, status=500) class ApiQuizDetail(BaseCourseView): def get(self, request, course_code, quiz_id): - if not self.check_enrollment(request, course_code): - return Response({'detail': 'Not enrolled in this course'}, status=403) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + q = models.Quiz.objects.get(pk=quiz_id) extra_info, is_student_user = self.get_role_info(request) if is_student_user: @@ -523,6 +680,18 @@ def post(self, request, course_code, quiz_id): extra_info, is_student_user = self.get_role_info(request) if not is_student_user: return Response({'detail': 'Student only'}, status=403) + curr = services.get_course_obj(course_code) + if not curr: + return Response({'detail': 'Course not found'}, status=404) + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + q = models.Quiz.objects.get(pk=quiz_id) if timezone.now() > q.end_time: return Response({'detail': 'Quiz has ended'}, status=403) @@ -561,33 +730,115 @@ def post(self, request, course_code, quiz_id): class ApiRemoveQuiz(BaseCourseView): def delete(self, request, course_code, quiz_id): - if not self.check_enrollment(request, course_code): - return Response({'detail': 'Not enrolled' }, status=403) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr extra_info, is_student_user = self.get_role_info(request) if is_student_user: return Response({'detail': 'Faculty only'}, status=403) + + curr = services.get_course_obj(course_code) + if not curr: + return Response({'detail': 'Course not found'}, status=404) + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + models.Quiz.objects.filter(pk=quiz_id).delete() return Response(status=204) class ApiAttendance(BaseCourseView): def get(self, request, course_code): - if not self.check_enrollment(request, course_code): - return Response({'detail': 'Not enrolled'}, status=403) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr + curr = services.get_course_obj(course_code) + if not curr: + return Response({'detail': 'Course not found'}, status=404) + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr extra_info, is_student_user = self.get_role_info(request) if is_student_user: - student = Student.objects.get(id=extra_info) - recs = Student_attendance.objects.filter( - instructor_id__curriculum_id__course_code=course_code, - student_id=student, + # For students, try both new and old system attendance records + from applications.academic_information.models import CourseAttendance + + student_username = extra_info.user.username if hasattr(extra_info, 'user') else str(extra_info) + logger.info(f"[Student Attendance] User: {request.user.username}, Course: {course_code}, StudentUsername: {student_username}") + + # Try new system first + recs_new = CourseAttendance.objects.filter( + course_code=course_code, + student_id=student_username ).order_by('date') - return Response([{'date': r.date.isoformat(), 'present': r.present} for r in recs]) + logger.info(f"[Student Attendance] New system records found: {recs_new.count()}") + + if recs_new.exists(): + # Found in new system + res = {} + for r in recs_new: + d = r.date.isoformat() + if d not in res: + res[d] = [] + res[d].append({ + 'present': r.present, + }) + return Response(res) + else: + # Try old system + try: + student = Student.objects.get(id=extra_info) + recs_old = Student_attendance.objects.filter( + instructor_id__curriculum_id__course_code=course_code, + student_id=student, + ).order_by('date') + logger.info(f"[Student Attendance] Old system records found: {recs_old.count()}") + return Response([{'date': r.date.isoformat(), 'present': r.present} for r in recs_old]) + except Student.DoesNotExist: + logger.warning(f"[Student Attendance] Student not found for {request.user.username}") + return Response([]) # faculty link = services.get_instructor_link(extra_info, course_code) if not link: return Response({'detail': 'Not an instructor for this course'}, status=403) + # Check if this is a MockInstructorLink (from new system) + if hasattr(link, '__class__') and 'MockInstructorLink' in str(link.__class__): + # New system - use CourseAttendance model + from applications.academic_information.models import CourseAttendance + + # Get instructor user ID + instructor_user_id = extra_info if isinstance(extra_info, int) else extra_info.user.id if hasattr(extra_info, 'user') else extra_info + + recs = CourseAttendance.objects.filter( + course_code=course_code, + instructor_id=instructor_user_id + ).order_by('date') + res = {} + for r in recs: + d = r.date.isoformat() + if d not in res: + res[d] = [] + res[d].append({ + 'student_id': r.student_id, + 'name': r.student_id, + 'present': r.present, + }) + return Response(res) + + # Old system recs = Student_attendance.objects.filter(instructor_id=link).select_related( 'student_id', 'student_id__id', 'student_id__id__user' ).order_by('date') @@ -605,12 +856,25 @@ def get(self, request, course_code): return Response(res) def post(self, request, course_code): - if not self.check_enrollment(request, course_code): - return Response({'detail': 'Not enrolled'}, status=403) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr extra_info, is_student_user = self.get_role_info(request) if is_student_user: return Response({'detail': 'Faculty only'}, status=403) + curr = services.get_course_obj(course_code) + if not curr: + return Response({'detail': 'Course not found'}, status=404) + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + link = services.get_instructor_link(extra_info, course_code) if not link: return Response({'detail': 'Not an instructor for this course'}, status=403) @@ -624,53 +888,106 @@ def post(self, request, course_code): if not isinstance(atts, list): return Response({'detail': 'attendance must be a list'}, status=400) + # Check if this is a MockInstructorLink (from new system) + is_new_system = hasattr(link, '__class__') and 'MockInstructorLink' in str(link.__class__) + + # Get instructor user ID + if is_new_system: + instructor_user_id = extra_info if isinstance(extra_info, int) else extra_info.user.id if hasattr(extra_info, 'user') else extra_info + count = 0 for att in atts: sid = att.get('student_id') present = bool(att.get('present')) if not sid: continue - student = Student.objects.filter(id__user__username=sid).first() - if not student: - continue - rec, _ = Student_attendance.objects.get_or_create( - instructor_id=link, - student_id=student, - date=dt, - ) - rec.present = present - rec.save() - count += 1 + + if is_new_system: + # New system - use CourseAttendance model + from applications.academic_information.models import CourseAttendance + rec, _ = CourseAttendance.objects.get_or_create( + student_id=sid, + course_code=course_code, + instructor_id=instructor_user_id, + date=dt, + ) + rec.present = present + rec.save() + count += 1 + else: + # Old system + student = Student.objects.filter(id__user__username=sid).first() + if not student: + continue + rec, _ = Student_attendance.objects.get_or_create( + instructor_id=link, + student_id=student, + date=dt, + ) + rec.present = present + rec.save() + count += 1 return Response({'status': 'saved', 'count': count}) class ApiAttendanceRoster(BaseCourseView): def get(self, request, course_code): - if not self.check_enrollment(request, course_code): - return Response({'detail': 'Not enrolled'}, status=403) - extra_info, is_student_user = self.get_role_info(request) - if is_student_user: - return Response({'detail': 'Faculty only'}, status=403) - - link = services.get_instructor_link(extra_info, course_code) - if not link: - return Response({'detail': 'Not an instructor for this course'}, status=403) - - roster = services.get_course_roster(course_code) - return Response(roster) + try: + if not self.check_enrollment(request, course_code): + return Response({'detail': 'Not enrolled'}, status=403) + extra_info, is_student_user = self.get_role_info(request) + if is_student_user: + return Response({'detail': 'Faculty only'}, status=403) + curr = services.get_course_obj(course_code) + if not curr: + return Response({'detail': 'Course not found'}, status=404) + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + + link = services.get_instructor_link(extra_info, course_code) + if not link: + return Response({'detail': 'Not an instructor for this course'}, status=403) + + roster = services.get_course_roster(course_code) + if not roster: + logger.warning(f"Empty roster for course {course_code}, instructor {extra_info}") + return Response([], safe=True) + return Response(roster) + except Exception as e: + logger.error(f"Error getting attendance roster for course {course_code}: {e}") + return Response({'detail': 'An error occurred while fetching the roster'}, status=500) class ApiQuestionBank(BaseCourseView): def get(self, request, course_code): - if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr extra_info, is_student_user = self.get_role_info(request) if is_student_user: return Response({'detail': 'Faculty only'}, status=403) curr = services.get_course_obj(course_code) + if not curr: + return Response({'detail': 'Course not found'}, status=404) + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + # Using a dummy implementation or empty list if no models exist for QB since it wasn't specified completely in models # Assuming we might have QuestionBank models if not we send empty array try: - banks = models.QuestionBank.objects.filter(course_id=curr.course_id) + banks = models.QuestionBank.objects.filter(course_id=course_obj) return Response([{'id': b.pk, 'title': b.name} for b in banks]) except: return Response([]) @@ -689,11 +1006,21 @@ def post(self, request, course_code, bank_id, topic_id): class ApiGrading(BaseCourseView): def get(self, request, course_code): - if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) - curr = services.get_course_obj(course_code) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + extra_info, is_student_user = self.get_role_info(request) - schemes = models.GradingScheme.objects.filter(course_id=curr.course_id) + schemes = models.GradingScheme.objects.filter(course_id=course_obj) if is_student_user: student = models.Student.objects.get(id=extra_info) evals = models.StudentEvaluation.objects.filter(student=student, scheme__in=schemes) @@ -708,19 +1035,32 @@ def get(self, request, course_code): class ApiCreateGradingScheme(BaseCourseView): def post(self, request, course_code): - if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr extra_info, is_student_user = self.get_role_info(request) if is_student_user: return Response({'detail': 'Faculty only'}, status=403) curr = services.get_course_obj(course_code) + if not curr: + return Response({'detail': 'Course not found'}, status=404) + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + w = float(request.data.get('weightage', 0)) - schemes = models.GradingScheme.objects.filter(course_id=curr.course_id) + schemes = models.GradingScheme.objects.filter(course_id=course_obj) total = sum([s.weightage for s in schemes]) if total + w > 100: return Response({'detail': 'Total weightage cannot exceed 100'}, status=400) gs = models.GradingScheme.objects.create( - course_id=curr.course_id, + course_id=course_obj, component=request.data.get('component'), weightage=w, max_marks=float(request.data.get('max_marks', 0)) @@ -729,7 +1069,9 @@ def post(self, request, course_code): class ApiEvaluate(BaseCourseView): def post(self, request, course_code): - if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr extra_info, is_student_user = self.get_role_info(request) if is_student_user: return Response({'detail': 'Faculty only'}, status=403) @@ -746,13 +1088,26 @@ def post(self, request, course_code): class ApiStudentGrades(BaseCourseView): def get(self, request, course_code): - if not self.check_enrollment(request, course_code): return Response({'detail': 'Not enrolled'}, status=403) + curr = self.get_course_or_error(request, course_code) + if isinstance(curr, Response): + return curr extra_info, is_student_user = self.get_role_info(request) if not is_student_user: return Response({'detail': 'Student only'}, status=403) curr = services.get_course_obj(course_code) + if not curr: + return Response({'detail': 'Course not found'}, status=404) + + # Get the actual Course object - handle both old and new systems consistently + if hasattr(curr, 'course_id'): + # This is from the old curriculum system or MockCurriculum + course_obj = curr.course_id + else: + # This is a direct Course object from new system + course_obj = curr + student = models.Student.objects.get(id=extra_info) - schemes = models.GradingScheme.objects.filter(course_id=curr.course_id) + schemes = models.GradingScheme.objects.filter(course_id=course_obj) res = [] total_w = 0 diff --git a/FusionIIIT/applications/online_cms/management/commands/create_test_users.py b/FusionIIIT/applications/online_cms/management/commands/create_test_users.py new file mode 100644 index 000000000..76fb02fb5 --- /dev/null +++ b/FusionIIIT/applications/online_cms/management/commands/create_test_users.py @@ -0,0 +1,262 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth.models import User +from applications.globals.models import ExtraInfo, DepartmentInfo +from applications.academic_information.models import Course, Student, Curriculum +from applications.academic_procedures.models import Register +from applications.programme_curriculum.models import CourseInstructor, Batch, Discipline, Programme +from datetime import datetime, date +import random + +class Command(BaseCommand): + help = 'Create test student and teacher users with course assignments for testing' + + def handle(self, *args, **options): + self.stdout.write(self.style.SUCCESS('šŸš€ Setting up test data...')) + + # Get or create department + try: + department = DepartmentInfo.objects.first() + if not department: + self.stdout.write(self.style.WARNING('āš ļø No department found, creating a test department...')) + department = DepartmentInfo.objects.create(name='Computer Science', code='CS') + except: + department = None + + # Get or create discipline + try: + discipline = Discipline.objects.first() + if not discipline: + self.stdout.write(self.style.WARNING('āš ļø No discipline found, creating a test discipline...')) + discipline = Discipline.objects.create(name='Computer Science', acronym='CS') + except Exception as e: + self.stdout.write(self.style.ERROR(f'āŒ Error with discipline: {e}')) + discipline = None + + # Get or create batch + try: + batch = Batch.objects.first() + if not batch: + self.stdout.write(self.style.WARNING('āš ļø No batch found, creating a test batch...')) + if discipline: + batch = Batch.objects.create(name='BTech', discipline=discipline, year=2024) + else: + self.stdout.write(self.style.ERROR('āŒ Cannot create batch without discipline')) + batch = None + except Exception as e: + self.stdout.write(self.style.ERROR(f'āŒ Error with batch: {e}')) + batch = None + + # Create or get test courses + courses = self._create_test_courses(batch, discipline, department) + + # Create student user and assign courses + student = self._create_student_user(department, batch, courses) + + # Create teacher user and assign courses + teacher = self._create_teacher_user(department, courses, batch) + + self.stdout.write(self.style.SUCCESS('\nāœ… Test data setup complete!')) + self._print_summary(student, teacher, courses) + + def _create_test_courses(self, batch, discipline, department): + """Create or retrieve test courses""" + self.stdout.write('\nšŸ“š Setting up test courses...') + + course_codes = [] + course_data = [ + ('CS101', 'Introduction to Python', 'Learn Python basics'), + ('CS102', 'Web Development', 'Build web applications with Django'), + ] + + for course_code, course_name, details in course_data: + try: + course, created = Course.objects.get_or_create( + course_name=course_name, + defaults={'course_details': details} + ) + if created: + self.stdout.write(f' āœ… Created course: {course_name}') + else: + self.stdout.write(f' ā„¹ļø Found existing course: {course_name}') + + # Create curriculum if needed + try: + curriculum, _ = Curriculum.objects.get_or_create( + course_code=course_code, + batch=batch.year if batch else 2024, + programme='BTech' if batch else 'BTech', + defaults={ + 'course_id': course, + 'sem': 1, + 'credits': 3, + 'course_type': 'Professional Core', + 'branch': 'CSE', + } + ) + self.stdout.write(f' āœ… Curriculum ready for: {course_code}') + course_codes.append(course_code) # Just store the code, fetch later + except Exception as e: + self.stdout.write(f' āš ļø Error with curriculum {course_code}: {e}') + except Exception as e: + self.stdout.write(f' āš ļø Error creating course {course_code}: {e}') + + return course_codes + + def _create_student_user(self, department, batch, course_codes): + """Create a test student user and register in courses""" + self.stdout.write('\nšŸ‘Øā€šŸŽ“ Creating test student user...') + + username = 'teststudent' + email = 'teststudent@iiitdmj.ac.in' + + try: + # Create or get Django user + user, created = User.objects.get_or_create( + username=username, + defaults={ + 'email': email, + 'first_name': 'Test', + 'last_name': 'Student', + } + ) + + if created: + user.set_password('password123') + user.save() + self.stdout.write(f' āœ… Created user: {username}') + else: + self.stdout.write(f' ā„¹ļø Found existing user: {username}') + + # Create or update ExtraInfo + extra_info, _ = ExtraInfo.objects.get_or_create( + id=username, + defaults={ + 'user': user, + 'title': 'Mr.', + 'sex': 'M', + 'date_of_birth': date(2000, 1, 1), + 'user_status': 'PRESENT', + 'phone_no': 9876543210, + 'user_type': 'Student', + 'department': department, + } + ) + self.stdout.write(f' āœ… Created ExtraInfo for: {username}') + + # Create or get Student record + student, _ = Student.objects.get_or_create( + id=extra_info, + defaults={ + 'programme': 'BTech', + 'batch': 2024, + 'cpi': 7.5, + 'category': 'General', + 'curr_semester_no': 1, + 'batch_id': batch, + } + ) + self.stdout.write(f' āœ… Created Student record for: {username}') + + # Register student in courses + for code in course_codes: + try: + curriculum = Curriculum.objects.get(course_code=code) + register, created = Register.objects.get_or_create( + curr_id=curriculum, + student_id=student, + defaults={ + 'year': 2024, + 'semester': 1, + } + ) + if created: + self.stdout.write(f' āœ… Registered student in course: {code}') + else: + self.stdout.write(f' ā„¹ļø Student already registered in course: {code}') + except Exception as e: + self.stdout.write(f' āš ļø Error registering in {code}: {e}') + + return student + + except Exception as e: + self.stdout.write(self.style.ERROR(f'āŒ Error creating student: {e}')) + return None + + def _create_teacher_user(self, department, course_codes, batch): + """Create a test teacher/instructor user and assign courses""" + self.stdout.write('\nšŸ‘Øā€šŸ« Creating test teacher user...') + + username = 'testteacher' + email = 'testteacher@iiitdmj.ac.in' + + try: + # Create or get Django user + user, created = User.objects.get_or_create( + username=username, + defaults={ + 'email': email, + 'first_name': 'Test', + 'last_name': 'Teacher', + } + ) + + if created: + user.set_password('password123') + user.save() + self.stdout.write(f' āœ… Created user: {username}') + else: + self.stdout.write(f' ā„¹ļø Found existing user: {username}') + + # Create or update ExtraInfo + extra_info, _ = ExtraInfo.objects.get_or_create( + id=username, + defaults={ + 'user': user, + 'title': 'Dr.', + 'sex': 'M', + 'date_of_birth': date(1980, 5, 15), + 'user_status': 'PRESENT', + 'phone_no': 9123456789, + 'user_type': 'Faculty', + 'department': department, + } + ) + self.stdout.write(f' āœ… Created ExtraInfo for: {username}') + + # Note: Instructor course assignment may require additional manual setup + # The users and courses are created successfully above + self.stdout.write(f' ā„¹ļø Note: Instructor course assignment may require additional configuration') + + return extra_info + + except Exception as e: + self.stdout.write(self.style.ERROR(f'āŒ Error creating teacher: {e}')) + return None + + def _print_summary(self, student, teacher, course_codes): + """Print summary of created test data""" + self.stdout.write(self.style.SUCCESS('\n' + '='*60)) + self.stdout.write(self.style.SUCCESS('TEST DATA SUMMARY')) + self.stdout.write(self.style.SUCCESS('='*60 + '\n')) + + self.stdout.write(self.style.HTTP_INFO('šŸ“ Student User:')) + self.stdout.write(f' Username: teststudent') + self.stdout.write(f' Password: password123') + self.stdout.write(f' Email: teststudent@iiitdmj.ac.in') + + self.stdout.write(self.style.HTTP_INFO('\nšŸ“ Teacher User:')) + self.stdout.write(f' Username: testteacher') + self.stdout.write(f' Password: password123') + self.stdout.write(f' Email: testteacher@iiitdmj.ac.in') + + self.stdout.write(self.style.HTTP_INFO('\nšŸ“š Courses:')) + for code in course_codes: + try: + curriculum = Curriculum.objects.get(course_code=code) + self.stdout.write(f' {code}: {curriculum.course_id.course_name}') + except: + self.stdout.write(f' {code}: (details not available)') + + self.stdout.write(self.style.SUCCESS('\n' + '='*60)) + self.stdout.write(self.style.SUCCESS('šŸŽÆ Ready to test APIs!')) + self.stdout.write(self.style.SUCCESS('='*60 + '\n')) diff --git a/FusionIIIT/applications/online_cms/migrations/0004_auto_20260326_1652.py b/FusionIIIT/applications/online_cms/migrations/0004_auto_20260326_1652.py new file mode 100644 index 000000000..6f2a9c512 --- /dev/null +++ b/FusionIIIT/applications/online_cms/migrations/0004_auto_20260326_1652.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1.5 on 2026-03-26 16:52 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('online_cms', '0003_alter_coursedocuments_document_url'), + ] + + operations = [ + migrations.AddField( + model_name='quiz', + name='announced', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='quiz', + name='announced_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='quiz', + name='announcement', + field=models.TextField(blank=True, max_length=2000, null=True), + ), + ] diff --git a/FusionIIIT/applications/online_cms/migrations/0005_auto_20260326_1937.py b/FusionIIIT/applications/online_cms/migrations/0005_auto_20260326_1937.py new file mode 100644 index 000000000..35779a869 --- /dev/null +++ b/FusionIIIT/applications/online_cms/migrations/0005_auto_20260326_1937.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-26 14:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('online_cms', '0004_auto_20260326_1652'), + ] + + operations = [ + migrations.AlterField( + model_name='quiz', + name='end_time', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/FusionIIIT/applications/online_cms/migrations/0006_auto_20260413_1545.py b/FusionIIIT/applications/online_cms/migrations/0006_auto_20260413_1545.py new file mode 100644 index 000000000..bb6274c5e --- /dev/null +++ b/FusionIIIT/applications/online_cms/migrations/0006_auto_20260413_1545.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1.5 on 2026-04-13 10:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('academic_information', '0002_courseattendance'), + ('online_cms', '0005_auto_20260326_1937'), + ] + + operations = [ + migrations.CreateModel( + name='CourseModule', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('module_name', models.CharField(max_length=100)), + ('description', models.TextField(blank=True, max_length=500)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('order', models.PositiveIntegerField(default=0)), + ('course_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='academic_information.course')), + ], + options={ + 'ordering': ['order', 'created_at'], + }, + ), + migrations.AddField( + model_name='coursedocuments', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='online_cms.coursemodule'), + ), + ] diff --git a/FusionIIIT/applications/online_cms/models.py b/FusionIIIT/applications/online_cms/models.py index 0841326c7..048fff87b 100644 --- a/FusionIIIT/applications/online_cms/models.py +++ b/FusionIIIT/applications/online_cms/models.py @@ -4,9 +4,24 @@ from applications.academic_procedures.models import Register from applications.globals.models import ExtraInfo +#course modules created by faculty to organize content +class CourseModule(models.Model): + course_id = models.ForeignKey(Course, on_delete=models.CASCADE) + module_name = models.CharField(max_length=100) + description = models.TextField(max_length=500, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + order = models.PositiveIntegerField(default=0) + + class Meta: + ordering = ['order', 'created_at'] + + def __str__(self): + return '{} - {}'.format(self.course_id, self.module_name) + #the documents in the course (slides , ppt) added by the faculty and can be downloaded by the students class CourseDocuments(models.Model): course_id = models.ForeignKey(Course, on_delete=models.CASCADE) + module = models.ForeignKey(CourseModule, on_delete=models.CASCADE, null=True, blank=True) upload_time = models.DateTimeField(auto_now=True) description = models.CharField(max_length=100) document_name = models.CharField(max_length=40) @@ -67,7 +82,7 @@ class Question(models.Model): class Quiz(models.Model): course_id = models.ForeignKey(Course, on_delete=models.CASCADE) quiz_name = models.CharField(max_length=20) - end_time = models.DateTimeField() + end_time = models.DateTimeField(null=True, blank=True) start_time = models.DateTimeField() d_day = models.CharField(max_length=2) d_hour = models.CharField(max_length=2) @@ -77,6 +92,9 @@ class Quiz(models.Model): description = models.TextField(max_length=1000) rules = models.TextField(max_length=2000) total_score = models.IntegerField(default=0) + announcement = models.TextField(max_length=2000, null=True, blank=True) # Quiz announcement text + announced_at = models.DateTimeField(null=True, blank=True) # Timestamp when quiz was announced + announced = models.BooleanField(default=False) # Flag to track if quiz has been announced def __str__(self): return '{} - {} - {} - {} - {}'.format( @@ -121,9 +139,9 @@ class PracticeQuestion(models.Model): def __str__(self): return '{} - {} - {} - {} - {} - {} - {} - {} - {}'.format( - self.pk, self.quiz_id, self.options1, + self.pk, self.prac_quiz_id, self.options1, self.options2, self.options3, self.options4, - self.options5, self.answer, self.announcement) + self.options5, self.answer, self.question) #answer given by the student in the quiz is stored here to check properly the answers class StudentAnswer(models.Model): diff --git a/FusionIIIT/applications/online_cms/services.py b/FusionIIIT/applications/online_cms/services.py index 4428a3002..ec6922267 100644 --- a/FusionIIIT/applications/online_cms/services.py +++ b/FusionIIIT/applications/online_cms/services.py @@ -1,5 +1,6 @@ from applications.academic_information.models import Curriculum, Curriculum_Instructor, Student, Course, Student_attendance -from applications.academic_procedures.models import Register +from applications.academic_procedures.models import Register, course_registration +from applications.programme_curriculum.models import CourseInstructor from applications.globals.models import ExtraInfo from .models import (Assignment, StudentAssignment, CourseDocuments, Forum, ForumReply, Quiz, QuizQuestion, StudentAnswer, @@ -20,15 +21,53 @@ def get_courses_for_user(user): seen = set() result = [] if student: + # Check both old and new enrollment systems + # Old system: Register table registers = Register.objects.filter(student_id=student).select_related( 'curr_id', 'curr_id__course_id').order_by('curr_id__course_code') curriculums = [r.curr_id for r in registers] + + # New system: course_registration table + course_regs = course_registration.objects.filter( + student_id=student + ).select_related('course_id').order_by('course_id__code') + + for reg in course_regs: + course_code = reg.course_id.code + if course_code not in seen: + seen.add(course_code) + result.append({ + 'courseCode': course_code, + 'courseName': reg.course_id.name, + 'semester': reg.semester_id.semester_type if reg.semester_id else '1', + 'credits': reg.course_id.credit, + }) else: + # For instructors, check both old and new systems + # Old system: Curriculum_Instructor table instructor_links = Curriculum_Instructor.objects.filter( instructor_id=extra_info).select_related( 'curriculum_id', 'curriculum_id__course_id').order_by( 'curriculum_id__course_code') curriculums = [link.curriculum_id for link in instructor_links] + + # New system: CourseInstructor table + course_instructors = CourseInstructor.objects.filter( + instructor_id=extra_info + ).select_related('course_id').order_by('course_id__code') + + for ci in course_instructors: + course_code = ci.course_id.code + if course_code not in seen: + seen.add(course_code) + result.append({ + 'courseCode': course_code, + 'courseName': ci.course_id.name, + 'semester': ci.semester_id.semester_type if hasattr(ci, 'semester_id') and ci.semester_id else '1' '1', + 'credits': ci.course_id.credit, + }) + + # Add courses from old curriculum system (avoiding duplicates) for curr in curriculums: if not curr or curr.course_code in seen: continue @@ -42,9 +81,26 @@ def get_courses_for_user(user): return result def get_course_obj(course_code): + # First try the old Curriculum system curr = Curriculum.objects.select_related('course_id').filter( course_code=course_code).first() - return curr + if curr: + return curr + + # If not found in Curriculum, try the new programme_curriculum Course system + from applications.programme_curriculum.models import Course as ProgrammeCourse + try: + programme_course = ProgrammeCourse.objects.get(code=course_code) + # Create a mock curriculum object for compatibility + class MockCurriculum: + def __init__(self, course): + self.course_id = course + self.course_code = course.code + self.credits = course.credit + self.sem = 1 # Default semester + return MockCurriculum(programme_course) + except ProgrammeCourse.DoesNotExist: + return None def is_enrolled(user, course_code): extra_info = get_extra_info(user) @@ -52,12 +108,43 @@ def is_enrolled(user, course_code): return False student = Student.objects.filter(id=extra_info).first() if student: - return Register.objects.filter( + # Check both old and new enrollment systems + # Old system: Register table + if Register.objects.filter( student_id=student, - curr_id__course_code=course_code).exists() - return Curriculum_Instructor.objects.filter( + curr_id__course_code=course_code).exists(): + return True + + # New system: course_registration table + from applications.programme_curriculum.models import Course + try: + course = Course.objects.get(code=course_code) + return course_registration.objects.filter( + student_id=student, + course_id=course).exists() + except Course.DoesNotExist: + pass + + return False + + # For instructors, check both systems + # Old system: Curriculum_Instructor table + if Curriculum_Instructor.objects.filter( instructor_id=extra_info, - curriculum_id__course_code=course_code).exists() + curriculum_id__course_code=course_code).exists(): + return True + + # New system: CourseInstructor table + from applications.programme_curriculum.models import Course + try: + course = Course.objects.get(code=course_code) + return CourseInstructor.objects.filter( + instructor_id=extra_info, + course_id=course).exists() + except Course.DoesNotExist: + pass + + return False def get_course_roster(course_code): @@ -66,29 +153,87 @@ def get_course_roster(course_code): Output items: { "student_id": "23BCS001", "name": "Full Name" } """ + res = [] + seen = set() + + # Check old enrollment system (Register table) regs = Register.objects.filter(curr_id__course_code=course_code).select_related( 'student_id', 'student_id__id', 'student_id__id__user' ) - res = [] - seen = set() for r in regs: s = r.student_id if not s or not getattr(s, 'id', None) or not getattr(s.id, 'user', None): continue username = s.id.user.username - if username in seen: - continue - seen.add(username) - res.append({ - 'student_id': username, - 'name': s.id.user.get_full_name() or username, - }) + if username not in seen: + seen.add(username) + res.append({ + 'student_id': username, + 'name': s.id.user.get_full_name() or username, + }) + + # Check new enrollment system (course_registration table) + from applications.programme_curriculum.models import Course + try: + course = Course.objects.get(code=course_code) + course_regs = course_registration.objects.filter( + course_id=course + ).select_related('student_id', 'student_id__id', 'student_id__id__user') + + for cr in course_regs: + s = cr.student_id + if not s or not getattr(s, 'id', None) or not getattr(s.id, 'user', None): + continue + username = s.id.user.username + if username not in seen: + seen.add(username) + res.append({ + 'student_id': username, + 'name': s.id.user.get_full_name() or username, + }) + except Course.DoesNotExist: + pass + return res def get_instructor_link(extra_info, course_code): - """Return Curriculum_Instructor row for this faculty+course_code (or None).""" - return Curriculum_Instructor.objects.filter( + """Return instructor link for this faculty+course_code (or None). + + Checks both old Curriculum_Instructor table and new CourseInstructor table. + """ + # Check old system first + old_link = Curriculum_Instructor.objects.filter( instructor_id=extra_info, curriculum_id__course_code=course_code, ).select_related('curriculum_id').first() + if old_link: + return old_link + + # Check new system + from applications.programme_curriculum.models import Course + try: + course = Course.objects.get(code=course_code) + new_link = CourseInstructor.objects.filter( + instructor_id=extra_info, + course_id=course + ).select_related('course_id').first() + if new_link: + # Create a mock object for compatibility with old system + class MockInstructorLink: + def __init__(self, course_instructor): + self.instructor_id = course_instructor.instructor_id + self.curriculum_id = MockCurriculum(course_instructor.course_id) + + class MockCurriculum: + def __init__(self, course): + self.course_id = course + self.course_code = course.code + self.credits = course.credit + self.sem = 1 # Default semester + + return MockInstructorLink(new_link) + except Course.DoesNotExist: + pass + + return None diff --git a/FusionIIIT/applications/online_cms/urls.py b/FusionIIIT/applications/online_cms/urls.py deleted file mode 100644 index d249119cf..000000000 --- a/FusionIIIT/applications/online_cms/urls.py +++ /dev/null @@ -1,36 +0,0 @@ -from django.urls import path -from . import views - -app_name = 'online_cms' - -urlpatterns = [ - path('api/courses/', views.ApiCourseList.as_view(), name='api_courses'), - path('api//dashboard/', views.ApiCourseDashboard.as_view(), name='api_dashboard'), - path('api//assignments/', views.ApiAssignments.as_view(), name='api_assignments'), - path('api//assignments/add/', views.ApiAddAssignment.as_view(), name='api_add_assignment'), - path('api//assignments/upload/', views.ApiUploadAssignment.as_view(), name='api_upload_assignment'), - path('api//assignments//grade/', views.ApiGradeAssignment.as_view(), name='api_grade_assignment'), - path('api//assignments//delete/', views.ApiDeleteAssignment.as_view(), name='api_delete_assignment'), - path('api//documents/', views.ApiDocuments.as_view(), name='api_documents'), - path('api//documents/add/', views.ApiAddDocument.as_view(), name='api_add_document'), - path('api//documents//delete/', views.ApiDeleteDocument.as_view(), name='api_delete_document'), - path('api//forum/', views.ApiForum.as_view(), name='api_forum'), - path('api//forum/new/', views.ApiForumNew.as_view(), name='api_forum_new'), - path('api//forum/reply/', views.ApiForumReply.as_view(), name='api_forum_reply'), - path('api//forum//remove/', views.ApiForumRemove.as_view(), name='api_forum_remove'), - path('api//quizzes/', views.ApiQuizzes.as_view(), name='api_quizzes'), - path('api//quizzes/create/', views.ApiCreateQuiz.as_view(), name='api_create_quiz'), - path('api//quizzes//', views.ApiQuizDetail.as_view(), name='api_quiz_detail'), - path('api//quizzes//submit/', views.ApiQuizSubmit.as_view(), name='api_quiz_submit'), - path('api//quizzes//remove/', views.ApiRemoveQuiz.as_view(), name='api_remove_quiz'), - path('api//attendance/', views.ApiAttendance.as_view(), name='api_attendance'), - path('api//attendance/roster/', views.ApiAttendanceRoster.as_view(), name='api_attendance_roster'), - path('api//questionbank/', views.ApiQuestionBank.as_view(), name='api_questionbank'), - path('api//questionbank/create/', views.ApiCreateBank.as_view(), name='api_create_bank'), - path('api//questionbank//topic/add/', views.ApiAddTopic.as_view(), name='api_add_topic'), - path('api//questionbank//topic//question/add/', views.ApiAddQuestion.as_view(), name='api_add_question'), - path('api//grading/', views.ApiGrading.as_view(), name='api_grading'), - path('api//grading/create/', views.ApiCreateGradingScheme.as_view(), name='api_create_grading'), - path('api//grading/evaluate/', views.ApiEvaluate.as_view(), name='api_evaluate'), - path('api//grading/student-grades/', views.ApiStudentGrades.as_view(), name='api_student_grades'), -] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0002_auto_20260326_0127.py b/FusionIIIT/applications/programme_curriculum/migrations/0002_auto_20260326_0127.py new file mode 100644 index 000000000..b9649fec2 --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0002_auto_20260326_0127.py @@ -0,0 +1,58 @@ +# Generated by Django 3.1.5 on 2026-03-26 01:27 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='course', + name='latest_version', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='course', + name='version', + field=models.DecimalField(decimal_places=1, default=1.0, max_digits=2, validators=[django.core.validators.MinValueValidator(1.0), django.core.validators.DecimalValidator(decimal_places=1, max_digits=2)]), + ), + migrations.AlterField( + model_name='batch', + name='year', + field=models.PositiveIntegerField(default=2026), + ), + migrations.AlterField( + model_name='course', + name='code', + field=models.CharField(max_length=10), + ), + migrations.AlterField( + model_name='course', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterField( + model_name='courseslot', + name='type', + field=models.CharField(choices=[('Professional Core', 'Professional Core'), ('Professional Elective', 'Professional Elective'), ('Professional Lab', 'Professional Lab'), ('Engineering Science', 'Engineering Science'), ('Natural Science', 'Natural Science'), ('Humanities', 'Humanities'), ('Design', 'Design'), ('Manufacturing', 'Manufacturing'), ('Management Science', 'Management Science'), ('Open Elective', 'Open Elective'), ('Swayam', 'Swayam'), ('Project', 'Project'), ('Optional', 'Optional'), ('Others', 'Others')], max_length=70), + ), + migrations.AlterField( + model_name='curriculum', + name='version', + field=models.DecimalField(decimal_places=1, default=1.0, max_digits=2, validators=[django.core.validators.MinValueValidator(1.0), django.core.validators.DecimalValidator(decimal_places=1, max_digits=2)]), + ), + migrations.AlterField( + model_name='programme', + name='programme_begin_year', + field=models.PositiveIntegerField(default=2026), + ), + migrations.AlterUniqueTogether( + name='course', + unique_together={('code', 'version')}, + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0003_fix_courseinstructor_schema.py b/FusionIIIT/applications/programme_curriculum/migrations/0003_fix_courseinstructor_schema.py new file mode 100644 index 000000000..026300bcb --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0003_fix_courseinstructor_schema.py @@ -0,0 +1,20 @@ +# Generated migration to fix CourseInstructor schema + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0002_auto_20260326_0127'), + ] + + operations = [ + # Add the batch_id field to CourseInstructor + migrations.AddField( + model_name='courseinstructor', + name='batch_id', + field=models.ForeignKey(null=True, blank=True, on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.batch'), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/migrations/0004_auto_20260326_1010.py b/FusionIIIT/applications/programme_curriculum/migrations/0004_auto_20260326_1010.py new file mode 100644 index 000000000..48138ca6d --- /dev/null +++ b/FusionIIIT/applications/programme_curriculum/migrations/0004_auto_20260326_1010.py @@ -0,0 +1,19 @@ +# Generated by Django 3.1.5 on 2026-03-26 10:10 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('programme_curriculum', '0003_fix_courseinstructor_schema'), + ] + + operations = [ + migrations.AlterField( + model_name='courseinstructor', + name='batch_id', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='programme_curriculum.batch'), + ), + ] diff --git a/FusionIIIT/applications/programme_curriculum/models.py b/FusionIIIT/applications/programme_curriculum/models.py index 733217fff..7c39547f9 100644 --- a/FusionIIIT/applications/programme_curriculum/models.py +++ b/FusionIIIT/applications/programme_curriculum/models.py @@ -332,5 +332,5 @@ class Meta: unique_together = ('course_id', 'instructor_id', 'batch_id') - def _self_(self): + def __str__(self): return '{} - {}'.format(self.course_id, self.instructor_id) \ No newline at end of file diff --git a/FusionIIIT/applications/research_procedures/api/urls.py b/FusionIIIT/applications/research_procedures/api/urls.py index 67a7e1fd3..5c26ac1c9 100644 --- a/FusionIIIT/applications/research_procedures/api/urls.py +++ b/FusionIIIT/applications/research_procedures/api/urls.py @@ -4,5 +4,4 @@ router = DefaultRouter() router.register(r'patent', PatentViewSet) -urlpatterns = router.urls -print("URL patterns",urlpatterns) \ No newline at end of file +urlpatterns = router.urls \ No newline at end of file diff --git a/FusionIIIT/applications/scholarships/migrations/0002_auto_20260326_1010.py b/FusionIIIT/applications/scholarships/migrations/0002_auto_20260326_1010.py new file mode 100644 index 000000000..8986c2f9b --- /dev/null +++ b/FusionIIIT/applications/scholarships/migrations/0002_auto_20260326_1010.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.5 on 2026-03-26 10:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('scholarships', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='previous_winner', + name='year', + field=models.IntegerField(default=2026), + ), + ] diff --git a/FusionIIIT/fix_extrainfo.py b/FusionIIIT/fix_extrainfo.py new file mode 100644 index 000000000..4ee465a94 --- /dev/null +++ b/FusionIIIT/fix_extrainfo.py @@ -0,0 +1,38 @@ +from django.contrib.auth.models import User +from applications.globals.models import ExtraInfo, DepartmentInfo + +# Get the first superuser +su = User.objects.filter(is_superuser=True).first() + +if su: + print(f"Processing superuser: {su.username}") + + # Check if extrainfo already exists + extra = ExtraInfo.objects.filter(user=su).first() + + if not extra: + # Create a default department + dept, _ = DepartmentInfo.objects.get_or_create(name='Administration') + + # Create ExtraInfo for this user + # Use username as the ID + try: + ExtraInfo.objects.create( + id=su.username, + user=su, + user_type='admin', + department=dept, + title='Dr.', + sex='M', + phone_no=9999999999, + user_status='PRESENT', + address='Administration', + about_me='System Administrator' + ) + print(f"āœ“ ExtraInfo created for {su.username}") + except Exception as e: + print(f"āœ— Error creating ExtraInfo: {e}") + else: + print(f"āœ“ ExtraInfo already exists for {su.username}") +else: + print("No superuser found. Create one first with: python manage.py createsuperuser") diff --git a/FusionIIIT/fix_extrainfo_23bcs080.py b/FusionIIIT/fix_extrainfo_23bcs080.py new file mode 100644 index 000000000..73cd3fe58 --- /dev/null +++ b/FusionIIIT/fix_extrainfo_23bcs080.py @@ -0,0 +1,38 @@ +from django.contrib.auth.models import User +from applications.globals.models import ExtraInfo, DepartmentInfo + +# Get the superuser with username 23bcs080 +su = User.objects.filter(username='23bcs080').first() + +if su: + print(f"Processing superuser: {su.username}") + + # Check if extrainfo already exists + extra = ExtraInfo.objects.filter(user=su).first() + + if not extra: + # Create a default department + dept, _ = DepartmentInfo.objects.get_or_create(name='Administration') + + # Create ExtraInfo for this user + # Use username as the ID + try: + ExtraInfo.objects.create( + id=su.username, + user=su, + user_type='admin', + department=dept, + title='Dr.', + sex='M', + phone_no=9999999999, + user_status='PRESENT', + address='Administration', + about_me='System Administrator' + ) + print(f"āœ“ ExtraInfo created for {su.username}") + except Exception as e: + print(f"āœ— Error creating ExtraInfo: {e}") + else: + print(f"āœ“ ExtraInfo already exists for {su.username}") +else: + print("User 23bcs080 not found. Check username spelling.") diff --git a/FusionIIIT/fix_settings.py b/FusionIIIT/fix_settings.py index 2bc30bfcd..04dcbf830 100644 --- a/FusionIIIT/fix_settings.py +++ b/FusionIIIT/fix_settings.py @@ -1,5 +1,5 @@ """ -This script previously mutated settings/common.py using a hard-coded absolute +TOSTis script previously mutated settings/common.py using a hard-coded absolute local path. The required settings have been applied directly to settings/common.py, so this helper script is intentionally left empty and should not be used. diff --git a/FusionIIIT/manage.py b/FusionIIIT/manage.py index c4bcdcbec..13c993fb8 100755 --- a/FusionIIIT/manage.py +++ b/FusionIIIT/manage.py @@ -3,7 +3,7 @@ import sys if __name__ == "__main__": - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Fusion.settings.development") + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Fusion.settings.development_local") from django.core.management import execute_from_command_line diff --git a/FusionIIIT/update_views.py b/FusionIIIT/update_views.py index 742c36b74..6c2e37c0a 100644 --- a/FusionIIIT/update_views.py +++ b/FusionIIIT/update_views.py @@ -280,7 +280,7 @@ def get(self, request, course_code): if is_student_user: student = models.Student.objects.get(id=extra_info) has_finished = models.QuizResult.objects.filter(quiz_id=q, student_id=student, finished=True).exists() - if not (q.start_time <= now <= q.end_time and not has_finished): + if not (q.start_time <= now and (q.end_time is None or now <= q.end_time) and not has_finished): continue res.append({ @@ -288,7 +288,7 @@ def get(self, request, course_code): 'title': getattr(q, 'title', getattr(q, 'quiz_name', '')), 'description': q.description if hasattr(q, 'description') else '', 'startTime': q.start_time.isoformat(), - 'endTime': q.end_time.isoformat(), + 'endTime': q.end_time.isoformat() if q.end_time else None, 'duration': getattr(q, 'duration', getattr(q, 'd_time', 0)), 'negativeMarks': getattr(q, 'negative_marks', 0), 'totalQuestions': q.number_of_question if hasattr(q, 'number_of_question') else 0 @@ -309,7 +309,7 @@ def post(self, request, course_code): if hasattr(q, 'title'): q.title = d.get('title') if hasattr(q, 'description'): q.description = d.get('description', '') q.start_time = d.get('start_time') - q.end_time = d.get('end_time') + q.end_time = d.get('end_time') # Can be None now if hasattr(q, 'd_time'): q.d_time = d.get('duration', 0) if hasattr(q, 'duration'): q.duration = d.get('duration', 0) if hasattr(q, 'negative_marks'): q.negative_marks = d.get('negative_marks', 0) @@ -323,7 +323,7 @@ def get(self, request, course_code, quiz_id): q = models.Quiz.objects.get(pk=quiz_id) extra_info, is_student_user = self.get_role_info(request) if is_student_user: - if timezone.now() > q.end_time: + if q.end_time and timezone.now() > q.end_time: return Response({'detail': 'Quiz has ended'}, status=403) student = models.Student.objects.get(id=extra_info) if models.QuizResult.objects.filter(quiz_id=q, student_id=student, finished=True).exists(): diff --git a/docker-compose.yml b/docker-compose.yml index e353d1423..197ad145f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,14 +4,20 @@ services: image: postgres:13-alpine restart: always volumes: - - /private/var/lib/postgresql:/var/lib/postgresql/data + - postgres_data:/var/lib/postgresql/data environment: - - PGDATA=/var/lib/postgresql/data/some_name/ - POSTGRES_DB=fusionlab - POSTGRES_USER=fusion_admin - - POSTGRES_PASSWORD=hello123 + - POSTGRES_PASSWORD=${DB_PASSWORD:-hello123} ports: - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U fusion_admin -d fusionlab"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + app: build: context: . @@ -21,8 +27,31 @@ services: - .:/home/app ports: - 8000:8000 - command: /bin/bash docker-entrypoint.sh + command: bash docker-entrypoint.sh environment: - DB_HOST=db + - DB_NAME=fusionlab + - DB_USER=fusion_admin + - DB_PASSWORD=${DB_PASSWORD:-hello123} depends_on: - - db + db: + condition: service_healthy + + frontend: + build: + context: ../Fusion-client + dockerfile: Dockerfile.dev + restart: always + volumes: + - ../Fusion-client:/app + - /app/node_modules + ports: + - 5173:5173 + depends_on: + - app + environment: + - REACT_APP_API_URL=http://localhost:8000 + +volumes: + postgres_data: + diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 655f946fd..26c08c868 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,9 +1,16 @@ #!/bin/bash +# Wait for database to be ready +echo "Waiting for database to be ready..." +until python -c "import psycopg2; conn = psycopg2.connect(host='$DB_HOST', user='$DB_USER', password='$DB_PASSWORD', dbname='$DB_NAME'); conn.close()" 2>/dev/null; do + echo "Waiting for database connection..." + sleep 2 +done + # Apply database migrations echo "Apply database migrations" -# python FusionIIIT/manage.py makemigrations -# python FusionIIIT/manage.py migrate +python FusionIIIT/manage.py makemigrations +python FusionIIIT/manage.py migrate # Start server echo "Starting server" diff --git a/requirements.txt b/requirements.txt index 4cc3a0a30..0b053b83e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,6 +28,7 @@ django-model-utils==4.1.1 django-notifications-hq==1.6.0 django-pagedown==2.2.0 djangorestframework==3.12.2 +djangorestframework-simplejwt==4.8.0 django-semanticui-forms==1.6.5 django-unused-media==0.2.2 future==0.18.2