This commit is contained in:
Ahmed Nagi 2025-01-21 11:12:45 +00:00
parent 060f4301e9
commit aae1451eeb
29 changed files with 234 additions and 349 deletions

View file

@ -12,3 +12,5 @@ REDIS_URL=redis://redis:6379/0
# Flower # Flower
CELERY_FLOWER_USER=debug CELERY_FLOWER_USER=debug
CELERY_FLOWER_PASSWORD=debug CELERY_FLOWER_PASSWORD=debug
SIGNING_KEY=ebd0c2f345ede5we3244t5r34a0dc1b994e33e729e

View file

@ -4,13 +4,13 @@
DJANGO_SETTINGS_MODULE=config.settings.production DJANGO_SETTINGS_MODULE=config.settings.production
DJANGO_SECRET_KEY=CQHQz4M3wN1VL2TT53Gl8yupKOjQ5m01js4jPw6bQsUexzkdy9JGXhQg9h6H24M5 DJANGO_SECRET_KEY=CQHQz4M3wN1VL2TT53Gl8yupKOjQ5m01js4jPw6bQsUexzkdy9JGXhQg9h6H24M5
DJANGO_ADMIN_URL=6XfjlokEGlPf6SpVfGh7wBvs7t5ZFMDs/ DJANGO_ADMIN_URL=6XfjlokEGlPf6SpVfGh7wBvs7t5ZFMDs/
DJANGO_ALLOWED_HOSTS=.example.com DJANGO_ALLOWED_HOSTS=.learngpt.tech
SIGNING_KEY=HQz4M3wN1ebd0c2f345ede5we324@#$%$#@#R$Q#Zaexsredg/*43/54333e729e
# Security # Security
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# TIP: better off using DNS, however, redirect is OK too # TIP: better off using DNS, however, redirect is OK too
DJANGO_SECURE_SSL_REDIRECT=False DJANGO_SECURE_SSL_REDIRECT=False
# Email # Email
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
DJANGO_SERVER_EMAIL= DJANGO_SERVER_EMAIL=

View file

