Compare commits
10 commits
dependabot
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a61c00671f | |||
| 7ac0b3f8ee | |||
| d24197b6c9 | |||
| aae1451eeb | |||
| 060f4301e9 | |||
| c140533954 | |||
| 64ce1b2293 | |||
| e5930c2cbf | |||
| 1bf4e86d4c | |||
| 4e1b8b9a02 |
207 changed files with 16292 additions and 672 deletions
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Ahmed Nagi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
151
README.md
151
README.md
|
|
@ -1,6 +1,8 @@
|
|||
# Learning Management System (LMS) API
|
||||
# Learning Management System (LMS) API with Vue.js
|
||||
|
||||
Welcome to the Learning Management System (LMS) API! This project is a robust and scalable backend solution built with Django and Django Rest Framework (DRF). It is designed to manage courses and their associated modules, providing a structured and secure platform for educational content delivery.
|
||||
Welcome to the Learning Management System (LMS) API! This project is a robust, scalable backend solution built with Django and Django Rest Framework (DRF), coupled with a modern and responsive frontend using Vue.js. It is designed to manage courses and their associated modules, providing a structured and secure platform for educational content delivery.
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -9,42 +11,38 @@ Welcome to the Learning Management System (LMS) API! This project is a robust an
|
|||
- **Authentication & Permissions**: Secure access using Token Authentication and IsAuthenticated permissions.
|
||||
- **RESTful API Design**: Follows REST principles with hyperlinked relationships for intuitive navigation.
|
||||
- **Custom Query Logic**: Retrieve modules filtered by course ID for efficient data access.
|
||||
- **Interactive Frontend**: A Vue.js-powered frontend for seamless interaction with the backend API, including real-time updates and dynamic views.
|
||||
|
||||
## Technologies Used
|
||||
|
||||
- **Backend**: Django, Django Rest Framework (DRF)
|
||||
### Backend
|
||||
- **Framework**: Django, Django Rest Framework (DRF)
|
||||
- **Authentication**: dj-rest-auth & django-alluth
|
||||
- **Database**: PostgreSQL
|
||||
- **API Documentation**: Auto-generated using drf_yasg browsable API.
|
||||
- **API Documentation**: Auto-generated using drf-spectacular browsable API.
|
||||
- **Project Scaffold**: Cookiecutter Django
|
||||
|
||||
## Getting Started
|
||||
### Frontend
|
||||
- **Framework**: Vue.js 3
|
||||
- **Routing**: Vue Router
|
||||
- **UI Framework**: Tailwind CSS, DaisyUI
|
||||
|
||||
|
||||
# Prerequisites
|
||||
### Integration
|
||||
|
||||
- [Docker](https://docs.docker.com/docker-for-mac/install/)
|
||||
|
||||
## Local Development
|
||||
|
||||
Start the dev server for local development:
|
||||
```bash
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
Run a command inside the docker container:
|
||||
|
||||
```bash
|
||||
docker-compose run --rm web [command]
|
||||
```
|
||||
1. **API Consumption**: Use Axios or Fetch API in Vue.js to interact with the backend API endpoints.
|
||||
2. **Authentication**: Implement login and token storage using Vuex/Pinia or localStorage.
|
||||
3. **Components**: Create reusable Vue components for courses, modules, authentication, and navigation.
|
||||
4. **Routing**: Use Vue Router to manage navigation between pages like course lists, module details, and user authentication.
|
||||
|
||||
## API Endpoints
|
||||
This project includes a fully interactive API documentation powered by drf-spectacular, a library for generating Swagger and ReDoc documentation for Django REST Framework (DRF).
|
||||
|
||||
This project includes a fully interactive API documentation powered by drf_yasg, a library for generating Swagger and ReDoc documentation for Django REST Framework (DRF).
|
||||
Features
|
||||
### Features
|
||||
|
||||
* Interactive Swagger UI: Test API endpoints directly within the browser.
|
||||
* ReDoc Interface: Professionally styled documentation for better readabi* lity.
|
||||
* Auto-generated: No need to write documentation manually; drf_yasg extracts t* he information from DRF views and serializers.
|
||||
- **Interactive Swagger UI**: Test API endpoints directly within the browser.
|
||||
- **MkDocs Material Interface**: A clean and customizable documentation tool with a modern Material Design theme.
|
||||
- **Auto-generated**: No need to write documentation manually; drf-spectacularyasg extracts the information from DRF views and serializers.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
|
@ -59,106 +57,7 @@ Contributions are welcome! If you’d like to contribute, please follow these st
|
|||
|
||||
This project is licensed under the MIT License. See the LICENSE file for details.
|
||||
|
||||
## Acknowledgments
|
||||
---
|
||||
|
||||
- Built with ❤️ using Django and Django Rest Framework.
|
||||
- Inspired by the need for scalable and secure e-learning solutions.
|
||||
By combining the power of Django and Vue.js, this LMS API provides a full-stack solution for managing and delivering educational content effectively. Happy coding!
|
||||
|
||||
Feel free to explore the API and contribute to its development. For any questions or feedback, please open an issue or contact the maintainers. Happy coding! 🚀
|
||||
|
||||
This README is clear, concise, and provides all the necessary information for users and contributors.
|
||||
|
||||
|
||||
|
||||
|
||||
[](https://github.com/cookiecutter/cookiecutter-django/)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
|
||||
## Settings
|
||||
|
||||
Moved to [settings](https://cookiecutter-django.readthedocs.io/en/latest/1-getting-started/settings.html).
|
||||
|
||||
## Basic Commands
|
||||
|
||||
### Setting Up Your Users
|
||||
|
||||
- To create a **normal user account**, just go to Sign Up and fill out the form. Once you submit it, you'll see a "Verify Your E-mail Address" page. Go to your console to see a simulated email verification message. Copy the link into your browser. Now the user's email should be verified and ready to go.
|
||||
|
||||
- To create a **superuser account**, use this command:
|
||||
|
||||
$ python manage.py createsuperuser
|
||||
|
||||
For convenience, you can keep your normal user logged in on Chrome and your superuser logged in on Firefox (or similar), so that you can see how the site behaves for both kinds of users.
|
||||
|
||||
### Type checks
|
||||
|
||||
Running type checks with mypy:
|
||||
|
||||
$ mypy lms
|
||||
|
||||
### Test coverage
|
||||
|
||||
To run the tests, check your test coverage, and generate an HTML coverage report:
|
||||
|
||||
$ coverage run -m pytest
|
||||
$ coverage html
|
||||
$ open htmlcov/index.html
|
||||
|
||||
#### Running tests with pytest
|
||||
|
||||
$ pytest
|
||||
|
||||
### Live reloading and Sass CSS compilation
|
||||
|
||||
Moved to [Live reloading and SASS compilation](https://cookiecutter-django.readthedocs.io/en/latest/2-local-development/developing-locally.html#using-webpack-or-gulp).
|
||||
|
||||
### Celery
|
||||
|
||||
This app comes with Celery.
|
||||
|
||||
To run a celery worker:
|
||||
|
||||
```bash
|
||||
cd lms
|
||||
celery -A config.celery_app worker -l info
|
||||
```
|
||||
|
||||
Please note: For Celery's import magic to work, it is important _where_ the celery commands are run. If you are in the same folder with _manage.py_, you should be right.
|
||||
|
||||
To run [periodic tasks](https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html), you'll need to start the celery beat scheduler service. You can start it as a standalone process:
|
||||
|
||||
```bash
|
||||
cd lms
|
||||
celery -A config.celery_app beat
|
||||
```
|
||||
|
||||
or you can embed the beat service inside a worker with the `-B` option (not recommended for production use):
|
||||
|
||||
```bash
|
||||
cd lms
|
||||
celery -A config.celery_app worker -B -l info
|
||||
```
|
||||
|
||||
### Email Server
|
||||
|
||||
In development, it is often nice to be able to see emails that are being sent from your application. For that reason local SMTP server [Mailpit](https://github.com/axllent/mailpit) with a web interface is available as docker container.
|
||||
|
||||
Container mailpit will start automatically when you will run all docker containers.
|
||||
Please check [cookiecutter-django Docker documentation](https://cookiecutter-django.readthedocs.io/en/latest/2-local-development/developing-locally-docker.html) for more details how to start all containers.
|
||||
|
||||
With Mailpit running, to view messages that are sent by your application, open your browser and go to `http://127.0.0.1:8025`
|
||||
|
||||
### Sentry
|
||||
|
||||
Sentry is an error logging aggregator service. You can sign up for a free account at <https://sentry.io/signup/?code=cookiecutter> or download and host it yourself.
|
||||
The system is set up with reasonable defaults, including 404 logging and integration with the WSGI application.
|
||||
|
||||
You must set the DSN url in production.
|
||||
|
||||
## Deployment
|
||||
|
||||
The following details how to deploy this application.
|
||||
|
||||
### Docker
|
||||
|
||||
See detailed [cookiecutter-django Docker documentation](https://cookiecutter-django.readthedocs.io/en/latest/3-deployment/deployment-with-docker.html).
|
||||
|
|
|
|||
|
|
@ -12,3 +12,5 @@ REDIS_URL=redis://redis:6379/0
|
|||
# Flower
|
||||
CELERY_FLOWER_USER=debug
|
||||
CELERY_FLOWER_PASSWORD=debug
|
||||
|
||||
SIGNING_KEY=ebd0c2f345ede5we3244t5r34a0dc1b994e33e729e
|
||||
|
|
@ -5,12 +5,12 @@ DJANGO_SETTINGS_MODULE=config.settings.production
|
|||
DJANGO_SECRET_KEY=CQHQz4M3wN1VL2TT53Gl8yupKOjQ5m01js4jPw6bQsUexzkdy9JGXhQg9h6H24M5
|
||||
DJANGO_ADMIN_URL=6XfjlokEGlPf6SpVfGh7wBvs7t5ZFMDs/
|
||||
DJANGO_ALLOWED_HOSTS=.example.com
|
||||
SIGNING_KEY=HQz4M3wN1ebd0c2f345ede5we324@#$%$#@#R$Q#Zaexsredg/*43/54333e729e
|
||||
|
||||
# Security
|
||||
# ------------------------------------------------------------------------------
|
||||
# TIP: better off using DNS, however, redirect is OK too
|
||||
DJANGO_SECURE_SSL_REDIRECT=False
|
||||
|
||||
# Email
|
||||
# ------------------------------------------------------------------------------
|
||||
DJANGO_SERVER_EMAIL=
|
||||
0
.gitattributes → backend/.gitattributes
vendored
0
.gitattributes → backend/.gitattributes
vendored
0
.gitignore → backend/.gitignore
vendored
0
.gitignore → backend/.gitignore
vendored
|
|
@ -6,12 +6,16 @@
|
|||
packages = [
|
||||
pkgs.docker
|
||||
pkgs.docker-compose
|
||||
pkgs.sudo
|
||||
];
|
||||
|
||||
# Sets environment variables in the workspace
|
||||
env = {};
|
||||
env = {
|
||||
PORT = "6000";
|
||||
};
|
||||
services.docker.enable = true;
|
||||
|
||||
|
||||
idx = {
|
||||
# Search for the extensions you want on https://open-vsx.org/ and use "publisher.id"
|
||||
extensions = [
|
||||
91
backend/README.md
Normal file
91
backend/README.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
[](https://github.com/cookiecutter/cookiecutter-django/)
|
||||
[](https://github.com/astral-sh/ruff)
|
||||
|
||||
## Settings
|
||||
|
||||
Moved to [settings](https://cookiecutter-django.readthedocs.io/en/latest/1-getting-started/settings.html).
|
||||
|
||||
## Basic Commands
|
||||
|
||||
### Setting Up Your Users
|
||||
|
||||
- To create a **normal user account**, just go to Sign Up and fill out the form. Once you submit it, you'll see a "Verify Your E-mail Address" page. Go to your console to see a simulated email verification message. Copy the link into your browser. Now the user's email should be verified and ready to go.
|
||||
|
||||
- To create a **superuser account**, use this command:
|
||||
|
||||
$ python manage.py createsuperuser
|
||||
|
||||
For convenience, you can keep your normal user logged in on Chrome and your superuser logged in on Firefox (or similar), so that you can see how the site behaves for both kinds of users.
|
||||
|
||||
### Type checks
|
||||
|
||||
Running type checks with mypy:
|
||||
|
||||
$ mypy lms
|
||||
|
||||
### Test coverage
|
||||
|
||||
To run the tests, check your test coverage, and generate an HTML coverage report:
|
||||
|
||||
$ coverage run -m pytest
|
||||
$ coverage html
|
||||
$ open htmlcov/index.html
|
||||
|
||||
#### Running tests with pytest
|
||||
|
||||
$ pytest
|
||||
|
||||
### Live reloading and Sass CSS compilation
|
||||
|
||||
Moved to [Live reloading and SASS compilation](https://cookiecutter-django.readthedocs.io/en/latest/2-local-development/developing-locally.html#using-webpack-or-gulp).
|
||||
|
||||
### Celery
|
||||
|
||||
This app comes with Celery.
|
||||
|
||||
To run a celery worker:
|
||||
|
||||
```bash
|
||||
cd lms
|
||||
celery -A config.celery_app worker -l info
|
||||
```
|
||||
|
||||
Please note: For Celery's import magic to work, it is important _where_ the celery commands are run. If you are in the same folder with _manage.py_, you should be right.
|
||||
|
||||
To run [periodic tasks](https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html), you'll need to start the celery beat scheduler service. You can start it as a standalone process:
|
||||
|
||||
```bash
|
||||
cd lms
|
||||
celery -A config.celery_app beat
|
||||
```
|
||||
|
||||
or you can embed the beat service inside a worker with the `-B` option (not recommended for production use):
|
||||
|
||||
```bash
|
||||
cd lms
|
||||
celery -A config.celery_app worker -B -l info
|
||||
```
|
||||
|
||||
### Email Server
|
||||
|
||||
In development, it is often nice to be able to see emails that are being sent from your application. For that reason local SMTP server [Mailpit](https://github.com/axllent/mailpit) with a web interface is available as docker container.
|
||||
|
||||
Container mailpit will start automatically when you will run all docker containers.
|
||||
Please check [cookiecutter-django Docker documentation](https://cookiecutter-django.readthedocs.io/en/latest/2-local-development/developing-locally-docker.html) for more details how to start all containers.
|
||||
|
||||
With Mailpit running, to view messages that are sent by your application, open your browser and go to `http://127.0.0.1:8025`
|
||||
|
||||
### Sentry
|
||||
|
||||
Sentry is an error logging aggregator service. You can sign up for a free account at <https://sentry.io/signup/?code=cookiecutter> or download and host it yourself.
|
||||
The system is set up with reasonable defaults, including 404 logging and integration with the WSGI application.
|
||||
|
||||
You must set the DSN url in production.
|
||||
|
||||
## Deployment
|
||||
|
||||
The following details how to deploy this application.
|
||||
|
||||
### Docker
|
||||
|
||||
See detailed [cookiecutter-django Docker documentation](https://cookiecutter-django.readthedocs.io/en/latest/3-deployment/deployment-with-docker.html).
|
||||
23
backend/compose/local/docs/Dockerfile
Normal file
23
backend/compose/local/docs/Dockerfile
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Use a lightweight Python base image
|
||||
FROM python:3.12.8-alpine AS python
|
||||
|
||||
# Python base stage
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install dependencies
|
||||
RUN apk update && apk add --no-cache \
|
||||
# Runtime dependencies
|
||||
make \
|
||||
gettext \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# Install MkDocs and required plugins
|
||||
RUN pip install mkdocs==1.5.1 mkdocs-material==9.1.15 mkdocs-markdownextradata-plugin
|
||||
|
||||
# Copy the start script
|
||||
COPY ./compose/local/docs/start /start-docs
|
||||
RUN sed -i 's/\r$//g' /start-docs
|
||||
RUN chmod +x /start-docs
|
||||
|
||||
WORKDIR /docs
|
||||
8
backend/compose/local/docs/start
Normal file
8
backend/compose/local/docs/start
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -o errexit
|
||||
set -o pipefail
|
||||
set -o nounset
|
||||
|
||||
# Start MkDocs live development server
|
||||
exec mkdocs serve -a 0.0.0.0:6000
|
||||
|
|
@ -75,6 +75,7 @@ THIRD_PARTY_APPS = [
|
|||
"crispy_forms",
|
||||
"crispy_bootstrap5",
|
||||
"allauth",
|
||||
'allauth.headless',
|
||||
"allauth.account",
|
||||
"allauth.mfa",
|
||||
"allauth.socialaccount",
|
||||
|
|
@ -233,7 +234,7 @@ EMAIL_TIMEOUT = 5
|
|||
# Django Admin URL.
|
||||
ADMIN_URL = "admin/"
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#admins
|
||||
ADMINS = [("""Ahmed Nagi""", "ahmed10nagi@gmail.com")]
|
||||
ADMINS = [("""ص""", "e@gmail.com")]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#managers
|
||||
MANAGERS = ADMINS
|
||||
# https://cookiecutter-django.readthedocs.io/en/latest/settings.html#other-environment-settings
|
||||
|
|
@ -245,23 +246,23 @@ DJANGO_ADMIN_FORCE_ALLAUTH = env.bool("DJANGO_ADMIN_FORCE_ALLAUTH", default=Fals
|
|||
# https://docs.djangoproject.com/en/dev/ref/settings/#logging
|
||||
# See https://docs.djangoproject.com/en/dev/topics/logging for
|
||||
# more details on how to customize your logging configuration.
|
||||
# LOGGING = {
|
||||
# "version": 1,
|
||||
# "disable_existing_loggers": False,
|
||||
# "formatters": {
|
||||
# "verbose": {
|
||||
# "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s",
|
||||
# },
|
||||
# },
|
||||
# "handlers": {
|
||||
# "console": {
|
||||
# "level": "DEBUG",
|
||||
# "class": "logging.StreamHandler",
|
||||
# "formatter": "verbose",
|
||||
# },
|
||||
# },
|
||||
# "root": {"level": "INFO", "handlers": ["console"]},
|
||||
# }
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"verbose": {
|
||||
"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"level": "DEBUG",
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "verbose",
|
||||
},
|
||||
},
|
||||
"root": {"level": "INFO", "handlers": ["console"]},
|
||||
}
|
||||
|
||||
REDIS_URL = env("REDIS_URL", default="redis://redis:6379/0")
|
||||
REDIS_SSL = REDIS_URL.startswith("rediss://")
|
||||
|
|
@ -319,19 +320,37 @@ ACCOUNT_USERNAME_REQUIRED = False
|
|||
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
|
||||
# https://docs.allauth.org/en/latest/account/configuration.html
|
||||
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
|
||||
ACCOUNT_LOGIN_METHODS = {"email"}
|
||||
ACCOUNT_LOGOUT_ON_GET = True
|
||||
LOGOUT_ON_PASSWORD_CHANGE = False
|
||||
ACCOUNT_LOGOUT_ON_PASSWORD_CHANGE = False
|
||||
ACCOUNT_CHANGE_EMAIL = True
|
||||
# ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED = True
|
||||
HEADLESS_SERVE_SPECIFICATION = True
|
||||
|
||||
ACCOUNT_EMAIL_CONFIRMATION_HMAC = True
|
||||
ACCOUNT_CONFIRM_EMAIL_ON_GET = True
|
||||
ACCOUNT_MAX_EMAIL_ADDRESSES = 2
|
||||
|
||||
ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = None
|
||||
ACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL = None
|
||||
# ACCOUNT_RATE_LIMITS = {
|
||||
# "confirm_email": "1/4m", # 1 confirmation email every 4 minutes
|
||||
# }
|
||||
HEADLESS_FRONTEND_URLS = {
|
||||
"account_signup":"http://localhost:3000/account/signup",
|
||||
"account_confirm_email": "http://127.0.0.1:3000/account/email-confirmation/{key}/",
|
||||
|
||||
# "https://app.project.org/account/email/verify-email?token={key}",
|
||||
"account_reset_password": "https://app.project.org/account/password/reset",
|
||||
"account_reset_password_from_key": "https://app.project.org/account/password/reset/key/{key}",
|
||||
# "account_signup": "https://app.project.org/account/signup",
|
||||
# Fallback in case the state containing the `next` URL is lost and the handshake
|
||||
# with the third-party provider fails.
|
||||
# "socialaccount_login_error": "https://app.project.org/account/provider/callback",
|
||||
}
|
||||
HEADLESS_ONLY = True
|
||||
# https://docs.allauth.org/en/latest/account/configuration.html
|
||||
ACCOUNT_ADAPTER = "lms.accounts.adapters.CustomAccountAdapter"
|
||||
# ACCOUNT_ADAPTER = "lms.accounts.adapters.CustomAccountAdapter"
|
||||
# https://docs.allauth.org/en/latest/account/forms.html
|
||||
# ACCOUNT_FORMS = {"signup": "lms.users.forms.UserSignupForm"}
|
||||
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
|
||||
|
|
@ -353,10 +372,13 @@ REST_FRAMEWORK = {
|
|||
'dj_rest_auth.jwt_auth.JWTCookieAuthentication',
|
||||
),
|
||||
"DEFAULT_PERMISSION_CLASSES": (
|
||||
# 'rest_framework.permissions.AllowAny',
|
||||
"rest_framework.permissions.IsAuthenticated",
|
||||
),
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
'EXCEPTION_HANDLER': 'utils.exception_handler.custom_exception_handler',
|
||||
}
|
||||
|
||||
REST_AUTH = {
|
||||
'LOGIN_SERIALIZER': 'lms.accounts.serializers.CustomLoginSerializer',
|
||||
'REGISTER_SERIALIZER': 'lms.accounts.serializers.CustomRegisterSerializer',
|
||||
|
|
@ -368,15 +390,15 @@ REST_AUTH = {
|
|||
from datetime import timedelta
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': timedelta(hours=5),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=30),
|
||||
'REFRESH_TOKEN_LIFETIME': timedelta(days=15),
|
||||
'ROTATE_REFRESH_TOKENS': True,
|
||||
'BLACKLIST_AFTER_ROTATION': True,
|
||||
'ALGORITHM': 'HS256',
|
||||
'SIGNING_KEY': 'SECRET_KEY',
|
||||
'SIGNING_KEY': 'env("SIGNING_KEY")',
|
||||
}
|
||||
|
||||
# django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup
|
||||
# CORS_URLS_REGEX = r"^/api/.*$"
|
||||
CORS_URLS_REGEX = r"^/api/.*$"
|
||||
|
||||
# By Default swagger ui is available only to admin user(s). You can change permission classes to change that
|
||||
# See more configuration options at https://drf-spectacular.readthedocs.io/en/latest/settings.html#settings
|
||||
|
|
@ -384,8 +406,9 @@ SPECTACULAR_SETTINGS = {
|
|||
"TITLE": "Learning Management System API",
|
||||
"DESCRIPTION": "Documentation of API endpoints of Learning Management System",
|
||||
"VERSION": "1.0.0",
|
||||
"SERVE_PERMISSIONS": ["rest_framework.permissions.IsAdminUser"],
|
||||
# "SERVE_PERMISSIONS": ["rest_framework.permissions.IsAdminUser"],
|
||||
"SCHEMA_PATH_PREFIX": "/api/",
|
||||
'SERVE_INCLUDE_SCHEMA': False,
|
||||
}
|
||||
# Your stuff...
|
||||
# ------------------------------------------------------------------------------
|
||||
|
|
@ -16,13 +16,13 @@ SECRET_KEY = env(
|
|||
)
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
|
||||
ALLOWED_HOSTS = ["127.0.0.1", "localhost",
|
||||
"8000-idx-lms-1736953337728.cluster-p6qcyjpiljdwusmrjxdspyb5m2.cloudworkstations.dev"
|
||||
"8000-idx-learning-management-systemgit-1737467650700.cluster-y34ecccqenfhcuavp7vbnxv7zk.cloudworkstations.dev"
|
||||
] # حدد المضيفين المسموح بهم
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
'http://localhost:3000',
|
||||
'http://127.0.0.1:3000',
|
||||
'https://8000-idx-lms-1736953337728.cluster-p6qcyjpiljdwusmrjxdspyb5m2.cloudworkstations.dev'
|
||||
'https://8000-idx-learning-management-systemgit-1737467650700.cluster-y34ecccqenfhcuavp7vbnxv7zk.cloudworkstations.dev'
|
||||
]
|
||||
CORS_ORIGIN_ALLOW_ALL = True
|
||||
CORS_ALLOW_CREDENTIALS = False
|
||||
|
|
@ -45,6 +45,7 @@ CORS_ALLOW_HEADERS = [
|
|||
"user-agent",
|
||||
"x-csrftoken",
|
||||
"x-requested-with",
|
||||
"x-session-token",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -12,13 +12,18 @@ from drf_spectacular.views import SpectacularSwaggerView
|
|||
from rest_framework.authtoken.views import obtain_auth_token
|
||||
from lms.accounts.views import *
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny
|
||||
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("", TemplateView.as_view(template_name="pages/home.html"), name="home"),
|
||||
# Django Admin, use {% url 'admin:index' %}
|
||||
path(settings.ADMIN_URL, admin.site.urls),
|
||||
# User management
|
||||
path("accounts/", include("allauth.urls")),
|
||||
|
||||
path("authwed/", include("allauth.urls")),
|
||||
# Your stuff: custom urls includes go here
|
||||
# ...
|
||||
# Media files
|
||||
|
|
@ -30,18 +35,12 @@ if settings.DEBUG:
|
|||
|
||||
# API URLS
|
||||
urlpatterns += [
|
||||
path("api/accounts/", include("allauth.urls")),
|
||||
|
||||
path('authw/', include('dj_rest_auth.urls')),
|
||||
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
|
||||
path('auth/', include('lms.accounts.urls')),
|
||||
path('api/auth/', include('lms.accounts.urls')),
|
||||
|
||||
path('app/', include('lms.app.urls')),
|
||||
path('api/app/', include('lms.app.urls')),
|
||||
|
||||
|
||||
# API base url
|
||||
# path("api/", include("config.api_router")),
|
||||
# DRF auth token
|
||||
path("api/auth-token/", obtain_auth_token),
|
||||
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
|
||||
path(
|
||||
"api/docs/",
|
||||
|
|
@ -12,5 +12,5 @@ services:
|
|||
- ./config:/app/config:z
|
||||
- ./lms:/app/lms:z
|
||||
ports:
|
||||
- '9000:9000'
|
||||
command: /start-docs
|
||||
- '6000:6000'
|
||||
command: mkdocs serve
|
||||
67
backend/docs/mkdocs.yml
Normal file
67
backend/docs/mkdocs.yml
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# اسم الموقع
|
||||
site_name: My Project
|
||||
|
||||
|
||||
site_description: Learning Management System
|
||||
|
||||
|
||||
|
||||
theme:
|
||||
name: material
|
||||
custom_dir: overrides # تخصيص السمات (اختياري)
|
||||
palette:
|
||||
- scheme: default # الوضع الافتراضي
|
||||
primary: indigo # اللون الأساسي
|
||||
accent: pink # اللون الثانوي
|
||||
- scheme: slate # وضع داكن
|
||||
primary: deep purple
|
||||
accent: amber
|
||||
# features:
|
||||
# - navigation.tabs # استخدام التبويبات للتنقل
|
||||
# - navigation.expand # توسيع القوائم تلقائيًا
|
||||
# - toc.integrate # دمج قائمة المحتويات (Table of Contents) مع التنقل
|
||||
# logo: images/logo.png # شعار الموقع (اختياري)
|
||||
# favicon: images/favicon.ico # أيقونة الموقع (اختياري)
|
||||
|
||||
# التنقل (Navigation)
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Getting Started:
|
||||
- Introduction: getting-started/introduction.md
|
||||
- Installation: getting-started/installation.md
|
||||
- Reference:
|
||||
- API Documentation: reference/api.md
|
||||
- CLI: reference/cli.md
|
||||
- About: about.md
|
||||
|
||||
# الإضافات (Plugins)
|
||||
plugins:
|
||||
- search # محرك البحث
|
||||
- markdownextradata # إدراج البيانات الإضافية (اختياري)
|
||||
|
||||
# ملحقات Markdown
|
||||
markdown_extensions:
|
||||
- admonition # الملاحظات (تحذير، نصيحة، إلخ)
|
||||
- codehilite # تمييز الأكواد
|
||||
- toc # قائمة المحتويات
|
||||
- tables # دعم الجداول
|
||||
- pymdownx.arithmatex # دعم LaTeX (للمعادلات الرياضية)
|
||||
- pymdownx.superfences # تحسين تداخل الأكواد والجداول
|
||||
|
||||
# إعدادات البحث
|
||||
extra:
|
||||
search:
|
||||
lang: en # لغة البحث (يدعم الإنجليزية، الفرنسية، إلخ)
|
||||
separator: "[\\s\\-]+" # الفاصل للبحث
|
||||
|
||||
# بيانات إضافية (اختيارية)
|
||||
extra_css:
|
||||
- styles/custom.css # ملف CSS مخصص
|
||||
extra_javascript:
|
||||
- scripts/custom.js # ملف JavaScript مخصص
|
||||
|
||||
# إعدادات مخرجات البناء
|
||||
# site_dir: site # مسار مجلد الإخراج
|
||||
docs_dir: docs # مسار مجلد الوثائق
|
||||
|
||||
dev_addr: 0.0.0.0:6000
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Generated by Django 5.0.10 on 2025-01-15 15:19
|
||||
# Generated by Django 5.0.10 on 2025-01-21 13:50
|
||||
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
|
@ -9,11 +9,19 @@ from django.contrib.auth import get_user_model
|
|||
from rest_framework.exceptions import ValidationError
|
||||
from allauth.account.utils import send_email_confirmation
|
||||
from rest_framework.response import Response
|
||||
from lms.utils.exception_handler import CustomValidationError
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['id', 'email', 'full_name']
|
||||
|
||||
|
||||
class CustomLoginSerializer(LoginSerializer):
|
||||
|
||||
email = serializers.EmailField(required=True)
|
||||
password = serializers.CharField(style={'input_type': 'password'}, write_only=True)
|
||||
|
||||
|
|
@ -22,24 +30,28 @@ class CustomLoginSerializer(LoginSerializer):
|
|||
password = attrs.get('password')
|
||||
|
||||
if not email or not password:
|
||||
raise serializers.ValidationError(_("Please enter both email and password."))
|
||||
raise CustomValidationError(_("Please enter both email and password."))
|
||||
|
||||
# البحث عن المستخدم بالبريد الإلكتروني
|
||||
users = User.objects.filter(email=email)
|
||||
email_address = EmailAddress.objects.filter(email=email).first()
|
||||
|
||||
if not users.exists():
|
||||
raise serializers.ValidationError(_("No account found with this email."))
|
||||
raise CustomValidationError(_("No account found with this email."))
|
||||
|
||||
if not email_address.verified:
|
||||
CustomValidationError(_("Email not verified. Please verify your email first."))
|
||||
|
||||
if users.count() > 1:
|
||||
raise serializers.ValidationError(_("Multiple accounts found with this email. Please contact support."))
|
||||
raise CustomValidationError(_("Multiple accounts found with this email. Please contact support."))
|
||||
|
||||
user = users.first()
|
||||
|
||||
if not user.check_password(password):
|
||||
raise serializers.ValidationError(_("Incorrect password."))
|
||||
raise CustomValidationError(_("Incorrect password."))
|
||||
|
||||
if not self.is_email_verified(user):
|
||||
raise serializers.ValidationError(_("Email not verified. Please verify your email first."))
|
||||
raise CustomValidationError(_("Email not verified. Please verify your email first."))
|
||||
|
||||
# إضافة المستخدم إلى الـ attrs
|
||||
attrs['user'] = user
|
||||
|
|
@ -66,10 +78,10 @@ class CustomRegisterSerializer(RegisterSerializer):
|
|||
email_address = EmailAddress.objects.filter(email=email).first()
|
||||
if email_address:
|
||||
if email_address.verified:
|
||||
raise ValidationError({'email': 'This email is already.'})
|
||||
raise CustomValidationError({'email': 'This email is already.'})
|
||||
else:
|
||||
send_email_confirmation(request, email_address.user)
|
||||
raise ValidationError({'email': 'A confirmation email has been sent. Please confirm your email.'})
|
||||
raise CustomValidationError({'email': 'A confirmation email has been sent. Please confirm your email.'})
|
||||
|
||||
user = super().save(request)
|
||||
user.full_name = self.data.get('full_name', '')
|
||||
|
|
@ -80,15 +92,12 @@ class CustomRegisterSerializer(RegisterSerializer):
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class ChangeEmailSerializer(serializers.Serializer):
|
||||
email = serializers.EmailField()
|
||||
|
||||
def validate_email(self, value):
|
||||
if EmailAddress.objects.filter(email=value).exists() or User.objects.filter(email=value).exists():
|
||||
raise serializers.ValidationError("This email is already in use.")
|
||||
raise CustomValidationError("This email is already in use.")
|
||||
return value
|
||||
|
||||
def save(self, user):
|
||||
|
|
@ -13,4 +13,5 @@ urlpatterns = [
|
|||
name='password_reset_confirm',
|
||||
),
|
||||
path('change-email/', views.ChangeEmailView.as_view(), name='change_email'),
|
||||
path('user-info/', views.UserView.as_view()),
|
||||
]
|
||||
|
|
@ -6,6 +6,41 @@ from allauth.account.models import EmailConfirmation, EmailConfirmationHMAC, Ema
|
|||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from .serializers import ChangeEmailSerializer
|
||||
from asgiref.sync import sync_to_async
|
||||
from django.contrib.auth import get_user_model
|
||||
from lms.utils.exception_handler import CustomValidationError
|
||||
User = get_user_model()
|
||||
|
||||
class UserView(APIView):
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
user = request.user
|
||||
image_url = request.build_absolute_uri(user.image.url) if user.image else None
|
||||
return Response({
|
||||
"name": user.full_name,
|
||||
"image": image_url
|
||||
})
|
||||
|
||||
def patch(self, request):
|
||||
print(request.data)
|
||||
user = request.user
|
||||
full_name = request.data.get('full_name')
|
||||
|
||||
if full_name:
|
||||
user.full_name = full_name
|
||||
|
||||
profile_image = request.FILES.get('profile_image')
|
||||
if profile_image:
|
||||
user.image = profile_image
|
||||
print("Ok")
|
||||
user.save()
|
||||
|
||||
return Response(
|
||||
{"ok"},
|
||||
status=status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
|
||||
class ChangeEmailView(APIView):
|
||||
|
||||
|
|
@ -17,7 +52,7 @@ class ChangeEmailView(APIView):
|
|||
"message": "Confirmation email has been sent to the new address.",
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
raise CustomValidationError(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
|
||||
|
|
@ -27,7 +62,7 @@ class ConfirmEmailAPIView(APIView):
|
|||
def post(self, request, *args, **kwargs):
|
||||
key = request.data.get("key")
|
||||
if not key:
|
||||
return Response({"detail": _("Key is required.")}, status=status.HTTP_400_BAD_REQUEST)
|
||||
raise CustomValidationError({"detail": _("Key is required.")}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
# Attempt to retrieve the email confirmation using HMAC key
|
||||
|
|
@ -36,7 +71,7 @@ class ConfirmEmailAPIView(APIView):
|
|||
# If HMAC fails, fallback to database key
|
||||
email_confirmation = EmailConfirmation.objects.get(key=key)
|
||||
except EmailConfirmation.DoesNotExist:
|
||||
return Response({"detail": _("Invalid or expired key.")}, status=status.HTTP_400_BAD_REQUEST)
|
||||
raise CustomValidationError({"detail": _("Invalid or expired key.")}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if email_confirmation.email_address.verified:
|
||||
return Response({"detail": _("Email is already verified.")}, status=status.HTTP_200_OK)
|
||||
|
|
@ -26,11 +26,7 @@ class EnrollmentAdmin(admin.ModelAdmin):
|
|||
search_fields = ('student__username', 'course__title')
|
||||
list_filter = ('enrolled_at', 'completed')
|
||||
|
||||
@admin.register(Quiz)
|
||||
class QuizAdmin(admin.ModelAdmin):
|
||||
list_display = ('title', 'module')
|
||||
search_fields = ('title', 'module__title')
|
||||
list_filter = ('module',)
|
||||
|
||||
|
||||
@admin.register(Certificate)
|
||||
class CertificateAdmin(admin.ModelAdmin):
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Generated by Django 5.0.10 on 2025-01-15 15:19
|
||||
# Generated by Django 5.0.10 on 2025-01-21 13:50
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
|
@ -15,6 +15,16 @@ class Migration(migrations.Migration):
|
|||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AD',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('description', models.TextField()),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='ads_images/')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Course',
|
||||
fields=[
|
||||
|
|
@ -27,7 +37,7 @@ class Migration(migrations.Migration):
|
|||
('rating', models.PositiveSmallIntegerField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses_taught', to=settings.AUTH_USER_MODEL, verbose_name='Instructor')),
|
||||
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owner', to=settings.AUTH_USER_MODEL, verbose_name='Owner')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
|
|
@ -47,7 +57,7 @@ class Migration(migrations.Migration):
|
|||
('enrolled_at', models.DateTimeField(auto_now_add=True, verbose_name='Enrollment Date')),
|
||||
('completed', models.BooleanField(default=False, verbose_name='Completed')),
|
||||
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to='app.course', verbose_name='Course')),
|
||||
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='enrollments', to=settings.AUTH_USER_MODEL, verbose_name='Student')),
|
||||
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='students_enrollments', to=settings.AUTH_USER_MODEL, verbose_name='Student')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
|
|
@ -68,17 +78,9 @@ class Migration(migrations.Migration):
|
|||
('description', models.TextField(null=True, verbose_name='Lesson Description')),
|
||||
('content', models.TextField(verbose_name='Lesson Content')),
|
||||
('file', models.FileField(blank=True, null=True, upload_to='lesson_files/', verbose_name='Attached File')),
|
||||
('quiz', models.JSONField(null=True)),
|
||||
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Created By')),
|
||||
('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='app.module', verbose_name='Module')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Quiz',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('title', models.CharField(max_length=255, verbose_name='Quiz Title')),
|
||||
('questions', models.JSONField(null=True, verbose_name='Questions')),
|
||||
('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quiz', to='app.module', verbose_name='Module')),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -5,7 +5,6 @@ from rest_framework.exceptions import ValidationError
|
|||
|
||||
User = get_user_model()
|
||||
|
||||
# Table for courses (Course)
|
||||
class Course(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
title = models.CharField(max_length=255, verbose_name="Course Title")
|
||||
|
|
@ -13,7 +12,7 @@ class Course(models.Model):
|
|||
image = models.ImageField(upload_to="courses/image", null=True)
|
||||
is_paid = models.BooleanField(default=False)
|
||||
price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='courses_taught', verbose_name="Instructor")
|
||||
owner = models.ForeignKey(User, on_delete=models.CASCADE, related_name='owner', verbose_name="Owner")
|
||||
rating = models.PositiveSmallIntegerField(null=True, blank=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created At")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="Updated At")
|
||||
|
|
@ -29,7 +28,6 @@ class Course(models.Model):
|
|||
raise ValidationError({'price': 'Price must be empty for free products.'})
|
||||
|
||||
|
||||
# Table for modules (Module)
|
||||
class Module(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
title = models.CharField(max_length=255, verbose_name="Module Title")
|
||||
|
|
@ -40,7 +38,6 @@ class Module(models.Model):
|
|||
def str(self):
|
||||
return self.title
|
||||
|
||||
# Table for lessons (Lesson)
|
||||
class Lesson(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
title = models.CharField(max_length=255, verbose_name="Lesson Title")
|
||||
|
|
@ -49,15 +46,14 @@ class Lesson(models.Model):
|
|||
module = models.ForeignKey(Module, on_delete=models.CASCADE, related_name='lessons', verbose_name="Module")
|
||||
file = models.FileField(upload_to='lesson_files/', null=True, blank=True, verbose_name="Attached File")
|
||||
created_by = models.ForeignKey(User, on_delete=models.CASCADE, null=True, blank=True, verbose_name="Created By")
|
||||
#order = models.IntegerField(null=True, blank=True)
|
||||
quiz = models.JSONField(null=True)
|
||||
|
||||
def str(self):
|
||||
return self.title
|
||||
|
||||
# Table for enrollments (Enrollment)
|
||||
class Enrollment(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
student = models.ForeignKey(User, on_delete=models.CASCADE, related_name='enrollments', verbose_name="Student")
|
||||
student = models.ForeignKey(User, on_delete=models.CASCADE, related_name='students_enrollments', verbose_name="Student")
|
||||
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='enrollments', verbose_name="Course")
|
||||
enrolled_at = models.DateTimeField(auto_now_add=True, verbose_name="Enrollment Date")
|
||||
completed = models.BooleanField(default=False, verbose_name="Completed")
|
||||
|
|
@ -65,21 +61,6 @@ class Enrollment(models.Model):
|
|||
def str(self):
|
||||
return f"{self.student.username} - {self.course.title}"
|
||||
|
||||
# Table for quizzes (Quiz)
|
||||
class Quiz(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
title = models.CharField(max_length=255, verbose_name="Quiz Title")
|
||||
module = models.ForeignKey(Module, on_delete=models.CASCADE, related_name='quiz', verbose_name="Module")
|
||||
questions = models.JSONField(verbose_name="Questions", null=True) # Stores questions as a JSON list
|
||||
|
||||
def str(self):
|
||||
return self.title
|
||||
|
||||
|
||||
def str(self):
|
||||
return f"{self.student.username} - {self.quiz.title}"
|
||||
|
||||
# Table for certificates (Certificate)
|
||||
class Certificate(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
|
||||
student = models.ForeignKey(User, on_delete=models.CASCADE, related_name='certificates', verbose_name="Student")
|
||||
|
|
@ -89,3 +70,15 @@ class Certificate(models.Model):
|
|||
|
||||
def str(self):
|
||||
return f"{self.student.username} - {self.course.title}"
|
||||
|
||||
|
||||
|
||||
|
||||
class AD(models.Model):
|
||||
title = models.CharField(max_length=255)
|
||||
description = models.TextField()
|
||||
image = models.ImageField(upload_to='ads_images/', blank=True, null=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
|
@ -25,10 +25,6 @@ class IsOwnerOrReadOnly(BasePermission):
|
|||
return obj.student == request.user
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class IsAdmin(BasePermission):
|
||||
|
|
@ -5,18 +5,28 @@ from django.contrib.auth import authenticate
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
from allauth.account.models import EmailAddress
|
||||
from dj_rest_auth.registration.serializers import RegisterSerializer
|
||||
|
||||
|
||||
from lms.utils.exception_handler import CustomValidationError
|
||||
|
||||
|
||||
|
||||
class CourseSerializer(serializers.ModelSerializer):
|
||||
owner_name = serializers.CharField(source='owner.username', read_only=True)
|
||||
owner_name = serializers.CharField(source='owner.full_name', read_only=True)
|
||||
owner_image = serializers.SerializerMethodField()
|
||||
students_in_course = serializers.SerializerMethodField()
|
||||
class Meta:
|
||||
model = Course
|
||||
fields = ['id', 'title', 'description', 'is_paid', 'price', 'image', 'owner_name', 'created_at', 'updated_at']
|
||||
fields = ['id', 'title', 'description', 'is_paid', 'price', 'image', 'owner_name', 'owner_image', 'students_in_course', 'created_at', 'updated_at']
|
||||
read_only_fields = ['created_at', 'updated_at']
|
||||
|
||||
def get_owner_image(self, obj):
|
||||
request = self.context.get('request')
|
||||
if obj.owner.image:
|
||||
return request.build_absolute_uri(obj.owner.image.url)
|
||||
return None
|
||||
|
||||
def get_students_in_course(self, obj):
|
||||
return Enrollment.objects.filter(course=obj).values('student').distinct().count()
|
||||
|
||||
|
||||
|
||||
class LessonSerializer(serializers.ModelSerializer):
|
||||
|
|
@ -64,15 +74,16 @@ class EnrollmentSerializer(serializers.ModelSerializer):
|
|||
"rating": course.rating,
|
||||
}
|
||||
|
||||
|
||||
|
||||
class QuizSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Quiz
|
||||
fields = ['id', 'title', 'module', 'questions']
|
||||
|
||||
|
||||
|
||||
class CertificateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Certificate
|
||||
fields = ['student', 'course', 'issued_at', 'certificate_file']
|
||||
fields = ['student', 'course', 'issued_at', 'certificate_file']
|
||||
|
||||
|
||||
class PrivateEnrollmentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Enrollment
|
||||
fields = ['id', 'course', 'student', 'enrolled_at']
|
||||
read_only_fields = ['enrolled_at']
|
||||
8
backend/lms/app/tasks.py
Normal file
8
backend/lms/app/tasks.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# tasks.py
|
||||
|
||||
from celery import shared_task
|
||||
|
||||
@shared_task
|
||||
def print_message(message):
|
||||
print(f"الرسالة هي: {message}")
|
||||
return message
|
||||
107
backend/lms/app/tests/test_models.py
Normal file
107
backend/lms/app/tests/test_models.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
from django.test import TestCase
|
||||
from django.contrib.auth import get_user_model
|
||||
from rest_framework.test import APIClient
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from lms.app.models import Course, Module, Lesson, Enrollment, Certificate, AD
|
||||
from uuid import uuid4
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class ModelsTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
# Create test user
|
||||
self.user = User.objects.create_user(email='testuser@email.com', password='password')
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
# Create a test course
|
||||
self.course = Course.objects.create(
|
||||
title="Test Course",
|
||||
description="A test course description",
|
||||
is_paid=True,
|
||||
price=100.00,
|
||||
owner=self.user
|
||||
)
|
||||
|
||||
def test_course_creation(self):
|
||||
"""Test creating a course"""
|
||||
self.assertEqual(Course.objects.count(), 1)
|
||||
self.assertEqual(self.course.title, "Test Course")
|
||||
self.assertTrue(self.course.is_paid)
|
||||
self.assertEqual(self.course.price, 100.00)
|
||||
|
||||
def test_course_validation(self):
|
||||
"""Test course validation logic"""
|
||||
course = Course(
|
||||
title="Invalid Course",
|
||||
description="Should fail validation",
|
||||
is_paid=True,
|
||||
price=None, # Invalid case
|
||||
owner=self.user
|
||||
)
|
||||
with self.assertRaises(ValidationError):
|
||||
course.clean()
|
||||
|
||||
def test_module_creation(self):
|
||||
"""Test creating a module"""
|
||||
module = Module.objects.create(
|
||||
title="Test Module",
|
||||
description="A test module description",
|
||||
course=self.course,
|
||||
created_by=self.user
|
||||
)
|
||||
self.assertEqual(Module.objects.count(), 1)
|
||||
self.assertEqual(module.title, "Test Module")
|
||||
|
||||
def test_lesson_creation(self):
|
||||
"""Test creating a lesson"""
|
||||
module = Module.objects.create(
|
||||
title="Test Module",
|
||||
course=self.course,
|
||||
created_by=self.user
|
||||
)
|
||||
lesson = Lesson.objects.create(
|
||||
title="Test Lesson",
|
||||
description="A test lesson description",
|
||||
content="Lesson content here",
|
||||
module=module,
|
||||
created_by=self.user
|
||||
)
|
||||
self.assertEqual(Lesson.objects.count(), 1)
|
||||
self.assertEqual(lesson.title, "Test Lesson")
|
||||
self.assertEqual(lesson.module, module)
|
||||
|
||||
def test_enrollment_creation(self):
|
||||
"""Test creating an enrollment"""
|
||||
enrollment = Enrollment.objects.create(
|
||||
student=self.user,
|
||||
course=self.course,
|
||||
completed=False
|
||||
)
|
||||
self.assertEqual(Enrollment.objects.count(), 1)
|
||||
self.assertEqual(enrollment.student, self.user)
|
||||
self.assertEqual(enrollment.course, self.course)
|
||||
|
||||
def test_certificate_creation(self):
|
||||
"""Test creating a certificate"""
|
||||
certificate = Certificate.objects.create(
|
||||
student=self.user,
|
||||
course=self.course,
|
||||
certificate_file='path/to/certificate.pdf'
|
||||
)
|
||||
self.assertEqual(Certificate.objects.count(), 1)
|
||||
self.assertEqual(certificate.student, self.user)
|
||||
self.assertEqual(certificate.course, self.course)
|
||||
|
||||
def test_ad_creation(self):
|
||||
"""Test creating an ad"""
|
||||
ad = AD.objects.create(
|
||||
title="Test Ad",
|
||||
description="This is a test ad",
|
||||
image=None
|
||||
)
|
||||
self.assertEqual(AD.objects.count(), 1)
|
||||
self.assertEqual(ad.title, "Test Ad")
|
||||
self.assertEqual(ad.description, "This is a test ad")
|
||||
|
|
@ -7,7 +7,8 @@ router.register(r'courses', CourseViewSet, basename='course')
|
|||
router.register(r'modules', ModuleViewSet, basename='modules')
|
||||
router.register(r'lessons', LessonViewSet, basename='lessons')
|
||||
router.register(r'enrollment', EnrollmentViewSet, basename='enrollment')
|
||||
router.register(r'quiz', QuizViewSet, basename='quiz')
|
||||
router.register(r'certificate', CertificateViewSet, basename='certificate')
|
||||
# router.register(r'certificate', CertificateViewSet, basename='certificate')
|
||||
|
||||
urlpatterns = router.urls
|
||||
urlpatterns = [
|
||||
|
||||
] + router.urls
|
||||
|
|
@ -4,38 +4,44 @@ from .models import *
|
|||
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.permissions import IsAuthenticated, BasePermission
|
||||
from .permissions import IsOwnerOrReadOnly, IsAdmin
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from django.contrib.auth import get_user_model
|
||||
from lms.utils.exception_handler import CustomValidationError
|
||||
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
class CourseViewSet(ModelViewSet):
|
||||
"""
|
||||
A ViewSet for viewing and editing Course instances.
|
||||
"""
|
||||
queryset = Course.objects.all()
|
||||
permission_classes = [IsOwnerOrReadOnly]
|
||||
permission_classes = [IsAuthenticated, IsOwnerOrReadOnly]
|
||||
serializer_class = CourseSerializer
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""
|
||||
Save the post data when creating a new course.
|
||||
"""
|
||||
|
||||
serializer.save(owner=self.request.user)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='my-courses', url_name='my_courses')
|
||||
def get_my_course(self, request):
|
||||
"""
|
||||
Custom GET method to fetch detailed information about my courses.
|
||||
"""
|
||||
|
||||
my_courses = Course.objects.filter(owner=request.user)
|
||||
|
||||
# Serialize the data
|
||||
my_courses = Course.objects.filter(owner=request.user).prefetch_related('enrollments__student')
|
||||
total_students = Enrollment.objects.filter(course__in=my_courses).values('student').distinct().count()
|
||||
|
||||
serializer = self.get_serializer(my_courses, many=True)
|
||||
|
||||
return Response(serializer.data)
|
||||
response_data = {
|
||||
"total_students": total_students,
|
||||
"courses": serializer.data
|
||||
}
|
||||
return Response(response_data)
|
||||
|
||||
|
||||
|
||||
|
|
@ -53,9 +59,9 @@ class ModuleViewSet(ModelViewSet):
|
|||
"""
|
||||
course_id = self.request.query_params.get('pk')
|
||||
if course_id:
|
||||
course = Course.objects.filter(id=course_id).first()
|
||||
course = Course.objects.filter(id=course_id).select_related('owner').first()
|
||||
if course:
|
||||
return Module.objects.filter(course=course)
|
||||
return Module.objects.filter(course=course).select_related('course')
|
||||
return Module.objects.none()
|
||||
|
||||
|
||||
|
|
@ -70,7 +76,7 @@ class ModuleViewSet(ModelViewSet):
|
|||
raise PermissionDenied("You do not have permission to create module.")
|
||||
|
||||
if not course:
|
||||
return Response(
|
||||
raise CustomValidationError(
|
||||
{"detail": "This course not found."},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
|
@ -104,7 +110,7 @@ class LessonViewSet(ModelViewSet):
|
|||
return Lesson.objects.none() # Return no results if the module does not exist
|
||||
|
||||
# Verify that the lesson exists and is associated with the module
|
||||
lesson = Lesson.objects.filter(id=lesson_id, module=module).first()
|
||||
lesson = Lesson.objects.filter(id=lesson_id, module=module).select_related('module__course__owner').first()
|
||||
if not lesson:
|
||||
return Lesson.objects.none() # Return no results if the lesson does not exist or is not linked to the module
|
||||
|
||||
|
|
@ -139,29 +145,26 @@ class LessonViewSet(ModelViewSet):
|
|||
"""
|
||||
Custom PATCH method to update a lesson.
|
||||
"""
|
||||
# الحصول على معرف الكائن (lesson_id) من الـ URL
|
||||
|
||||
lesson_id = self.request.query_params.get('lesson_id')
|
||||
if not lesson_id:
|
||||
return Response({"detail": "Lesson ID is required in the URL."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
raise CustomValidationError({"detail": "Lesson ID is required in the URL."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# البحث عن الدرس
|
||||
lesson = Lesson.objects.filter(id=lesson_id).first()
|
||||
if not lesson:
|
||||
return Response({"detail": "Lesson not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
raise CustomValidationError({"detail": "Lesson not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# التحقق من الصلاحيات (مالك الكورس أو مسجل فيه)
|
||||
is_owner = lesson.module.course.owner == request.user
|
||||
|
||||
if not is_owner:
|
||||
raise PermissionDenied("You do not have permission to update this lesson.")
|
||||
|
||||
# تحديث الدرس باستخدام البيانات المرسلة في الطلب
|
||||
serializer = self.get_serializer(lesson, data=request.data, partial=True) # partial=True لتحديث الحقول المطلوبة فقط
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
raise CustomValidationError(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
|
||||
|
|
@ -174,27 +177,25 @@ class LessonViewSet(ModelViewSet):
|
|||
# الحصول على معرف الكائن (lesson_id) من الـ URL
|
||||
lesson_id = request.query_params.get('lesson_id')
|
||||
if not lesson_id:
|
||||
return Response(
|
||||
raise CustomValidationError(
|
||||
{"detail": "Lesson ID is required in the URL."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# البحث عن الدرس
|
||||
|
||||
try:
|
||||
lesson = Lesson.objects.get(id=lesson_id)
|
||||
except Lesson.DoesNotExist:
|
||||
return Response(
|
||||
raise CustomValidationError(
|
||||
{"detail": "Lesson not found."},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# التحقق من الصلاحيات (مالك الكورس أو مسجل فيه)
|
||||
is_owner = lesson.module.course.owner == request.user
|
||||
|
||||
if not is_owner:
|
||||
raise PermissionDenied("You do not have permission to delete this lesson.")
|
||||
|
||||
# حذف الدرس
|
||||
lesson.delete()
|
||||
|
||||
return Response(
|
||||
|
|
@ -211,7 +212,7 @@ class EnrollmentViewSet(ModelViewSet):
|
|||
http_method_names = ['get', 'post', 'delete']
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
instance = Enrollment.objects.filter(student=request.user)
|
||||
instance = Enrollment.objects.filter(student=request.user).select_related('course__owner')
|
||||
|
||||
serializer = self.get_serializer(instance, many=True)
|
||||
return Response(serializer.data)
|
||||
|
|
@ -225,110 +226,103 @@ class EnrollmentViewSet(ModelViewSet):
|
|||
try:
|
||||
course = Course.objects.get(id=course_id)
|
||||
except Course.DoesNotExist:
|
||||
return Response({"detail": "Course not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
raise CustomValidationError({"detail": "Course not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if course.is_paid:
|
||||
raise CustomValidationError({"detail": "This is paid"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if Enrollment.objects.filter(student=request.user, course=course).exists():
|
||||
return Response({"detail": "You are already subscribed to this course."}, status=status.HTTP_404_NOT_FOUND)
|
||||
elif course.owner == request.user:
|
||||
return Response({"detail": "You can't enroll in your course"}, status=status.HTTP_404_NOT_FOUND)
|
||||
raise CustomValidationError({"detail": "You are already subscribed to this course."}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if course.owner == request.user:
|
||||
raise CustomValidationError({"detail": "You can't enroll in your course"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
# Create a new enrollment
|
||||
enrollment = Enrollment.objects.create(student=request.user, course=course)
|
||||
serializer = self.get_serializer(enrollment)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
|
||||
class QuizViewSet(ModelViewSet):
|
||||
queryset = Quiz.objects.all()
|
||||
serializer_class = QuizSerializer
|
||||
permission_classes = [IsAuthenticated]
|
||||
def create(self, request, *args, **kwargs):
|
||||
|
||||
# Ensure the user is an owner
|
||||
if request.user.role != 'owner':
|
||||
return Response({"detail": "Only owners can create quizzes"}, status=status.HTTP_403_FORBIDDEN)
|
||||
# Get course data from the request
|
||||
moduleId = request.data.get('module')
|
||||
# Check if the course exists
|
||||
try:
|
||||
module = Module.objects.get(id=moduleId)
|
||||
except Course.DoesNotExist:
|
||||
return Response({"detail": "Course not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
# Ensure the current owner is the course owner
|
||||
if module.course.owner != request.user:
|
||||
return Response({"detail": "You can only create quizzes for your own courses"}, status=status.HTTP_403_FORBIDDEN)
|
||||
# Create a new quiz
|
||||
# data = request.data.copy() # نسخ البيانات لتجنب التعديل على الأصل
|
||||
# data.pop('module', None) # إزالة المفتاح module إذا كان موجودًا
|
||||
quiz = Quiz.objects.create(module=module)
|
||||
serializer = self.get_serializer(quiz)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
def update(self, request, *args, **kwargs):
|
||||
# Ensure the user is an owner
|
||||
if request.user.role != 'owner':
|
||||
return Response({"detail": "Only owners can update quizzes"}, status=status.HTTP_403_FORBIDDEN)
|
||||
# Get the quiz object to update
|
||||
quiz = self.get_object()
|
||||
# Ensure the current owner is the course owner
|
||||
if quiz.module.course.owner != request.user:
|
||||
return Response({"detail": "You can only update quizzes for your own courses"}, status=status.HTTP_403_FORBIDDEN)
|
||||
# Update the quiz
|
||||
serializer = self.get_serializer(quiz, data=request.data, partial=True)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
|
||||
# Ensure the user is an owner
|
||||
if request.user.role != 'owner':
|
||||
return Response({"detail": "Only owners can delete quizzes"}, status=status.HTTP_403_FORBIDDEN)
|
||||
# Get the quiz object to delete
|
||||
quiz = self.get_object()
|
||||
# Ensure the current owner is the course owner
|
||||
if quiz.module.course.owner != request.user:
|
||||
return Response({"detail": "You can only delete quizzes for your own courses"}, status=status.HTTP_403_FORBIDDEN)
|
||||
# Delete the quiz
|
||||
quiz.delete()
|
||||
return Response({"detail": "Quiz deleted successfully"}, status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
class CertificateViewSet(ModelViewSet):
|
||||
queryset = Certificate.objects.all()
|
||||
serializer_class = CertificateSerializer
|
||||
permission_classes = []
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == 'create':
|
||||
# permission_classes = [Isowner]
|
||||
pass
|
||||
elif self.action in ['update', 'destroy']:
|
||||
permission_classes = [IsAdmin]
|
||||
else:
|
||||
permission_classes = []
|
||||
return [permission() for permission in permission_classes]
|
||||
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
|
||||
# Get course data from the request
|
||||
courseId = request.data.get('course')
|
||||
student_id = request.data.get('student')
|
||||
# Check if the course exists
|
||||
try:
|
||||
course = Course.objects.get(id=courseId)
|
||||
student = User.objects.get(id=student_id, role='student')
|
||||
except User.DoesNotExist:
|
||||
return Response({"detail": "Student not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
except Course.DoesNotExist:
|
||||
return Response({"detail": "Course not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
# Ensure the current owner is the course owner
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='private-enrollment')
|
||||
def private_enrollment( self, request):
|
||||
"""
|
||||
Handles the private enrollment of a student into a specific course.
|
||||
|
||||
This custom action allows the owner of a paid course to manually enroll a student
|
||||
using their email address. The course ID is provided in the URL, and the student's
|
||||
email is received in the request body.
|
||||
"""
|
||||
course_id = request.data.get('course')
|
||||
student_email = request.data.get('student_email').strip()
|
||||
|
||||
# Check if the course & student exists
|
||||
course = Course.objects.filter(id=course_id).select_related('owner').first()
|
||||
student = User.objects.filter(email=student_email).first()
|
||||
|
||||
if not student:
|
||||
raise CustomValidationError("User not found", status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
if student_email == request.user.email:
|
||||
raise CustomValidationError("You can't add yourself", status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
if Enrollment.objects.filter(student__email=student_email, course=course).exists():
|
||||
raise CustomValidationError("This user already exists", status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if not course.is_paid:
|
||||
raise CustomValidationError("Course is not paid", status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Allow only the course owner to enroll students
|
||||
if course.owner != request.user:
|
||||
return Response({"detail": "You can only create certificate for your own courses"}, status=status.HTTP_403_FORBIDDEN)
|
||||
raise CustomValidationError("You do not have permission to enroll students in this course",
|
||||
status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
# Validate the data before saving
|
||||
enrollment_data = {
|
||||
'course': course.id,
|
||||
'student': student.id
|
||||
}
|
||||
serializer = PrivateEnrollmentSerializer(data=enrollment_data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(f"Student {student.full_name} has been added",
|
||||
status=status.HTTP_201_CREATED)
|
||||
|
||||
raise CustomValidationError(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@action(detail=False, methods=['get'], url_path='get-my-students')
|
||||
def get_my_students(self, request):
|
||||
"""
|
||||
Fetch detailed information about my students in a specific course.
|
||||
"""
|
||||
course_id = request.query_params.get('course')
|
||||
if not course_id:
|
||||
raise CustomValidationError(
|
||||
{"detail": "Course ID is required in the query parameters."},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
course = Course.objects.get(id=course_id, owner=request.user)
|
||||
except Course.DoesNotExist:
|
||||
raise CustomValidationError(
|
||||
{"detail": "Course not found or you do not have permission to access it."},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
my_students = (
|
||||
Enrollment.objects.filter(course=course)
|
||||
.select_related('student')
|
||||
.values('student__full_name', 'student__email')
|
||||
.distinct()
|
||||
)
|
||||
|
||||
return Response(list(my_students), status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
certificate = Certificate.objects.create(course=course, student=student)
|
||||
serializer = self.get_serializer(certificate)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue