diff --git a/.envs/.local/.django b/.envs/.local/.django index 247287b..4181a6e 100644 --- a/.envs/.local/.django +++ b/.envs/.local/.django @@ -12,3 +12,5 @@ REDIS_URL=redis://redis:6379/0 # Flower CELERY_FLOWER_USER=debug CELERY_FLOWER_PASSWORD=debug + +SIGNING_KEY=ebd0c2f345ede5we3244t5r34a0dc1b994e33e729e \ No newline at end of file diff --git a/.envs/.production/.django b/.envs/.production/.django index 2e19e37..9150a91 100644 --- a/.envs/.production/.django +++ b/.envs/.production/.django @@ -4,13 +4,13 @@ DJANGO_SETTINGS_MODULE=config.settings.production DJANGO_SECRET_KEY=CQHQz4M3wN1VL2TT53Gl8yupKOjQ5m01js4jPw6bQsUexzkdy9JGXhQg9h6H24M5 DJANGO_ADMIN_URL=6XfjlokEGlPf6SpVfGh7wBvs7t5ZFMDs/ -DJANGO_ALLOWED_HOSTS=.example.com +DJANGO_ALLOWED_HOSTS=.learngpt.tech +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= diff --git a/.idx/dev.nix b/.idx/dev.nix index 9de9c1d..54807cc 100644 --- a/.idx/dev.nix +++ b/.idx/dev.nix @@ -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 = [ diff --git a/compose/local/docs/Dockerfile b/compose/local/docs/Dockerfile index ba9e579..6d71df6 100644 --- a/compose/local/docs/Dockerfile +++ b/compose/local/docs/Dockerfile @@ -1,60 +1,21 @@ -# define an alias for the specific python version used in this file. -FROM docker.io/python:3.12.8-slim-bookworm AS python - - -# Python build stage -FROM python AS python-build-stage +# Use a lightweight Python base image +FROM python:3.12.8-alpine AS python +# Python base stage 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 PYTHONDONTWRITEBYTECODE=1 -RUN apt-get update && apt-get install --no-install-recommends -y \ - # To run the Makefile - make \ - # psycopg dependencies - libpq-dev \ - # Translations dependencies - 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/* +# Install dependencies +RUN apk update && apk add --no-cache \ + # Runtime dependencies + make \ + gettext \ + && rm -rf /var/cache/apk/* -# copy python dependency wheels from python-build-stage -COPY --from=python-build-stage /usr/src/app/wheels /wheels - -# use wheels to install python dependencies -RUN pip install --no-cache /wheels/* \ - && rm -rf /wheels +# 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 diff --git a/compose/local/docs/start b/compose/local/docs/start index 96a94f5..1e6eee9 100644 --- a/compose/local/docs/start +++ b/compose/local/docs/start @@ -4,4 +4,5 @@ set -o errexit set -o pipefail set -o nounset -exec make livehtml +# Start MkDocs live development server +exec mkdocs serve -a 0.0.0.0:6000 diff --git a/compose/production/traefik/traefik.yml b/compose/production/traefik/traefik.yml index dc0d2ec..1a1171f 100644 --- a/compose/production/traefik/traefik.yml +++ b/compose/production/traefik/traefik.yml @@ -31,7 +31,7 @@ certificatesResolvers: http: routers: web-secure-router: - rule: 'Host(`example.com`) || Host(`www.example.com`)' + rule: 'Host(`learngpt.tech`) || Host(`www.learngpt.tech`)' entryPoints: - web-secure middlewares: @@ -42,7 +42,7 @@ http: certResolver: letsencrypt flower-secure-router: - rule: 'Host(`example.com`)' + rule: 'Host(`learngpt.tech`)' entryPoints: - flower service: flower @@ -51,7 +51,7 @@ http: certResolver: letsencrypt web-media-router: - rule: '(Host(`example.com`) || Host(`www.example.com`)) && PathPrefix(`/media/`)' + rule: '(Host(`learngpt.tech`) || Host(`www.learngpt.tech`)) && PathPrefix(`/media/`)' entryPoints: - web-secure middlewares: diff --git a/config/settings/base.py b/config/settings/base.py index f0c5a72..d63d9ce 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -380,6 +380,7 @@ REST_FRAMEWORK = { "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', @@ -395,11 +396,11 @@ SIMPLE_JWT = { '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 diff --git a/config/settings/production.py b/config/settings/production.py index 9f6a3b7..5fdc9aa 100644 --- a/config/settings/production.py +++ b/config/settings/production.py @@ -19,7 +19,7 @@ from .base import env # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key SECRET_KEY = env("DJANGO_SECRET_KEY") # 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 # ------------------------------------------------------------------------------ @@ -87,7 +87,7 @@ STORAGES = { # https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email DEFAULT_FROM_EMAIL = env( "DJANGO_DEFAULT_FROM_EMAIL", - default="Learning Management System ", + default="Learning Management System ", ) # https://docs.djangoproject.com/en/dev/ref/settings/#server-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 SPECTACULAR_SETTINGS["SERVERS"] = [ - {"url": "https://example.com", "description": "Production server"}, + {"url": "https://learngpt.tech", "description": "Production server"}, ] # Your stuff... # ------------------------------------------------------------------------------ diff --git a/config/urls.py b/config/urls.py index e99fb69..15f2cb4 100644 --- a/config/urls.py +++ b/config/urls.py @@ -22,9 +22,7 @@ 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("users/", include("lms.users.urls", namespace="users")), - path("accounts/", include("allauth.urls")), + # path("auth/", include("allauth.headless.urls")), # Your stuff: custom urls includes go here # ... @@ -37,16 +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/auth-token/", obtain_auth_token), path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), path( "api/docs/", diff --git a/docker-compose.docs.yml b/docker-compose.docs.yml index 3122a51..37d0afc 100644 --- a/docker-compose.docs.yml +++ b/docker-compose.docs.yml @@ -12,5 +12,5 @@ services: - ./config:/app/config:z - ./lms:/app/lms:z ports: - - '9000:9000' - command: /start-docs + - '6000:6000' + command: mkdocs serve diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 67e63cb..0000000 --- a/docs/Makefile +++ /dev/null @@ -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 . diff --git a/docs/__init__.py b/docs/__init__.py deleted file mode 100644 index 8772c82..0000000 --- a/docs/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Included so that Django's startproject comment runs against the docs directory diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index e546820..0000000 --- a/docs/conf.py +++ /dev/null @@ -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"] diff --git a/docs/howto.rst b/docs/howto.rst deleted file mode 100644 index 6607748..0000000 --- a/docs/howto.rst +++ /dev/null @@ -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 `_ is the tool used to build documentation. - -Docstrings to Documentation ----------------------------------------------------------------------- - -The sphinx extension `apidoc `_ 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 `_ 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 diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 3b2ebea..0000000 --- a/docs/index.rst +++ /dev/null @@ -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` diff --git a/docs/make.bat b/docs/make.bat deleted file mode 100644 index 555427f..0000000 --- a/docs/make.bat +++ /dev/null @@ -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 diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml new file mode 100644 index 0000000..bb67ead --- /dev/null +++ b/docs/mkdocs.yml @@ -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 \ No newline at end of file diff --git a/docs/users.rst b/docs/users.rst deleted file mode 100644 index fd40414..0000000 --- a/docs/users.rst +++ /dev/null @@ -1,15 +0,0 @@ - .. _users: - -Users -====================================================================== - -Starting a new project, it’s 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 you’ll be able to customize it in the future if the need arises. - -.. automodule:: lms.accounts.models - :members: - :noindex: - diff --git a/lms/accounts/serializers.py b/lms/accounts/serializers.py index 1bad08e..2768710 100644 --- a/lms/accounts/serializers.py +++ b/lms/accounts/serializers.py @@ -78,10 +78,10 @@ class CustomRegisterSerializer(RegisterSerializer): email_address = EmailAddress.objects.filter(email=email).first() if email_address: if email_address.verified: - CustomValidationError({'email': 'This email is already.'}) + raise CustomValidationError({'email': 'This email is already.'}) else: 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.full_name = self.data.get('full_name', '') diff --git a/lms/accounts/views.py b/lms/accounts/views.py index f9ba14f..42959f3 100644 --- a/lms/accounts/views.py +++ b/lms/accounts/views.py @@ -7,7 +7,7 @@ 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): @@ -53,7 +53,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) @@ -63,7 +63,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 @@ -72,7 +72,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) diff --git a/lms/app/migrations/0003_advertisement_alter_course_owner.py b/lms/app/migrations/0003_advertisement_alter_course_owner.py new file mode 100644 index 0000000..da883d8 --- /dev/null +++ b/lms/app/migrations/0003_advertisement_alter_course_owner.py @@ -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'), + ), + ] diff --git a/lms/app/migrations/0004_rename_advertisement_ad.py b/lms/app/migrations/0004_rename_advertisement_ad.py new file mode 100644 index 0000000..6ed088f --- /dev/null +++ b/lms/app/migrations/0004_rename_advertisement_ad.py @@ -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', + ), + ] diff --git a/lms/app/models.py b/lms/app/models.py index f1582ec..9c72b7f 100644 --- a/lms/app/models.py +++ b/lms/app/models.py @@ -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") @@ -54,7 +51,6 @@ class Lesson(models.Model): 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='students_enrollments', verbose_name="Student") @@ -65,7 +61,6 @@ class Enrollment(models.Model): def str(self): return f"{self.student.username} - {self.course.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") @@ -75,3 +70,16 @@ 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 \ No newline at end of file diff --git a/lms/app/permissions.py b/lms/app/permissions.py index 39404c9..8cb96c4 100644 --- a/lms/app/permissions.py +++ b/lms/app/permissions.py @@ -25,10 +25,6 @@ class IsOwnerOrReadOnly(BasePermission): return obj.student == request.user - - - - class IsAdmin(BasePermission): diff --git a/lms/app/serializers.py b/lms/app/serializers.py index 5ebc5b0..f8b4fab 100644 --- a/lms/app/serializers.py +++ b/lms/app/serializers.py @@ -5,7 +5,7 @@ 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 diff --git a/lms/app/tasks.py b/lms/app/tasks.py new file mode 100644 index 0000000..cfdacb9 --- /dev/null +++ b/lms/app/tasks.py @@ -0,0 +1,8 @@ +# tasks.py + +from celery import shared_task + +@shared_task +def print_message(message): + print(f"الرسالة هي: {message}") + return message \ No newline at end of file diff --git a/lms/app/views.py b/lms/app/views.py index f5c5878..5975227 100644 --- a/lms/app/views.py +++ b/lms/app/views.py @@ -13,6 +13,7 @@ from django.contrib.auth import get_user_model from lms.utils.exception_handler import CustomValidationError + User = get_user_model() class CourseViewSet(ModelViewSet): @@ -32,21 +33,14 @@ class CourseViewSet(ModelViewSet): @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) - + 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() - - # Serialize the data + serializer = self.get_serializer(my_courses, many=True) response_data = { - "total_students": total_students, # Add the total count of students - "courses": serializer.data # Include detailed courses data + "total_students": total_students, + "courses": serializer.data } - return Response(response_data) @@ -65,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() @@ -82,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, ) @@ -116,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 @@ -154,12 +148,12 @@ class LessonViewSet(ModelViewSet): # الحصول على معرف الكائن (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 @@ -173,7 +167,7 @@ class LessonViewSet(ModelViewSet): 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) @@ -186,7 +180,7 @@ 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 ) @@ -195,7 +189,7 @@ class LessonViewSet(ModelViewSet): 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 ) @@ -223,7 +217,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) @@ -237,16 +231,16 @@ 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: - 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(): - 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: - 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 enrollment = Enrollment.objects.create(student=request.user, course=course) @@ -267,7 +261,7 @@ class EnrollmentViewSet(ModelViewSet): student_email = request.data.get('student_email').strip() # 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() if not student: @@ -304,17 +298,31 @@ class EnrollmentViewSet(ModelViewSet): @action(detail=False, methods=['get'], url_path='get-my-students') 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') - my_courses = Course.objects.filter(owner=request.user, id=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__in=my_courses) - .values('student__full_name', 'student__email') - .distinct() - ) + Enrollment.objects.filter(course=course) + .select_related('student') + .values('student__full_name', 'student__email') + .distinct() + ) - return Response(list(my_students), status=200) + return Response(list(my_students), status=status.HTTP_200_OK) diff --git a/lms/contrib/sites/migrations/0003_set_site_domain_and_name.py b/lms/contrib/sites/migrations/0003_set_site_domain_and_name.py index 7ae509f..b684ceb 100644 --- a/lms/contrib/sites/migrations/0003_set_site_domain_and_name.py +++ b/lms/contrib/sites/migrations/0003_set_site_domain_and_name.py @@ -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.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): """Update or create the site with default ID and keep the DB sequence in sync.""" diff --git a/lms/utils/exception_handler.py b/lms/utils/exception_handler.py index e4c3d6c..8c1d5fd 100644 --- a/lms/utils/exception_handler.py +++ b/lms/utils/exception_handler.py @@ -5,18 +5,20 @@ from rest_framework.response import Response class CustomValidationError(APIException): - status_code = status.HTTP_400_BAD_REQUEST + default_status = status.HTTP_400_BAD_REQUEST default_detail = 'A validation error occurred.' def __init__(self, detail=None, status=None): 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): 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): @@ -28,10 +30,10 @@ def custom_exception_handler(exc, context): return Response( { '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 response \ No newline at end of file + return response