@ -6,12 +6,16 @@
packages = [ packages = [
pkgs.docker pkgs.docker
pkgs.docker-compose pkgs.docker-compose
pkgs.sudo
]; ];
# Sets environment variables in the workspace # Sets environment variables in the workspace
env = {}; env = {
PORT = "6000";
};
services.docker.enable = true; services.docker.enable = true;
idx = { idx = {
# Search for the extensions you want on https://open-vsx.org/ and use "publisher.id" # Search for the extensions you want on https://open-vsx.org/ and use "publisher.id"
extensions = [ extensions = [

View file

@ -1,60 +1,21 @@
# define an alias for the specific python version used in this file. # Use a lightweight Python base image
FROM docker.io/python:3.12.8-slim-bookworm AS python FROM python:3.12.8-alpine AS python
# Python build stage
FROM python AS python-build-stage
# Python base stage
ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install --no-install-recommends -y \
# dependencies for building Python packages
build-essential \
# psycopg dependencies
libpq-dev \
# cleaning up unused files
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& rm -rf /var/lib/apt/lists/*
# Requirements are installed here to ensure they will be cached.
COPY ./requirements /requirements
# create python dependency wheels
RUN pip wheel --no-cache-dir --wheel-dir /usr/src/app/wheels \
-r /requirements/local.txt -r /requirements/production.txt \
&& rm -rf /requirements
# Python 'run' stage
FROM python AS python-run-stage
ARG BUILD_ENVIRONMENT
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
RUN apt-get update && apt-get install --no-install-recommends -y \ # Install dependencies
# To run the Makefile RUN apk update && apk add --no-cache \
make \ # Runtime dependencies
# psycopg dependencies make \
libpq-dev \ gettext \
# Translations dependencies && rm -rf /var/cache/apk/*
gettext \
# Uncomment below lines to enable Sphinx output to latex and pdf
# texlive-latex-recommended \
# texlive-fonts-recommended \
# texlive-latex-extra \
# latexmk \
# cleaning up unused files
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& rm -rf /var/lib/apt/lists/*
# copy python dependency wheels from python-build-stage # Install MkDocs and required plugins
COPY --from=python-build-stage /usr/src/app/wheels /wheels RUN pip install mkdocs==1.5.1 mkdocs-material==9.1.15 mkdocs-markdownextradata-plugin
# use wheels to install python dependencies
RUN pip install --no-cache /wheels/* \
&& rm -rf /wheels
# Copy the start script
COPY ./compose/local/docs/start /start-docs COPY ./compose/local/docs/start /start-docs
RUN sed -i 's/\r$//g' /start-docs RUN sed -i 's/\r$//g' /start-docs
RUN chmod +x /start-docs RUN chmod +x /start-docs

View file

@ -4,4 +4,5 @@ set -o errexit
set -o pipefail set -o pipefail
set -o nounset set -o nounset
exec make livehtml # Start MkDocs live development server
exec mkdocs serve -a 0.0.0.0:6000

View file

@ -31,7 +31,7 @@ certificatesResolvers:
http: http:
routers: routers:
web-secure-router: web-secure-router:
rule: 'Host(`example.com`) || Host(`www.example.com`)' rule: 'Host(`learngpt.tech`) || Host(`www.learngpt.tech`)'
entryPoints: entryPoints:
- web-secure - web-secure
middlewares: middlewares:
@ -42,7 +42,7 @@ http:
certResolver: letsencrypt certResolver: letsencrypt
flower-secure-router: flower-secure-router:
rule: 'Host(`example.com`)' rule: 'Host(`learngpt.tech`)'
entryPoints: entryPoints:
- flower - flower
service: flower service: flower
@ -51,7 +51,7 @@ http:
certResolver: letsencrypt certResolver: letsencrypt
web-media-router: web-media-router:
rule: '(Host(`example.com`) || Host(`www.example.com`)) && PathPrefix(`/media/`)' rule: '(Host(`learngpt.tech`) || Host(`www.learngpt.tech`)) && PathPrefix(`/media/`)'
entryPoints: entryPoints:
- web-secure - web-secure
middlewares: middlewares:

View file

@ -380,6 +380,7 @@ REST_FRAMEWORK = {
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
'EXCEPTION_HANDLER': 'utils.exception_handler.custom_exception_handler', 'EXCEPTION_HANDLER': 'utils.exception_handler.custom_exception_handler',
} }
REST_AUTH = { REST_AUTH = {
'LOGIN_SERIALIZER': 'lms.accounts.serializers.CustomLoginSerializer', 'LOGIN_SERIALIZER': 'lms.accounts.serializers.CustomLoginSerializer',
'REGISTER_SERIALIZER': 'lms.accounts.serializers.CustomRegisterSerializer', 'REGISTER_SERIALIZER': 'lms.accounts.serializers.CustomRegisterSerializer',
@ -395,11 +396,11 @@ SIMPLE_JWT = {
'ROTATE_REFRESH_TOKENS': True, 'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True, 'BLACKLIST_AFTER_ROTATION': True,
'ALGORITHM': 'HS256', 'ALGORITHM': 'HS256',
'SIGNING_KEY': 'SECRET_KEY', 'SIGNING_KEY': 'env("SIGNING_KEY")',
} }
# django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup # 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 # 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 # See more configuration options at https://drf-spectacular.readthedocs.io/en/latest/settings.html#settings

View file

@ -19,7 +19,7 @@ from .base import env
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
SECRET_KEY = env("DJANGO_SECRET_KEY") SECRET_KEY = env("DJANGO_SECRET_KEY")
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["example.com"]) ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["learngpt.tech"])
# DATABASES # DATABASES
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@ -87,7 +87,7 @@ STORAGES = {
# https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email # https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email
DEFAULT_FROM_EMAIL = env( DEFAULT_FROM_EMAIL = env(
"DJANGO_DEFAULT_FROM_EMAIL", "DJANGO_DEFAULT_FROM_EMAIL",
default="Learning Management System <noreply@example.com>", default="Learning Management System <noreply@learngpt.tech>",
) )
# https://docs.djangoproject.com/en/dev/ref/settings/#server-email # https://docs.djangoproject.com/en/dev/ref/settings/#server-email
SERVER_EMAIL = env("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL) SERVER_EMAIL = env("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL)
@ -200,7 +200,7 @@ sentry_sdk.init(
# ------------------------------------------------------------------------------- # -------------------------------------------------------------------------------
# Tools that generate code samples can use SERVERS to point to the correct domain # Tools that generate code samples can use SERVERS to point to the correct domain
SPECTACULAR_SETTINGS["SERVERS"] = [ SPECTACULAR_SETTINGS["SERVERS"] = [
{"url": "https://example.com", "description": "Production server"}, {"url": "https://learngpt.tech", "description": "Production server"},
] ]
# Your stuff... # Your stuff...
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------

View file

@ -22,9 +22,7 @@ urlpatterns = [
path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), path("", TemplateView.as_view(template_name="pages/home.html"), name="home"),
# Django Admin, use {% url 'admin:index' %} # Django Admin, use {% url 'admin:index' %}
path(settings.ADMIN_URL, admin.site.urls), path(settings.ADMIN_URL, admin.site.urls),
# User management
# path("users/", include("lms.users.urls", namespace="users")),
path("accounts/", include("allauth.urls")),
# path("auth/", include("allauth.headless.urls")), # path("auth/", include("allauth.headless.urls")),
# Your stuff: custom urls includes go here # Your stuff: custom urls includes go here
# ... # ...
@ -37,16 +35,12 @@ if settings.DEBUG:
# API URLS # API URLS
urlpatterns += [ urlpatterns += [
path("api/accounts/", include("allauth.urls")),
# path('authw/', include('dj_rest_auth.urls')), path('api/auth/', include('lms.accounts.urls')),
# path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
path('auth/', include('lms.accounts.urls')),
path('app/', include('lms.app.urls')), path('api/app/', include('lms.app.urls')),
# API base url
# path("api/auth-token/", obtain_auth_token),
path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"),
path( path(
"api/docs/", "api/docs/",

View file

@ -12,5 +12,5 @@ services:
- ./config:/app/config:z - ./config:/app/config:z
- ./lms:/app/lms:z - ./lms:/app/lms:z
ports: ports:
- '9000:9000' - '6000:6000'
command: /start-docs command: mkdocs serve

View file

@ -1,29 +0,0 @@
# Minimal makefile for Sphinx documentation
#
# You can set these variables from the command line, and also
# from the environment for the first two.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build
SOURCEDIR = .
BUILDDIR = ./_build
APP = /app
.PHONY: html livehtml apidocs Makefile
# Put it first so that "make" without argument is like "make html".
html:
@$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -c .
# Build, watch and serve docs with live reload
livehtml:
sphinx-autobuild -b html --host 0.0.0.0 --port 9000 --watch $(APP) -c . $(SOURCEDIR) $(BUILDDIR)/html
# Outputs rst files from django application code
apidocs:
sphinx-apidoc -o $(SOURCEDIR)/api $(APP)
# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -c .

View file

@ -1 +0,0 @@
# Included so that Django's startproject comment runs against the docs directory

View file

@ -1,63 +0,0 @@
# ruff: noqa
# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
# -- Path setup --------------------------------------------------------------
# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
import os
import sys
import django
if os.getenv("READTHEDOCS", default=False) == "True":
sys.path.insert(0, os.path.abspath(".."))
os.environ["DJANGO_READ_DOT_ENV_FILE"] = "True"
os.environ["USE_DOCKER"] = "no"
else:
sys.path.insert(0, os.path.abspath("/app"))
os.environ["DATABASE_URL"] = "sqlite:///readthedocs.db"
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
django.setup()
# -- Project information -----------------------------------------------------
project = "Learning Management System"
copyright = """2025, Ahmed Nagi"""
author = "Ahmed Nagi"
# -- General configuration ---------------------------------------------------
# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
"sphinx.ext.autodoc",
"sphinx.ext.napoleon",
]
# Add any paths that contain templates here, relative to this directory.
# templates_path = ["_templates"]
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# -- Options for HTML output -------------------------------------------------
# The theme to use for HTML and HTML Help pages. See the documentation for
# a list of builtin themes.
#
html_theme = "alabaster"
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
# html_static_path = ["_static"]

View file

@ -1,38 +0,0 @@
How To - Project Documentation
======================================================================
Get Started
----------------------------------------------------------------------
Documentation can be written as rst files in `lms/docs`.
To build and serve docs, use the commands::
docker compose -f docker-compose.local.yml up docs
Changes to files in `docs/_source` will be picked up and reloaded automatically.
`Sphinx <https://www.sphinx-doc.org/>`_ is the tool used to build documentation.
Docstrings to Documentation
----------------------------------------------------------------------
The sphinx extension `apidoc <https://www.sphinx-doc.org/en/master/man/sphinx-apidoc.html>`_ is used to automatically document code using signatures and docstrings.
Numpy or Google style docstrings will be picked up from project files and available for documentation. See the `Napoleon <https://sphinxcontrib-napoleon.readthedocs.io/en/latest/>`_ extension for details.
For an in-use example, see the `page source <_sources/users.rst.txt>`_ for :ref:`users`.
To compile all docstrings automatically into documentation source files, use the command:
::
make apidocs
This can be done in the docker container:
::
docker run --rm docs make apidocs

View file

@ -1,23 +0,0 @@
.. Learning Management System documentation master file, created by
sphinx-quickstart.
You can adapt this file completely to your liking, but it should at least
contain the root `toctree` directive.
Welcome to Learning Management System's documentation!
======================================================================
.. toctree::
:maxdepth: 2
:caption: Contents:
howto
users
Indices and tables
==================
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

View file

@ -1,46 +0,0 @@
@ECHO OFF
pushd %~dp0
REM Command file for Sphinx documentation
if "%SPHINXBUILD%" == "" (
set SPHINXBUILD=sphinx-build -c .
)
set SOURCEDIR=_source
set BUILDDIR=_build
set APP=..\lms
if "%1" == "" goto html
%SPHINXBUILD% >NUL 2>NUL
if errorlevel 9009 (
echo.
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
echo.installed, then set the SPHINXBUILD environment variable to point
echo.to the full path of the 'sphinx-build' executable. Alternatively you
echo.may add the Sphinx directory to PATH.
echo.
echo.Install sphinx-autobuild for live serving.
echo.If you don't have Sphinx installed, grab it from
echo.http://sphinx-doc.org/
exit /b 1
)
%SPHINXBUILD% -b %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
goto end
:livehtml
sphinx-autobuild -b html --open-browser -p 9000 --watch %APP% -c . %SOURCEDIR% %BUILDDIR%/html
GOTO :EOF
:apidocs
sphinx-apidoc -o %SOURCEDIR%/api %APP%
GOTO :EOF
:html
%SPHINXBUILD% -b html %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
:end
popd

67
docs/mkdocs.yml Normal file
View 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

View file

@ -1,15 +0,0 @@
.. _users:
Users
======================================================================
Starting a new project, its highly recommended to set up a custom user model,
even if the default User model is sufficient for you.
This model behaves identically to the default user model,
but youll be able to customize it in the future if the need arises.
.. automodule:: lms.accounts.models
:members:
:noindex:

View file

@ -78,10 +78,10 @@ class CustomRegisterSerializer(RegisterSerializer):
email_address = EmailAddress.objects.filter(email=email).first() email_address = EmailAddress.objects.filter(email=email).first()
if email_address: if email_address:
if email_address.verified: if email_address.verified:
CustomValidationError({'email': 'This email is already.'}) raise CustomValidationError({'email': 'This email is already.'})
else: else:
send_email_confirmation(request, email_address.user) send_email_confirmation(request, email_address.user)
CustomValidationError({'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 = super().save(request)
user.full_name = self.data.get('full_name', '') user.full_name = self.data.get('full_name', '')

View file

@ -7,7 +7,7 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from .serializers import ChangeEmailSerializer from .serializers import ChangeEmailSerializer
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from lms.utils.exception_handler import CustomValidationError
User = get_user_model() User = get_user_model()
class UserView(APIView): class UserView(APIView):
@ -53,7 +53,7 @@ class ChangeEmailView(APIView):
"message": "Confirmation email has been sent to the new address.", "message": "Confirmation email has been sent to the new address.",
}, status=status.HTTP_200_OK) }, 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)
@ -63,7 +63,7 @@ class ConfirmEmailAPIView(APIView):
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
key = request.data.get("key") key = request.data.get("key")
if not 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: try:
# Attempt to retrieve the email confirmation using HMAC key # Attempt to retrieve the email confirmation using HMAC key
@ -72,7 +72,7 @@ class ConfirmEmailAPIView(APIView):
# If HMAC fails, fallback to database key # If HMAC fails, fallback to database key
email_confirmation = EmailConfirmation.objects.get(key=key) email_confirmation = EmailConfirmation.objects.get(key=key)
except EmailConfirmation.DoesNotExist: 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: if email_confirmation.email_address.verified:
return Response({"detail": _("Email is already verified.")}, status=status.HTTP_200_OK) return Response({"detail": _("Email is already verified.")}, status=status.HTTP_200_OK)

View file

@ -0,0 +1,31 @@
# Generated by Django 5.0.10 on 2025-01-21 09:33
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('app', '0002_lesson_quiz_alter_enrollment_student_delete_quiz'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Advertisement',
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.AlterField(
model_name='course',
name='owner',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owner', to=settings.AUTH_USER_MODEL, verbose_name='Owner'),
),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 5.0.10 on 2025-01-21 10:43
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('app', '0003_advertisement_alter_course_owner'),
]
operations = [
migrations.RenameModel(
old_name='Advertisement',
new_name='AD',
),
]

View file

@ -5,7 +5,6 @@ from rest_framework.exceptions import ValidationError
User = get_user_model() User = get_user_model()
# Table for courses (Course)
class Course(models.Model): class Course(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
title = models.CharField(max_length=255, verbose_name="Course Title") 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) image = models.ImageField(upload_to="courses/image", null=True)
is_paid = models.BooleanField(default=False) is_paid = models.BooleanField(default=False)
price = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True) 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) rating = models.PositiveSmallIntegerField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created At") created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created At")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Updated 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.'}) raise ValidationError({'price': 'Price must be empty for free products.'})
# Table for modules (Module)
class Module(models.Model): class Module(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
title = models.CharField(max_length=255, verbose_name="Module Title") title = models.CharField(max_length=255, verbose_name="Module Title")
@ -40,7 +38,6 @@ class Module(models.Model):
def str(self): def str(self):
return self.title return self.title
# Table for lessons (Lesson)
class Lesson(models.Model): class Lesson(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
title = models.CharField(max_length=255, verbose_name="Lesson Title") title = models.CharField(max_length=255, verbose_name="Lesson Title")
@ -54,7 +51,6 @@ class Lesson(models.Model):
def str(self): def str(self):
return self.title return self.title
# Table for enrollments (Enrollment)
class Enrollment(models.Model): class Enrollment(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
student = models.ForeignKey(User, on_delete=models.CASCADE, related_name='students_enrollments', verbose_name="Student") student = models.ForeignKey(User, on_delete=models.CASCADE, related_name='students_enrollments', verbose_name="Student")
@ -65,7 +61,6 @@ class Enrollment(models.Model):
def str(self): def str(self):
return f"{self.student.username} - {self.course.title}" return f"{self.student.username} - {self.course.title}"
# Table for certificates (Certificate)
class Certificate(models.Model): class Certificate(models.Model):
id = models.UUIDField(primary_key=True, default=uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid4, editable=False)
student = models.ForeignKey(User, on_delete=models.CASCADE, related_name='certificates', verbose_name="Student") student = models.ForeignKey(User, on_delete=models.CASCADE, related_name='certificates', verbose_name="Student")
@ -75,3 +70,16 @@ class Certificate(models.Model):
def str(self): def str(self):
return f"{self.student.username} - {self.course.title}" 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

View file

@ -27,10 +27,6 @@ class IsOwnerOrReadOnly(BasePermission):
class IsAdmin(BasePermission): class IsAdmin(BasePermission):
""" """
Custom permission to allow access only to users with role 'instructor'. Custom permission to allow access only to users with role 'instructor'.

View file

@ -5,7 +5,7 @@ from django.contrib.auth import authenticate
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from allauth.account.models import EmailAddress from allauth.account.models import EmailAddress
from dj_rest_auth.registration.serializers import RegisterSerializer from dj_rest_auth.registration.serializers import RegisterSerializer
from lms.utils.exception_handler import CustomValidationError

8
lms/app/tasks.py Normal file
View file

@ -0,0 +1,8 @@
# tasks.py
from celery import shared_task
@shared_task
def print_message(message):
print(f"الرسالة هي: {message}")
return message

View file

@ -13,6 +13,7 @@ from django.contrib.auth import get_user_model
from lms.utils.exception_handler import CustomValidationError from lms.utils.exception_handler import CustomValidationError
User = get_user_model() User = get_user_model()
class CourseViewSet(ModelViewSet): class CourseViewSet(ModelViewSet):
@ -32,21 +33,14 @@ class CourseViewSet(ModelViewSet):
@action(detail=False, methods=['get'], url_path='my-courses', url_name='my_courses') @action(detail=False, methods=['get'], url_path='my-courses', url_name='my_courses')
def get_my_course(self, request): def get_my_course(self, request):
""" my_courses = Course.objects.filter(owner=request.user).prefetch_related('enrollments__student')
Custom GET method to fetch detailed information about my courses.
"""
my_courses = Course.objects.filter(owner=request.user)
total_students = Enrollment.objects.filter(course__in=my_courses).values('student').distinct().count() total_students = Enrollment.objects.filter(course__in=my_courses).values('student').distinct().count()
# Serialize the data
serializer = self.get_serializer(my_courses, many=True) serializer = self.get_serializer(my_courses, many=True)
response_data = { response_data = {
"total_students": total_students, # Add the total count of students "total_students": total_students,
"courses": serializer.data # Include detailed courses data "courses": serializer.data
} }
return Response(response_data) return Response(response_data)
@ -65,9 +59,9 @@ class ModuleViewSet(ModelViewSet):
""" """
course_id = self.request.query_params.get('pk') course_id = self.request.query_params.get('pk')
if course_id: if course_id:
course = Course.objects.filter(id=course_id).first() course = Course.objects.filter(id=course_id).select_related('owner').first()
if course: if course:
return Module.objects.filter(course=course) return Module.objects.filter(course=course).select_related('course')
return Module.objects.none() return Module.objects.none()
@ -82,7 +76,7 @@ class ModuleViewSet(ModelViewSet):
raise PermissionDenied("You do not have permission to create module.") raise PermissionDenied("You do not have permission to create module.")
if not course: if not course:
return Response( raise CustomValidationError(
{"detail": "This course not found."}, {"detail": "This course not found."},
status=status.HTTP_404_NOT_FOUND, status=status.HTTP_404_NOT_FOUND,
) )
@ -116,7 +110,7 @@ class LessonViewSet(ModelViewSet):
return Lesson.objects.none() # Return no results if the module does not exist return Lesson.objects.none() # Return no results if the module does not exist
# Verify that the lesson exists and is associated with the module # 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: if not lesson:
return Lesson.objects.none() # Return no results if the lesson does not exist or is not linked to the module return Lesson.objects.none() # Return no results if the lesson does not exist or is not linked to the module
@ -154,12 +148,12 @@ class LessonViewSet(ModelViewSet):
# الحصول على معرف الكائن (lesson_id) من الـ URL # الحصول على معرف الكائن (lesson_id) من الـ URL
lesson_id = self.request.query_params.get('lesson_id') lesson_id = self.request.query_params.get('lesson_id')
if not 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() lesson = Lesson.objects.filter(id=lesson_id).first()
if not lesson: 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 is_owner = lesson.module.course.owner == request.user
@ -173,7 +167,7 @@ class LessonViewSet(ModelViewSet):
serializer.save() serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK) 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)
@ -186,7 +180,7 @@ class LessonViewSet(ModelViewSet):
# الحصول على معرف الكائن (lesson_id) من الـ URL # الحصول على معرف الكائن (lesson_id) من الـ URL
lesson_id = request.query_params.get('lesson_id') lesson_id = request.query_params.get('lesson_id')
if not lesson_id: if not lesson_id:
return Response( raise CustomValidationError(
{"detail": "Lesson ID is required in the URL."}, {"detail": "Lesson ID is required in the URL."},
status=status.HTTP_400_BAD_REQUEST status=status.HTTP_400_BAD_REQUEST
) )
@ -195,7 +189,7 @@ class LessonViewSet(ModelViewSet):
try: try:
lesson = Lesson.objects.get(id=lesson_id) lesson = Lesson.objects.get(id=lesson_id)
except Lesson.DoesNotExist: except Lesson.DoesNotExist:
return Response( raise CustomValidationError(
{"detail": "Lesson not found."}, {"detail": "Lesson not found."},
status=status.HTTP_404_NOT_FOUND status=status.HTTP_404_NOT_FOUND
) )
@ -223,7 +217,7 @@ class EnrollmentViewSet(ModelViewSet):
http_method_names = ['get', 'post', 'delete'] http_method_names = ['get', 'post', 'delete']
def list(self, request, *args, **kwargs): 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) serializer = self.get_serializer(instance, many=True)
return Response(serializer.data) return Response(serializer.data)
@ -237,16 +231,16 @@ class EnrollmentViewSet(ModelViewSet):
try: try:
course = Course.objects.get(id=course_id) course = Course.objects.get(id=course_id)
except Course.DoesNotExist: 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: if course.is_paid:
return Response({"detail": "This is paid"}, status=status.HTTP_404_NOT_FOUND) raise CustomValidationError({"detail": "This is paid"}, status=status.HTTP_404_NOT_FOUND)
if Enrollment.objects.filter(student=request.user, course=course).exists(): 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) raise CustomValidationError({"detail": "You are already subscribed to this course."}, status=status.HTTP_404_NOT_FOUND)
if course.owner == request.user: if course.owner == request.user:
return Response({"detail": "You can't enroll in your course"}, status=status.HTTP_404_NOT_FOUND) raise CustomValidationError({"detail": "You can't enroll in your course"}, status=status.HTTP_404_NOT_FOUND)
# Create a new enrollment # Create a new enrollment
enrollment = Enrollment.objects.create(student=request.user, course=course) enrollment = Enrollment.objects.create(student=request.user, course=course)
@ -267,7 +261,7 @@ class EnrollmentViewSet(ModelViewSet):
student_email = request.data.get('student_email').strip() student_email = request.data.get('student_email').strip()
# Check if the course & student exists # Check if the course & student exists
course = Course.objects.filter(id=course_id).first() course = Course.objects.filter(id=course_id).select_related('owner').first()
student = User.objects.filter(email=student_email).first() student = User.objects.filter(email=student_email).first()
if not student: if not student:
@ -304,17 +298,31 @@ class EnrollmentViewSet(ModelViewSet):
@action(detail=False, methods=['get'], url_path='get-my-students') @action(detail=False, methods=['get'], url_path='get-my-students')
def get_my_students(self, request): def get_my_students(self, request):
""" """
fetch detailed information about my students in my courses. Fetch detailed information about my students in a specific course.
""" """
course = request.query_params.get('course') course_id = request.query_params.get('course')
my_courses = Course.objects.filter(owner=request.user, id=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 = ( my_students = (
Enrollment.objects.filter(course__in=my_courses) Enrollment.objects.filter(course=course)
.values('student__full_name', 'student__email') .select_related('student')
.distinct() .values('student__full_name', 'student__email')
) .distinct()
)
return Response(list(my_students), status=200) return Response(list(my_students), status=status.HTTP_200_OK)

View file

@ -6,7 +6,7 @@ http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-djan
from django.conf import settings from django.conf import settings
from django.db import migrations from django.db import migrations
domain_name = "example.com" # Front end domain domain_name = "learngpt.tech" # Front end domain
def _update_or_create_site_with_sequence(site_model, connection, domain, name): def _update_or_create_site_with_sequence(site_model, connection, domain, name):
"""Update or create the site with default ID and keep the DB sequence in sync.""" """Update or create the site with default ID and keep the DB sequence in sync."""

View file

@ -5,18 +5,20 @@ from rest_framework.response import Response
class CustomValidationError(APIException): class CustomValidationError(APIException):
status_code = status.HTTP_400_BAD_REQUEST default_status = status.HTTP_400_BAD_REQUEST
default_detail = 'A validation error occurred.' default_detail = 'A validation error occurred.'
def __init__(self, detail=None, status=None): def __init__(self, detail=None, status=None):
self.detail = detail if detail is not None else self.default_detail self.detail = detail if detail is not None else self.default_detail
self.status = status if status is not None else self.status self.status = status if status is not None else self.default_status
def __str__(self): def __str__(self):
return f"{self.detail} (Status: {self.status})" return f"{self.detail} (Status: {self.status})"
@property
def status_code(self):
# Map `status` to `status_code` to ensure compatibility with DRF
return self.status
def custom_exception_handler(exc, context): def custom_exception_handler(exc, context):
@ -28,9 +30,9 @@ def custom_exception_handler(exc, context):
return Response( return Response(
{ {
'error': exc.detail, 'error': exc.detail,
'status_code': exc.status_code, 'status_code': exc.status,
}, },
status=exc.status_code, status=exc.status,
) )
# Return the default response for other exceptions # Return the default response for other exceptions