This commit is contained in:
Ahmed Nagi 2025-01-10 19:54:55 +02:00
parent 5133a243ab
commit e58c9c1f71
153 changed files with 6115 additions and 0 deletions

View file

@ -0,0 +1,20 @@
#
# .bashrc.override.sh
#
# persistent bash history
HISTFILE=~/.bash_history
PROMPT_COMMAND="history -a; $PROMPT_COMMAND"
# set some django env vars
source /entrypoint
# restore default shell options
set +o errexit
set +o pipefail
set +o nounset
# start ssh-agent
# https://code.visualstudio.com/docs/remote/troubleshooting
eval "$(ssh-agent -s)"

View file

@ -0,0 +1,68 @@
// For format details, see https://containers.dev/implementors/json_reference/
{
"name": "lms_dev",
"dockerComposeFile": [
"../docker-compose.local.yml"
],
"init": true,
"mounts": [
{
"source": "./.devcontainer/bash_history",
"target": "/home/dev-user/.bash_history",
"type": "bind"
},
{
"source": "~/.ssh",
"target": "/home/dev-user/.ssh",
"type": "bind"
}
],
// Tells devcontainer.json supporting services / tools whether they should run
// /bin/sh -c "while sleep 1000; do :; done" when starting the container instead of the containers default command
"overrideCommand": false,
"service": "django",
// "remoteEnv": {"PATH": "/home/dev-user/.local/bin:${containerEnv:PATH}"},
"remoteUser": "dev-user",
"workspaceFolder": "/app",
// Set *default* container specific settings.json values on container create.
"customizations": {
"vscode": {
"settings": {
"editor.formatOnSave": true,
"[python]": {
"analysis.autoImportCompletions": true,
"analysis.typeCheckingMode": "basic",
"defaultInterpreterPath": "/usr/local/bin/python",
"editor.codeActionsOnSave": {
"source.organizeImports": "always"
},
"editor.defaultFormatter": "charliermarsh.ruff",
"languageServer": "Pylance",
"linting.enabled": true,
"linting.mypyEnabled": true,
"linting.mypyPath": "/usr/local/bin/mypy",
}
},
// https://code.visualstudio.com/docs/remote/devcontainerjson-reference#_vs-code-specific-properties
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"davidanson.vscode-markdownlint",
"mrmlnc.vscode-duplicate",
"visualstudioexptteam.vscodeintellicode",
"visualstudioexptteam.intellicode-api-usage-examples",
// python
"ms-python.python",
"ms-python.vscode-pylance",
"charliermarsh.ruff",
// django
"batisteo.vscode-django"
]
}
},
// Uncomment the next line if you want start specific services in your Docker Compose config.
// "runServices": [],
// Uncomment the next line if you want to keep your containers running after VS Code shuts down.
// "shutdownAction": "none",
// Uncomment the next line to run commands after the container is created.
"postCreateCommand": "cat .devcontainer/bashrc.override.sh >> ~/.bashrc"
}

12
.dockerignore Normal file
View file

@ -0,0 +1,12 @@
.editorconfig
.gitattributes
.github
.gitignore
.gitlab-ci.yml
.idea
.pre-commit-config.yaml
.readthedocs.yml
.travis.yml
venv
.git
.envs/

27
.editorconfig Normal file
View file

@ -0,0 +1,27 @@
# http://editorconfig.org
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{py,rst,ini}]
indent_style = space
indent_size = 4
[*.{html,css,scss,json,yml,xml}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
[default.conf]
indent_style = space
indent_size = 2

14
.envs/.local/.django Normal file
View file

@ -0,0 +1,14 @@
# General
# ------------------------------------------------------------------------------
USE_DOCKER=yes
IPYTHONDIR=/app/.ipython
# Redis
# ------------------------------------------------------------------------------
REDIS_URL=redis://redis:6379/0
# Celery
# ------------------------------------------------------------------------------
# Flower
CELERY_FLOWER_USER=debug
CELERY_FLOWER_PASSWORD=debug

7
.envs/.local/.postgres Normal file
View file

@ -0,0 +1,7 @@
# PostgreSQL
# ------------------------------------------------------------------------------
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=lms
POSTGRES_USER=debug
POSTGRES_PASSWORD=debug

45
.envs/.production/.django Normal file
View file

@ -0,0 +1,45 @@
# General
# ------------------------------------------------------------------------------
# DJANGO_READ_DOT_ENV_FILE=True
DJANGO_SETTINGS_MODULE=config.settings.production
DJANGO_SECRET_KEY=CQHQz4M3wN1VL2TT53Gl8yupKOjQ5m01js4jPw6bQsUexzkdy9JGXhQg9h6H24M5
DJANGO_ADMIN_URL=6XfjlokEGlPf6SpVfGh7wBvs7t5ZFMDs/
DJANGO_ALLOWED_HOSTS=.example.com
# Security
# ------------------------------------------------------------------------------
# TIP: better off using DNS, however, redirect is OK too
DJANGO_SECURE_SSL_REDIRECT=False
# Email
# ------------------------------------------------------------------------------
DJANGO_SERVER_EMAIL=
MAILGUN_API_KEY=
MAILGUN_DOMAIN=
# django-allauth
# ------------------------------------------------------------------------------
DJANGO_ACCOUNT_ALLOW_REGISTRATION=True
# Gunicorn
# ------------------------------------------------------------------------------
WEB_CONCURRENCY=4
# Sentry
# ------------------------------------------------------------------------------
SENTRY_DSN=
# Redis
# ------------------------------------------------------------------------------
REDIS_URL=redis://redis:6379/0
# Celery
# ------------------------------------------------------------------------------
# Flower
CELERY_FLOWER_USER=debug
CELERY_FLOWER_PASSWORD=debug

View file

@ -0,0 +1,7 @@
# PostgreSQL
# ------------------------------------------------------------------------------
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=lms
POSTGRES_USER=debug
POSTGRES_PASSWORD=debug

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto

98
.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,98 @@
# Config for Dependabot updates. See Documentation here:
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
# Update GitHub actions in workflows
- package-ecosystem: 'github-actions'
directory: '/'
# Every weekday
schedule:
interval: 'daily'
# Enable version updates for Docker
# We need to specify each Dockerfile in a separate entry because Dependabot doesn't
# support wildcards or recursively checking subdirectories. Check this issue for updates:
# https://github.com/dependabot/dependabot-core/issues/2178
- package-ecosystem: 'docker'
# Look for a `Dockerfile` in the `compose/local/django` directory
directory: 'compose/local/django/'
# Every weekday
schedule:
interval: 'daily'
# Ignore minor version updates (3.10 -> 3.11) but update patch versions
ignore:
- dependency-name: '*'
update-types:
- 'version-update:semver-major'
- 'version-update:semver-minor'
- package-ecosystem: 'docker'
# Look for a `Dockerfile` in the `compose/local/docs` directory
directory: 'compose/local/docs/'
# Every weekday
schedule:
interval: 'daily'
# Ignore minor version updates (3.10 -> 3.11) but update patch versions
ignore:
- dependency-name: '*'
update-types:
- 'version-update:semver-major'
- 'version-update:semver-minor'
- package-ecosystem: 'docker'
# Look for a `Dockerfile` in the `compose/local/node` directory
directory: 'compose/local/node/'
# Every weekday
schedule:
interval: 'daily'
- package-ecosystem: 'docker'
# Look for a `Dockerfile` in the `compose/production/aws` directory
directory: 'compose/production/aws/'
# Every weekday
schedule:
interval: 'daily'
- package-ecosystem: 'docker'
# Look for a `Dockerfile` in the `compose/production/django` directory
directory: 'compose/production/django/'
# Every weekday
schedule:
interval: 'daily'
# Ignore minor version updates (3.10 -> 3.11) but update patch versions
ignore:
- dependency-name: '*'
update-types:
- 'version-update:semver-major'
- 'version-update:semver-minor'
- package-ecosystem: 'docker'
# Look for a `Dockerfile` in the `compose/production/postgres` directory
directory: 'compose/production/postgres/'
# Every weekday
schedule:
interval: 'daily'
- package-ecosystem: 'docker'
# Look for a `Dockerfile` in the `compose/production/traefik` directory
directory: 'compose/production/traefik/'
# Every weekday
schedule:
interval: 'daily'
- package-ecosystem: 'docker'
# Look for a `Dockerfile` in the `compose/production/nginx` directory
directory: 'compose/production/nginx/'
# Every weekday
schedule:
interval: 'daily'
# Enable version updates for Python/Pip - Production
- package-ecosystem: 'pip'
# Look for a `requirements.txt` in the `root` directory
# also 'setup.cfg', 'runtime.txt' and 'requirements/*.txt'
directory: '/'
# Every weekday
schedule:
interval: 'daily'

59
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,59 @@
name: CI
# Enable Buildkit and let compose use it to speed up image building
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
on:
pull_request:
branches: ['master', 'main']
paths-ignore: ['docs/**']
push:
branches: ['master', 'main']
paths-ignore: ['docs/**']
concurrency:
group: ${{ github.head_ref || github.run_id }}
cancel-in-progress: true
jobs:
linter:
runs-on: ubuntu-latest
steps:
- name: Checkout Code Repository
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Run pre-commit
uses: pre-commit/action@v3.0.1
# With no caching at all the entire ci process takes 3m to complete!
pytest:
runs-on: ubuntu-latest
steps:
- name: Checkout Code Repository
uses: actions/checkout@v4
- name: Build the Stack
run: docker compose -f docker-compose.local.yml build django
- name: Build the docs
run: docker compose -f docker-compose.docs.yml build docs
- name: Check DB Migrations
run: docker compose -f docker-compose.local.yml run --rm django python manage.py makemigrations --check
- name: Run DB Migrations
run: docker compose -f docker-compose.local.yml run --rm django python manage.py migrate
- name: Run Django Tests
run: docker compose -f docker-compose.local.yml run django pytest
- name: Tear down the Stack
run: docker compose -f docker-compose.local.yml down

279
.gitignore vendored Normal file
View file

@ -0,0 +1,279 @@
### Python template
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Translations
*.mo
*.pot
# Django stuff:
staticfiles/
# Sphinx documentation
docs/_build/
# PyBuilder
target/
# pyenv
.python-version
# celery beat schedule file
celerybeat-schedule
# Environments
.venv
venv/
ENV/
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
### Node template
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Typescript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
### Linux template
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### VisualStudioCode template
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for devcontainer
.devcontainer/bash_history
### Windows template
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
Desktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msm
*.msp
# Windows shortcuts
*.lnk
### macOS template
# General
*.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
### SublimeText template
# Cache files for Sublime Text
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache
# Workspace files are user-specific
*.sublime-workspace
# Project files should be checked into the repository, unless a significant
# proportion of contributors will probably not be using Sublime Text
# *.sublime-project
# SFTP configuration file
sftp-config.json
# Package control specific files
Package Control.last-run
Package Control.ca-list
Package Control.ca-bundle
Package Control.system-ca-bundle
Package Control.cache/
Package Control.ca-certs/
Package Control.merged-ca-bundle
Package Control.user-ca-bundle
oscrypto-ca-bundle.crt
bh_unicode_properties.cache
# Sublime-github package stores a github token in this file
# https://packagecontrol.io/packages/sublime-github
GitHub.sublime-settings
### Vim template
# Swap
[._]*.s[a-v][a-z]
[._]*.sw[a-p]
[._]s[a-v][a-z]
[._]sw[a-p]
# Session
Session.vim
# Temporary
.netrwhist
# Auto-generated tag files
tags
# Redis dump file
dump.rdb
### Project template
lms/media/
.pytest_cache/
.ipython/
.env

50
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,50 @@
exclude: '^docs/|/migrations/|devcontainer.json'
default_stages: [pre-commit]
minimum_pre_commit_version: "3.2.0"
default_language_version:
python: python3.12
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-json
- id: check-toml
- id: check-xml
- id: check-yaml
- id: debug-statements
- id: check-builtin-literals
- id: check-case-conflict
- id: check-docstring-first
- id: detect-private-key
- repo: https://github.com/adamchainz/django-upgrade
rev: '1.22.2'
hooks:
- id: django-upgrade
args: ['--target-version', '5.0']
# Run the Ruff linter.
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.6
hooks:
# Linter
- id: ruff
args: [--fix, --exit-non-zero-on-fix]
# Formatter
- id: ruff-format
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.36.4
hooks:
- id: djlint-reformat-django
- id: djlint-django
# sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date
ci:
autoupdate_schedule: weekly
skip: []
submodules: false

20
.readthedocs.yml Normal file
View file

@ -0,0 +1,20 @@
# Read the Docs configuration file
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
# Required
version: 2
# Set the version of Python and other tools you might need
build:
os: ubuntu-22.04
tools:
python: '3.12'
# Build documentation in the docs/ directory with Sphinx
sphinx:
configuration: docs/conf.py
# Python requirements required to build your docs
python:
install:
- requirements: requirements/local.txt

View file

@ -0,0 +1,92 @@
# 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
ARG BUILD_ENVIRONMENT=local
# Install apt packages
RUN apt-get update && apt-get install --no-install-recommends -y \
# dependencies for building Python packages
build-essential \
# psycopg dependencies
libpq-dev
# Requirements are installed here to ensure they will be cached.
COPY ./requirements .
# Create Python Dependency and Sub-Dependency Wheels.
RUN pip wheel --wheel-dir /usr/src/app/wheels \
-r ${BUILD_ENVIRONMENT}.txt
# Python 'run' stage
FROM python AS python-run-stage
ARG BUILD_ENVIRONMENT=local
ARG APP_HOME=/app
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV BUILD_ENV=${BUILD_ENVIRONMENT}
WORKDIR ${APP_HOME}
# devcontainer dependencies and utils
RUN apt-get update && apt-get install --no-install-recommends -y \
sudo git bash-completion nano ssh
# Create devcontainer user and add it to sudoers
RUN groupadd --gid 1000 dev-user \
&& useradd --uid 1000 --gid dev-user --shell /bin/bash --create-home dev-user \
&& echo dev-user ALL=\(root\) NOPASSWD:ALL > /etc/sudoers.d/dev-user \
&& chmod 0440 /etc/sudoers.d/dev-user
# Install required system dependencies
RUN apt-get update && apt-get install --no-install-recommends -y \
# psycopg dependencies
libpq-dev \
wait-for-it \
# Translations dependencies
gettext \
# cleaning up unused files
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& rm -rf /var/lib/apt/lists/*
# All absolute dir copies ignore workdir instruction. All relative dir copies are wrt to the workdir instruction
# 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-dir --no-index --find-links=/wheels/ /wheels/* \
&& rm -rf /wheels/
COPY ./compose/production/django/entrypoint /entrypoint
RUN sed -i 's/\r$//g' /entrypoint
RUN chmod +x /entrypoint
COPY ./compose/local/django/start /start
RUN sed -i 's/\r$//g' /start
RUN chmod +x /start
COPY ./compose/local/django/celery/worker/start /start-celeryworker
RUN sed -i 's/\r$//g' /start-celeryworker
RUN chmod +x /start-celeryworker
COPY ./compose/local/django/celery/beat/start /start-celerybeat
RUN sed -i 's/\r$//g' /start-celerybeat
RUN chmod +x /start-celerybeat
COPY ./compose/local/django/celery/flower/start /start-flower
RUN sed -i 's/\r$//g' /start-flower
RUN chmod +x /start-flower
# copy application code to WORKDIR
COPY . ${APP_HOME}
ENTRYPOINT ["/entrypoint"]

View file

@ -0,0 +1,8 @@
#!/bin/bash
set -o errexit
set -o nounset
rm -f './celerybeat.pid'
exec watchfiles --filter python celery.__main__.main --args '-A config.celery_app beat -l INFO'

View file

@ -0,0 +1,16 @@
#!/bin/bash
set -o errexit
set -o nounset
until timeout 10 celery -A config.celery_app inspect ping; do
>&2 echo "Celery workers not available"
done
echo 'Starting flower'
exec watchfiles --filter python celery.__main__.main \
--args \
"-A config.celery_app -b \"${REDIS_URL}\" flower --basic_auth=\"${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}\""

View file

@ -0,0 +1,7 @@
#!/bin/bash
set -o errexit
set -o nounset
exec watchfiles --filter python celery.__main__.main --args '-A config.celery_app worker -l INFO'

View file

@ -0,0 +1,9 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
python manage.py migrate
exec uvicorn config.asgi:application --host 0.0.0.0 --reload --reload-include '*.html'

View file

@ -0,0 +1,62 @@
# 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
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/*
# 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
COPY ./compose/local/docs/start /start-docs
RUN sed -i 's/\r$//g' /start-docs
RUN chmod +x /start-docs
WORKDIR /docs

7
compose/local/docs/start Normal file
View file

@ -0,0 +1,7 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
exec make livehtml

View file

@ -0,0 +1,98 @@
# 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
ARG BUILD_ENVIRONMENT=production
# Install apt packages
RUN apt-get update && apt-get install --no-install-recommends -y \
# dependencies for building Python packages
build-essential \
# psycopg dependencies
libpq-dev
# Requirements are installed here to ensure they will be cached.
COPY ./requirements .
# Create Python Dependency and Sub-Dependency Wheels.
RUN pip wheel --wheel-dir /usr/src/app/wheels \
-r ${BUILD_ENVIRONMENT}.txt
# Python 'run' stage
FROM python AS python-run-stage
ARG BUILD_ENVIRONMENT=production
ARG APP_HOME=/app
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
ENV BUILD_ENV=${BUILD_ENVIRONMENT}
WORKDIR ${APP_HOME}
RUN addgroup --system django \
&& adduser --system --ingroup django django
# Install required system dependencies
RUN apt-get update && apt-get install --no-install-recommends -y \
# psycopg dependencies
libpq-dev \
# Translations dependencies
gettext \
# entrypoint
wait-for-it \
# cleaning up unused files
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& rm -rf /var/lib/apt/lists/*
# All absolute dir copies ignore workdir instruction. All relative dir copies are wrt to the workdir instruction
# 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-dir --no-index --find-links=/wheels/ /wheels/* \
&& rm -rf /wheels/
COPY --chown=django:django ./compose/production/django/entrypoint /entrypoint
RUN sed -i 's/\r$//g' /entrypoint
RUN chmod +x /entrypoint
COPY --chown=django:django ./compose/production/django/start /start
RUN sed -i 's/\r$//g' /start
RUN chmod +x /start
COPY --chown=django:django ./compose/production/django/celery/worker/start /start-celeryworker
RUN sed -i 's/\r$//g' /start-celeryworker
RUN chmod +x /start-celeryworker
COPY --chown=django:django ./compose/production/django/celery/beat/start /start-celerybeat
RUN sed -i 's/\r$//g' /start-celerybeat
RUN chmod +x /start-celerybeat
COPY --chown=django:django ./compose/production/django/celery/flower/start /start-flower
RUN sed -i 's/\r$//g' /start-flower
RUN chmod +x /start-flower
# copy application code to WORKDIR
COPY --chown=django:django . ${APP_HOME}
# make django owner of the WORKDIR directory as well.
RUN chown -R django:django ${APP_HOME}
USER django
RUN DATABASE_URL="" \
DJANGO_SETTINGS_MODULE="config.settings.test" \
python manage.py compilemessages
ENTRYPOINT ["/entrypoint"]

View file

@ -0,0 +1,8 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
exec celery -A config.celery_app beat -l INFO

View file

@ -0,0 +1,19 @@
#!/bin/bash
set -o errexit
set -o nounset
until timeout 10 celery -A config.celery_app inspect ping; do
>&2 echo "Celery workers not available"
done
echo 'Starting flower'
exec celery \
-A config.celery_app \
-b "${REDIS_URL}" \
flower \
--basic_auth="${CELERY_FLOWER_USER}:${CELERY_FLOWER_PASSWORD}"

View file

@ -0,0 +1,8 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
exec celery -A config.celery_app worker -l INFO

View file

@ -0,0 +1,17 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
if [ -z "${POSTGRES_USER}" ]; then
base_postgres_image_default_user='postgres'
export POSTGRES_USER="${base_postgres_image_default_user}"
fi
export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}"
wait-for-it "${POSTGRES_HOST}:${POSTGRES_PORT}" -t 30
>&2 echo 'PostgreSQL is available'
exec "$@"

View file

@ -0,0 +1,29 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
python /app/manage.py collectstatic --noinput
compress_enabled() {
python << END
import sys
from environ import Env
env = Env(COMPRESS_ENABLED=(bool, True))
if env('COMPRESS_ENABLED'):
sys.exit(0)
else:
sys.exit(1)
END
}
if compress_enabled; then
# NOTE this command will fail if django-compressor is disabled
python /app/manage.py compress
fi
exec /usr/local/bin/gunicorn config.asgi --bind 0.0.0.0:5000 --chdir=/app -k uvicorn_worker.UvicornWorker

View file

@ -0,0 +1,2 @@
FROM docker.io/nginx:1.17.8-alpine
COPY ./compose/production/nginx/default.conf /etc/nginx/conf.d/default.conf

View file

@ -0,0 +1,7 @@
server {
listen 80;
server_name localhost;
location /media/ {
alias /usr/share/nginx/media/;
}
}

View file

@ -0,0 +1,6 @@
FROM docker.io/postgres:16
COPY ./compose/production/postgres/maintenance /usr/local/bin/maintenance
RUN chmod +x /usr/local/bin/maintenance/*
RUN mv /usr/local/bin/maintenance/* /usr/local/bin \
&& rmdir /usr/local/bin/maintenance

View file

@ -0,0 +1,5 @@
#!/usr/bin/env bash
BACKUP_DIR_PATH='/backups'
BACKUP_FILE_PREFIX='backup'

View file

@ -0,0 +1,12 @@
#!/usr/bin/env bash
countdown() {
declare desc="A simple countdown. Source: https://superuser.com/a/611582"
local seconds="${1}"
local d=$(($(date +%s) + "${seconds}"))
while [ "$d" -ge `date +%s` ]; do
echo -ne "$(date -u --date @$(($d - `date +%s`)) +%H:%M:%S)\r";
sleep 0.1
done
}

View file

@ -0,0 +1,41 @@
#!/usr/bin/env bash
message_newline() {
echo
}
message_debug()
{
echo -e "DEBUG: ${@}"
}
message_welcome()
{
echo -e "\e[1m${@}\e[0m"
}
message_warning()
{
echo -e "\e[33mWARNING\e[0m: ${@}"
}
message_error()
{
echo -e "\e[31mERROR\e[0m: ${@}"
}
message_info()
{
echo -e "\e[37mINFO\e[0m: ${@}"
}
message_suggestion()
{
echo -e "\e[33mSUGGESTION\e[0m: ${@}"
}
message_success()
{
echo -e "\e[32mSUCCESS\e[0m: ${@}"
}

View file

@ -0,0 +1,16 @@
#!/usr/bin/env bash
yes_no() {
declare desc="Prompt for confirmation. \$\"\{1\}\": confirmation message."
local arg1="${1}"
local response=
read -r -p "${arg1} (y/[n])? " response
if [[ "${response}" =~ ^[Yy]$ ]]
then
exit 0
else
exit 1
fi
}

View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
### Create a database backup.
###
### Usage:
### $ docker compose -f <environment>.yml (exec |run --rm) postgres backup
set -o errexit
set -o pipefail
set -o nounset
working_dir="$(dirname ${0})"
source "${working_dir}/_sourced/constants.sh"
source "${working_dir}/_sourced/messages.sh"
message_welcome "Backing up the '${POSTGRES_DB}' database..."
if [[ "${POSTGRES_USER}" == "postgres" ]]; then
message_error "Backing up as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again."
exit 1
fi
export PGHOST="${POSTGRES_HOST}"
export PGPORT="${POSTGRES_PORT}"
export PGUSER="${POSTGRES_USER}"
export PGPASSWORD="${POSTGRES_PASSWORD}"
export PGDATABASE="${POSTGRES_DB}"
backup_filename="${BACKUP_FILE_PREFIX}_$(date +'%Y_%m_%dT%H_%M_%S').sql.gz"
pg_dump | gzip > "${BACKUP_DIR_PATH}/${backup_filename}"
message_success "'${POSTGRES_DB}' database backup '${backup_filename}' has been created and placed in '${BACKUP_DIR_PATH}'."

View file

@ -0,0 +1,22 @@
#!/usr/bin/env bash
### View backups.
###
### Usage:
### $ docker compose -f <environment>.yml (exec |run --rm) postgres backups
set -o errexit
set -o pipefail
set -o nounset
working_dir="$(dirname ${0})"
source "${working_dir}/_sourced/constants.sh"
source "${working_dir}/_sourced/messages.sh"
message_welcome "These are the backups you have got:"
ls -lht "${BACKUP_DIR_PATH}"

View file

@ -0,0 +1,55 @@
#!/usr/bin/env bash
### Restore database from a backup.
###
### Parameters:
### <1> filename of an existing backup.
###
### Usage:
### $ docker compose -f <environment>.yml (exec |run --rm) postgres restore <1>
set -o errexit
set -o pipefail
set -o nounset
working_dir="$(dirname ${0})"
source "${working_dir}/_sourced/constants.sh"
source "${working_dir}/_sourced/messages.sh"
if [[ -z ${1+x} ]]; then
message_error "Backup filename is not specified yet it is a required parameter. Make sure you provide one and try again."
exit 1
fi
backup_filename="${BACKUP_DIR_PATH}/${1}"
if [[ ! -f "${backup_filename}" ]]; then
message_error "No backup with the specified filename found. Check out the 'backups' maintenance script output to see if there is one and try again."
exit 1
fi
message_welcome "Restoring the '${POSTGRES_DB}' database from the '${backup_filename}' backup..."
if [[ "${POSTGRES_USER}" == "postgres" ]]; then
message_error "Restoring as 'postgres' user is not supported. Assign 'POSTGRES_USER' env with another one and try again."
exit 1
fi
export PGHOST="${POSTGRES_HOST}"
export PGPORT="${POSTGRES_PORT}"
export PGUSER="${POSTGRES_USER}"
export PGPASSWORD="${POSTGRES_PASSWORD}"
export PGDATABASE="${POSTGRES_DB}"
message_info "Dropping the database..."
dropdb "${PGDATABASE}"
message_info "Creating a new database..."
createdb --owner="${POSTGRES_USER}"
message_info "Applying the backup to the new database..."
gunzip -c "${backup_filename}" | psql "${POSTGRES_DB}"
message_success "The '${POSTGRES_DB}' database has been restored from the '${backup_filename}' backup."

View file

@ -0,0 +1,36 @@
#!/usr/bin/env bash
### Remove a database backup.
###
### Parameters:
### <1> filename of a backup to remove.
###
### Usage:
### $ docker-compose -f <environment>.yml (exec |run --rm) postgres rmbackup <1>
set -o errexit
set -o pipefail
set -o nounset
working_dir="$(dirname ${0})"
source "${working_dir}/_sourced/constants.sh"
source "${working_dir}/_sourced/messages.sh"
if [[ -z ${1+x} ]]; then
message_error "Backup filename is not specified yet it is a required parameter. Make sure you provide one and try again."
exit 1
fi
backup_filename="${BACKUP_DIR_PATH}/${1}"
if [[ ! -f "${backup_filename}" ]]; then
message_error "No backup with the specified filename found. Check out the 'backups' maintenance script output to see if there is one and try again."
exit 1
fi
message_welcome "Removing the '${backup_filename}' backup file..."
rm -r "${backup_filename}"
message_success "The '${backup_filename}' database backup has been removed."

View file

@ -0,0 +1,5 @@
FROM docker.io/traefik:3.3.0
RUN mkdir -p /etc/traefik/acme \
&& touch /etc/traefik/acme/acme.json \
&& chmod 600 /etc/traefik/acme/acme.json
COPY ./compose/production/traefik/traefik.yml /etc/traefik

View file

@ -0,0 +1,90 @@
log:
level: INFO
entryPoints:
web:
# http
address: ':80'
http:
# https://doc.traefik.io/traefik/routing/entrypoints/#entrypoint
redirections:
entryPoint:
to: web-secure
web-secure:
# https
address: ':443'
flower:
address: ':5555'
certificatesResolvers:
letsencrypt:
# https://doc.traefik.io/traefik/https/acme/#lets-encrypt
acme:
email: 'ahmed10nagi@gmail.com'
storage: /etc/traefik/acme/acme.json
# https://doc.traefik.io/traefik/https/acme/#httpchallenge
httpChallenge:
entryPoint: web
http:
routers:
web-secure-router:
rule: 'Host(`example.com`) || Host(`www.example.com`)'
entryPoints:
- web-secure
middlewares:
- csrf
service: django
tls:
# https://doc.traefik.io/traefik/routing/routers/#certresolver
certResolver: letsencrypt
flower-secure-router:
rule: 'Host(`example.com`)'
entryPoints:
- flower
service: flower
tls:
# https://doc.traefik.io/traefik/master/routing/routers/#certresolver
certResolver: letsencrypt
web-media-router:
rule: '(Host(`example.com`) || Host(`www.example.com`)) && PathPrefix(`/media/`)'
entryPoints:
- web-secure
middlewares:
- csrf
service: django-media
tls:
certResolver: letsencrypt
middlewares:
csrf:
# https://doc.traefik.io/traefik/master/middlewares/http/headers/#hostsproxyheaders
# https://docs.djangoproject.com/en/dev/ref/csrf/#ajax
headers:
hostsProxyHeaders: ['X-CSRFToken']
services:
django:
loadBalancer:
servers:
- url: http://django:5000
flower:
loadBalancer:
servers:
- url: http://flower:5555
django-media:
loadBalancer:
servers:
- url: http://nginx:80
providers:
# https://doc.traefik.io/traefik/master/providers/file/
file:
filename: /etc/traefik/traefik.yml
watch: true

5
config/__init__.py Normal file
View file

@ -0,0 +1,5 @@
# This will make sure the app is always imported when
# Django starts so that shared_task will use this app.
from .celery_app import app as celery_app
__all__ = ("celery_app",)

13
config/api_router.py Normal file
View file

@ -0,0 +1,13 @@
from django.conf import settings
from rest_framework.routers import DefaultRouter
from rest_framework.routers import SimpleRouter
from lms.users.api.views import UserViewSet
router = DefaultRouter() if settings.DEBUG else SimpleRouter()
router.register("users", UserViewSet)
app_name = "api"
urlpatterns = router.urls

43
config/asgi.py Normal file
View file

@ -0,0 +1,43 @@
# ruff: noqa
"""
ASGI config for Learning Management System project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/dev/howto/deployment/asgi/
"""
import os
import sys
from pathlib import Path
from django.core.asgi import get_asgi_application
# This allows easy placement of apps within the interior
# lms directory.
BASE_DIR = Path(__file__).resolve(strict=True).parent.parent
sys.path.append(str(BASE_DIR / "lms"))
# If DJANGO_SETTINGS_MODULE is unset, default to the local settings
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
# This application object is used by any ASGI server configured to use this file.
django_application = get_asgi_application()
# Apply ASGI middleware here.
# from helloworld.asgi import HelloWorldApplication
# application = HelloWorldApplication(application)
# Import websocket application here, so apps from django_application are loaded first
from config.websocket import websocket_application
async def application(scope, receive, send):
if scope["type"] == "http":
await django_application(scope, receive, send)
elif scope["type"] == "websocket":
await websocket_application(scope, receive, send)
else:
msg = f"Unknown scope type {scope['type']}"
raise NotImplementedError(msg)

28
config/celery_app.py Normal file
View file

@ -0,0 +1,28 @@
import os
from celery import Celery
from celery.signals import setup_logging
# set the default Django settings module for the 'celery' program.
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local")
app = Celery("lms")
# Using a string here means the worker doesn't have to serialize
# the configuration object to child processes.
# - namespace='CELERY' means all celery-related configuration keys
# should have a `CELERY_` prefix.
app.config_from_object("django.conf:settings", namespace="CELERY")
@setup_logging.connect
def config_loggers(*args, **kwargs):
from logging.config import dictConfig
from django.conf import settings
dictConfig(settings.LOGGING)
# Load task modules from all registered Django app configs.
app.autodiscover_tasks()

View file

392
config/settings/base.py Normal file
View file

@ -0,0 +1,392 @@
# ruff: noqa: ERA001, E501
"""Base settings to build other settings files upon."""
import ssl
from pathlib import Path
import environ
BASE_DIR = Path(__file__).resolve(strict=True).parent.parent.parent
# lms/
APPS_DIR = BASE_DIR / "lms"
env = environ.Env()
READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False)
if READ_DOT_ENV_FILE:
# OS environment variables take precedence over variables from .env
env.read_env(str(BASE_DIR / ".env"))
# GENERAL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#debug
DEBUG = env.bool("DJANGO_DEBUG", False)
# Local time zone. Choices are
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
# though not all of them may be available with every OS.
# In Windows, this must be set to your system time zone.
TIME_ZONE = "UTC"
# https://docs.djangoproject.com/en/dev/ref/settings/#language-code
LANGUAGE_CODE = "en-us"
# https://docs.djangoproject.com/en/dev/ref/settings/#languages
# from django.utils.translation import gettext_lazy as _
# LANGUAGES = [
# ('en', _('English')),
# ('fr-fr', _('French')),
# ('pt-br', _('Portuguese')),
# ]
# https://docs.djangoproject.com/en/dev/ref/settings/#site-id
SITE_ID = 1
# https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n
USE_I18N = True
# https://docs.djangoproject.com/en/dev/ref/settings/#use-tz
USE_TZ = True
# https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths
LOCALE_PATHS = [str(BASE_DIR / "locale")]
# DATABASES
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#databases
DATABASES = {"default": env.db("DATABASE_URL")}
DATABASES["default"]["ATOMIC_REQUESTS"] = True
# https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-DEFAULT_AUTO_FIELD
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# URLS
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf
ROOT_URLCONF = "config.urls"
# https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
WSGI_APPLICATION = "config.wsgi.application"
# APPS
# ------------------------------------------------------------------------------
DJANGO_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.sites",
"django.contrib.messages",
"django.contrib.staticfiles",
# "django.contrib.humanize", # Handy template tags
"django.contrib.admin",
"django.forms",
]
THIRD_PARTY_APPS = [
"crispy_forms",
"crispy_bootstrap5",
"allauth",
"allauth.account",
"allauth.mfa",
"allauth.socialaccount",
"django_celery_beat",
"rest_framework",
"rest_framework.authtoken",
"corsheaders",
"drf_spectacular",
]
LOCAL_APPS = [
"lms.users",
"lms.accounts",
"lms.app",
# Your stuff: custom apps go here
]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
# MIGRATIONS
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules
MIGRATION_MODULES = {"sites": "lms.contrib.sites.migrations"}
# AUTHENTICATION
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
]
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-user-model
AUTH_USER_MODEL = "accounts.CustomUser"
# https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url
# LOGIN_REDIRECT_URL = "users:redirect"
# https://docs.djangoproject.com/en/dev/ref/settings/#login-url
# LOGIN_URL = "account_login"
# PASSWORDS
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers
PASSWORD_HASHERS = [
# https://docs.djangoproject.com/en/dev/topics/auth/passwords/#using-argon2-with-django
"django.contrib.auth.hashers.Argon2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2PasswordHasher",
"django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher",
"django.contrib.auth.hashers.BCryptSHA256PasswordHasher",
]
# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
{"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
{"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
# MIDDLEWARE
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#middleware
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"corsheaders.middleware.CorsMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
# STATIC
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#static-root
STATIC_ROOT = str(BASE_DIR / "staticfiles")
# https://docs.djangoproject.com/en/dev/ref/settings/#static-url
STATIC_URL = "/static/"
# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS
STATICFILES_DIRS = [str(APPS_DIR / "static")]
# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders
STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.FileSystemFinder",
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
]
# MEDIA
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#media-root
MEDIA_ROOT = str(APPS_DIR / "media")
# https://docs.djangoproject.com/en/dev/ref/settings/#media-url
MEDIA_URL = "/media/"
# TEMPLATES
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#templates
TEMPLATES = [
{
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND
"BACKEND": "django.template.backends.django.DjangoTemplates",
# https://docs.djangoproject.com/en/dev/ref/settings/#dirs
"DIRS": [str(APPS_DIR / "templates")],
# https://docs.djangoproject.com/en/dev/ref/settings/#app-dirs
"APP_DIRS": True,
"OPTIONS": {
# https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.template.context_processors.i18n",
"django.template.context_processors.media",
"django.template.context_processors.static",
"django.template.context_processors.tz",
"django.contrib.messages.context_processors.messages",
"lms.users.context_processors.allauth_settings",
],
},
},
]
# https://docs.djangoproject.com/en/dev/ref/settings/#form-renderer
FORM_RENDERER = "django.forms.renderers.TemplatesSetting"
# http://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs
CRISPY_TEMPLATE_PACK = "bootstrap5"
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
# FIXTURES
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#fixture-dirs
FIXTURE_DIRS = (str(APPS_DIR / "fixtures"),)
# SECURITY
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-httponly
SESSION_COOKIE_HTTPONLY = True
# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-httponly
CSRF_COOKIE_HTTPONLY = True
# https://docs.djangoproject.com/en/dev/ref/settings/#x-frame-options
X_FRAME_OPTIONS = "DENY"
# EMAIL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
EMAIL_BACKEND = env(
"DJANGO_EMAIL_BACKEND",
default="django.core.mail.backends.smtp.EmailBackend",
)
# https://docs.djangoproject.com/en/dev/ref/settings/#email-timeout
EMAIL_TIMEOUT = 5
# ADMIN
# ------------------------------------------------------------------------------
# Django Admin URL.
ADMIN_URL = "admin/"
# https://docs.djangoproject.com/en/dev/ref/settings/#admins
ADMINS = [("""Ahmed Nagi""", "ahmed10nagi@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
# Force the `admin` sign in process to go through the `django-allauth` workflow
DJANGO_ADMIN_FORCE_ALLAUTH = env.bool("DJANGO_ADMIN_FORCE_ALLAUTH", default=False)
# LOGGING
# ------------------------------------------------------------------------------
# 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"]},
}
REDIS_URL = env("REDIS_URL", default="redis://redis:6379/0")
REDIS_SSL = REDIS_URL.startswith("rediss://")
# Celery
# ------------------------------------------------------------------------------
if USE_TZ:
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-timezone
CELERY_TIMEZONE = TIME_ZONE
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-broker_url
CELERY_BROKER_URL = REDIS_URL
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#redis-backend-use-ssl
CELERY_BROKER_USE_SSL = {"ssl_cert_reqs": ssl.CERT_NONE} if REDIS_SSL else None
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-result_backend
CELERY_RESULT_BACKEND = REDIS_URL
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#redis-backend-use-ssl
CELERY_REDIS_BACKEND_USE_SSL = CELERY_BROKER_USE_SSL
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#result-extended
CELERY_RESULT_EXTENDED = True
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#result-backend-always-retry
# https://github.com/celery/celery/pull/6122
CELERY_RESULT_BACKEND_ALWAYS_RETRY = True
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#result-backend-max-retries
CELERY_RESULT_BACKEND_MAX_RETRIES = 10
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-accept_content
CELERY_ACCEPT_CONTENT = ["json"]
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-task_serializer
CELERY_TASK_SERIALIZER = "json"
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std:setting-result_serializer
CELERY_RESULT_SERIALIZER = "json"
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-time-limit
# TODO: set to whatever value is adequate in your circumstances
CELERY_TASK_TIME_LIMIT = 5 * 60
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-soft-time-limit
# TODO: set to whatever value is adequate in your circumstances
CELERY_TASK_SOFT_TIME_LIMIT = 60
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-scheduler
CELERY_BEAT_SCHEDULER = "django_celery_beat.schedulers:DatabaseScheduler"
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#worker-send-task-events
CELERY_WORKER_SEND_TASK_EVENTS = True
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#std-setting-task_send_sent_event
CELERY_TASK_SEND_SENT_EVENT = True
# https://cheat.readthedocs.io/en/latest/django/celery.html
CELERYD_HIJACK_ROOT_LOGGER = False
# django-allauth
# ------------------------------------------------------------------------------
ACCOUNT_ALLOW_REGISTRATION = env.bool("DJANGO_ACCOUNT_ALLOW_REGISTRATION", True)
# https://docs.allauth.org/en/latest/account/configuration.html
ACCOUNT_AUTHENTICATION_METHOD = "email"
# https://docs.allauth.org/en/latest/account/configuration.html
ACCOUNT_EMAIL_REQUIRED = True
# https://docs.allauth.org/en/latest/account/configuration.html
ACCOUNT_USERNAME_REQUIRED = False
# https://docs.allauth.org/en/latest/account/configuration.html
ACCOUNT_USER_MODEL_USERNAME_FIELD = None
# https://docs.allauth.org/en/latest/account/configuration.html
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
ACCOUNT_LOGOUT_ON_GET = True
LOGOUT_ON_PASSWORD_CHANGE = False
ACCOUNT_CHANGE_EMAIL = True
ACCOUNT_EMAIL_CONFIRMATION_HMAC = True
ACCOUNT_CONFIRM_EMAIL_ON_GET = True
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
# }
# https://docs.allauth.org/en/latest/account/configuration.html
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
# SOCIALACCOUNT_ADAPTER = "lms.users.adapters.SocialAccountAdapter"
# https://docs.allauth.org/en/latest/socialaccount/configuration.html
# SOCIALACCOUNT_FORMS = {"signup": "lms.users.forms.UserSocialSignupForm"}
# django-compressor
# ------------------------------------------------------------------------------
# https://django-compressor.readthedocs.io/en/latest/quickstart/#installation
INSTALLED_APPS += ["compressor"]
STATICFILES_FINDERS += ["compressor.finders.CompressorFinder"]
# django-rest-framework
# -------------------------------------------------------------------------------
# django-rest-framework - https://www.django-rest-framework.org/api-guide/settings/
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.TokenAuthentication",
'dj_rest_auth.jwt_auth.JWTCookieAuthentic ation',
),
"DEFAULT_PERMISSION_CLASSES": (
"rest_framework.permissions.IsAuthenticated",
),
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoS chema",
}
REST_AUTH = {
'LOGIN_SERIALIZER': 'lms.accounts.serializers.CustomLoginSerializer',
'REGISTER_SERIALIZER': 'lms.accounts.serializers.CustomRegisterSerializer',
'OLD_PASSWORD_FIELD_ENABLED': True,
'USE_JWT': True,
'JWT_AUTH_HTTPONLY':False
}
from datetime import timedelta
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(hours=5),
'REFRESH_TOKEN_LIFETIME': timedelta(days=30),
'ROTATE_REFRESH_TOKENS': True,
'BLACKLIST_AFTER_ROTATION': True,
'ALGORITHM': 'HS256',
'SIGNING_KEY': SECRET_KEY,
}
# django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup
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
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"],
"SCHEMA_PATH_PREFIX": "/api/",
}
# Your stuff...
# ------------------------------------------------------------------------------

83
config/settings/local.py Normal file
View file

@ -0,0 +1,83 @@
# ruff: noqa: E501
from .base import * # noqa: F403
from .base import INSTALLED_APPS
from .base import MIDDLEWARE
from .base import env
# GENERAL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#debug
DEBUG = True
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
SECRET_KEY = env(
"DJANGO_SECRET_KEY",
default="DM837WrWz7KIfZM2eb4swzqGlIG0VhhAIFNXf9KgamMtT42DTkHIEXfpF4N9rh2Y",
)
# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts
ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1"] # noqa: S104
# CACHES
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#caches
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "",
},
}
# EMAIL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#email-host
EMAIL_HOST = env("EMAIL_HOST", default="mailpit")
# https://docs.djangoproject.com/en/dev/ref/settings/#email-port
EMAIL_PORT = 1025
# WhiteNoise
# ------------------------------------------------------------------------------
# http://whitenoise.evans.io/en/latest/django.html#using-whitenoise-in-development
INSTALLED_APPS = ["whitenoise.runserver_nostatic", *INSTALLED_APPS]
# django-debug-toolbar
# ------------------------------------------------------------------------------
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites
INSTALLED_APPS += ["debug_toolbar"]
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#middleware
MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"]
# https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html#debug-toolbar-config
DEBUG_TOOLBAR_CONFIG = {
"DISABLE_PANELS": [
"debug_toolbar.panels.redirects.RedirectsPanel",
# Disable profiling panel due to an issue with Python 3.12:
# https://github.com/jazzband/django-debug-toolbar/issues/1875
"debug_toolbar.panels.profiling.ProfilingPanel",
],
"SHOW_TEMPLATE_CONTEXT": True,
}
# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#internal-ips
INTERNAL_IPS = ["127.0.0.1", "10.0.2.2"]
if env("USE_DOCKER") == "yes":
import socket
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
INTERNAL_IPS += [".".join(ip.split(".")[:-1] + ["1"]) for ip in ips]
# RunServerPlus
# ------------------------------------------------------------------------------
# This is a custom setting for RunServerPlus to fix reloader issue in Windows docker environment
# Werkzeug reloader type [auto, watchdog, or stat]
RUNSERVERPLUS_POLLER_RELOADER_TYPE = 'stat'
# If you have CPU and IO load issues, you can increase this poller interval e.g) 5
RUNSERVERPLUS_POLLER_RELOADER_INTERVAL = 1
# django-extensions
# ------------------------------------------------------------------------------
# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration
INSTALLED_APPS += ["django_extensions"]
# Celery
# ------------------------------------------------------------------------------
# https://docs.celeryq.dev/en/stable/userguide/configuration.html#task-eager-propagates
CELERY_TASK_EAGER_PROPAGATES = True
# Your stuff...
# ------------------------------------------------------------------------------

View file

@ -0,0 +1,206 @@
# ruff: noqa: E501
import logging
import sentry_sdk
from sentry_sdk.integrations.celery import CeleryIntegration
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import LoggingIntegration
from sentry_sdk.integrations.redis import RedisIntegration
from .base import * # noqa: F403
from .base import DATABASES
from .base import INSTALLED_APPS
from .base import REDIS_URL
from .base import SPECTACULAR_SETTINGS
from .base import env
# GENERAL
# ------------------------------------------------------------------------------
# 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"])
# DATABASES
# ------------------------------------------------------------------------------
DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60)
# CACHES
# ------------------------------------------------------------------------------
CACHES = {
"default": {
"BACKEND": "django_redis.cache.RedisCache",
"LOCATION": REDIS_URL,
"OPTIONS": {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
# Mimicking memcache behavior.
# https://github.com/jazzband/django-redis#memcached-exceptions-behavior
"IGNORE_EXCEPTIONS": True,
},
},
}
# SECURITY
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-ssl-redirect
SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=True)
# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-secure
SESSION_COOKIE_SECURE = True
# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-name
SESSION_COOKIE_NAME = "__Secure-sessionid"
# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-secure
CSRF_COOKIE_SECURE = True
# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-name
CSRF_COOKIE_NAME = "__Secure-csrftoken"
# https://docs.djangoproject.com/en/dev/topics/security/#ssl-https
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-seconds
# TODO: set this to 60 seconds first and then to 518400 once you prove the former works
SECURE_HSTS_SECONDS = 60
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-include-subdomains
SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool(
"DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS",
default=True,
)
# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-preload
SECURE_HSTS_PRELOAD = env.bool("DJANGO_SECURE_HSTS_PRELOAD", default=True)
# https://docs.djangoproject.com/en/dev/ref/middleware/#x-content-type-options-nosniff
SECURE_CONTENT_TYPE_NOSNIFF = env.bool(
"DJANGO_SECURE_CONTENT_TYPE_NOSNIFF",
default=True,
)
# STATIC & MEDIA
# ------------------------
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# EMAIL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email
DEFAULT_FROM_EMAIL = env(
"DJANGO_DEFAULT_FROM_EMAIL",
default="Learning Management System <noreply@example.com>",
)
# https://docs.djangoproject.com/en/dev/ref/settings/#server-email
SERVER_EMAIL = env("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL)
# https://docs.djangoproject.com/en/dev/ref/settings/#email-subject-prefix
EMAIL_SUBJECT_PREFIX = env(
"DJANGO_EMAIL_SUBJECT_PREFIX",
default="[Learning Management System] ",
)
ACCOUNT_EMAIL_SUBJECT_PREFIX = EMAIL_SUBJECT_PREFIX
# ADMIN
# ------------------------------------------------------------------------------
# Django Admin URL regex.
ADMIN_URL = env("DJANGO_ADMIN_URL")
# Anymail
# ------------------------------------------------------------------------------
# https://anymail.readthedocs.io/en/stable/installation/#installing-anymail
INSTALLED_APPS += ["anymail"]
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
# https://anymail.readthedocs.io/en/stable/installation/#anymail-settings-reference
# https://anymail.readthedocs.io/en/stable/esps/mailgun/
EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend"
ANYMAIL = {
"MAILGUN_API_KEY": env("MAILGUN_API_KEY"),
"MAILGUN_SENDER_DOMAIN": env("MAILGUN_DOMAIN"),
"MAILGUN_API_URL": env("MAILGUN_API_URL", default="https://api.mailgun.net/v3"),
}
# django-compressor
# ------------------------------------------------------------------------------
# https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_ENABLED
COMPRESS_ENABLED = env.bool("COMPRESS_ENABLED", default=True)
# https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_STORAGE
COMPRESS_STORAGE = "compressor.storage.GzipCompressorFileStorage"
# https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_URL
COMPRESS_URL = STATIC_URL # noqa: F405
# https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_OFFLINE
COMPRESS_OFFLINE = True # Offline compression is required when using Whitenoise
# https://django-compressor.readthedocs.io/en/latest/settings/#django.conf.settings.COMPRESS_FILTERS
COMPRESS_FILTERS = {
"css": [
"compressor.filters.css_default.CssAbsoluteFilter",
"compressor.filters.cssmin.rCSSMinFilter",
],
"js": ["compressor.filters.jsmin.JSMinFilter"],
}
# LOGGING
# ------------------------------------------------------------------------------
# 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": True,
"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"]},
"loggers": {
"django.db.backends": {
"level": "ERROR",
"handlers": ["console"],
"propagate": False,
},
# Errors logged by the SDK itself
"sentry_sdk": {"level": "ERROR", "handlers": ["console"], "propagate": False},
"django.security.DisallowedHost": {
"level": "ERROR",
"handlers": ["console"],
"propagate": False,
},
},
}
# Sentry
# ------------------------------------------------------------------------------
SENTRY_DSN = env("SENTRY_DSN")
SENTRY_LOG_LEVEL = env.int("DJANGO_SENTRY_LOG_LEVEL", logging.INFO)
sentry_logging = LoggingIntegration(
level=SENTRY_LOG_LEVEL, # Capture info and above as breadcrumbs
event_level=logging.ERROR, # Send errors as events
)
integrations = [
sentry_logging,
DjangoIntegration(),
CeleryIntegration(),
RedisIntegration(),
]
sentry_sdk.init(
dsn=SENTRY_DSN,
integrations=integrations,
environment=env("SENTRY_ENVIRONMENT", default="production"),
traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.0),
)
# django-rest-framework
# -------------------------------------------------------------------------------
# Tools that generate code samples can use SERVERS to point to the correct domain
SPECTACULAR_SETTINGS["SERVERS"] = [
{"url": "https://example.com", "description": "Production server"},
]
# Your stuff...
# ------------------------------------------------------------------------------

38
config/settings/test.py Normal file
View file

@ -0,0 +1,38 @@
"""
With these settings, tests run faster.
"""
from .base import * # noqa: F403
from .base import TEMPLATES
from .base import env
# GENERAL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
SECRET_KEY = env(
"DJANGO_SECRET_KEY",
default="bBbLq2AJMKURpUeubdNeXxodsC5LUsRNqZ9oTIaHTr81eBf0GGIeRWcspXaBl0r2",
)
# https://docs.djangoproject.com/en/dev/ref/settings/#test-runner
TEST_RUNNER = "django.test.runner.DiscoverRunner"
# PASSWORDS
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
# EMAIL
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
# DEBUGGING FOR TEMPLATES
# ------------------------------------------------------------------------------
TEMPLATES[0]["OPTIONS"]["debug"] = True # type: ignore[index]
# MEDIA
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#media-url
MEDIA_URL = "http://media.testserver"
# Your stuff...
# ------------------------------------------------------------------------------

83
config/urls.py Normal file
View file

@ -0,0 +1,83 @@
# ruff: noqa
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.staticfiles.urls import staticfiles_urlpatterns
from django.urls import include
from django.urls import path
from django.views import defaults as default_views
from django.views.generic import TemplateView
from drf_spectacular.views import SpectacularAPIView
from drf_spectacular.views import SpectacularSwaggerView
from rest_framework.authtoken.views import obtain_auth_token
from dj_rest_auth.views import PasswordResetConfirmView
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")),
# Your stuff: custom urls includes go here
# ...
# Media files
*static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT),
]
if settings.DEBUG:
# Static file serving when using Gunicorn + Uvicorn for local web socket development
urlpatterns += staticfiles_urlpatterns()
# API URLS
urlpatterns += [
path(
'auth/password/reset/confirm/<uidb64>/<token>/',
PasswordResetConfirmView.as_view(),
name='password_reset_confirm',
),
path('auth/registration/', include('dj_rest_auth.registration.urls')),
path('auth/', include('dj_rest_auth.urls')),
path('api-auth/', include('rest_framework.urls', namespace='rest_framework')),
path('auth/', include('lms.accounts.urls')),
path('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/",
SpectacularSwaggerView.as_view(url_name="api-schema"),
name="api-docs",
),
]
if settings.DEBUG:
# This allows the error pages to be debugged during development, just visit
# these url in browser to see how these error pages look like.
urlpatterns += [
path(
"400/",
default_views.bad_request,
kwargs={"exception": Exception("Bad Request!")},
),
path(
"403/",
default_views.permission_denied,
kwargs={"exception": Exception("Permission Denied")},
),
path(
"404/",
default_views.page_not_found,
kwargs={"exception": Exception("Page not Found")},
),
path("500/", default_views.server_error),
]
if "debug_toolbar" in settings.INSTALLED_APPS:
import debug_toolbar
urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns

13
config/websocket.py Normal file
View file

@ -0,0 +1,13 @@
async def websocket_application(scope, receive, send):
while True:
event = await receive()
if event["type"] == "websocket.connect":
await send({"type": "websocket.accept"})
if event["type"] == "websocket.disconnect":
break
if event["type"] == "websocket.receive":
if event["text"] == "ping":
await send({"type": "websocket.send", "text": "pong!"})

40
config/wsgi.py Normal file
View file

@ -0,0 +1,40 @@
# ruff: noqa
"""
WSGI config for Learning Management System project.
This module contains the WSGI application used by Django's development server
and any production WSGI deployments. It should expose a module-level variable
named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover
this application via the ``WSGI_APPLICATION`` setting.
Usually you will have the standard Django WSGI application here, but it also
might make sense to replace the whole Django WSGI application with a custom one
that later delegates to the Django one. For example, you could introduce WSGI
middleware here, or combine a Django application with an application of another
framework.
"""
import os
import sys
from pathlib import Path
from django.core.wsgi import get_wsgi_application
# This allows easy placement of apps within the interior
# lms directory.
BASE_DIR = Path(__file__).resolve(strict=True).parent.parent
sys.path.append(str(BASE_DIR / "lms"))
# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks
# if running multiple sites in the same mod_wsgi process. To fix this, use
# mod_wsgi daemon mode with each site in its own daemon process, or use
# os.environ["DJANGO_SETTINGS_MODULE"] = "config.settings.production"
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
# This application object is used by any WSGI server configured to use this
# file. This includes Django's development server, if the WSGI_APPLICATION
# setting points here.
application = get_wsgi_application()
# Apply WSGI middleware here.
# from helloworld.wsgi import HelloWorldApplication
# application = HelloWorldApplication(application)

16
docker-compose.docs.yml Normal file
View file

@ -0,0 +1,16 @@
services:
docs:
image: lms_local_docs
container_name: lms_local_docs
build:
context: .
dockerfile: ./compose/local/docs/Dockerfile
env_file:
- ./.envs/.local/.django
volumes:
- ./docs:/docs:z
- ./config:/app/config:z
- ./lms:/app/lms:z
ports:
- '9000:9000'
command: /start-docs

80
docker-compose.local.yml Normal file
View file

@ -0,0 +1,80 @@
volumes:
lms_local_postgres_data: {}
lms_local_postgres_data_backups: {}
lms_local_redis_data: {}
services:
django: &django
build:
context: .
dockerfile: ./compose/local/django/Dockerfile
image: lms_local_django
container_name: lms_local_django
depends_on:
- postgres
- redis
- mailpit
volumes:
- .:/app:z
env_file:
- ./.envs/.local/.django
- ./.envs/.local/.postgres
ports:
- '8000:8000'
command: /start
postgres:
build:
context: .
dockerfile: ./compose/production/postgres/Dockerfile
image: lms_production_postgres
container_name: lms_local_postgres
volumes:
- lms_local_postgres_data:/var/lib/postgresql/data
- lms_local_postgres_data_backups:/backups
env_file:
- ./.envs/.local/.postgres
mailpit:
image: docker.io/axllent/mailpit:latest
container_name: lms_local_mailpit
ports:
- "8025:8025"
redis:
image: docker.io/redis:6
container_name: lms_local_redis
volumes:
- lms_local_redis_data:/data
celeryworker:
<<: *django
image: lms_local_celeryworker
container_name: lms_local_celeryworker
depends_on:
- redis
- postgres
- mailpit
ports: []
command: /start-celeryworker
celerybeat:
<<: *django
image: lms_local_celerybeat
container_name: lms_local_celerybeat
depends_on:
- redis
- postgres
- mailpit
ports: []
command: /start-celerybeat
flower:
<<: *django
image: lms_local_flower
container_name: lms_local_flower
ports:
- '5555:5555'
command: /start-flower

View file

@ -0,0 +1,83 @@
volumes:
production_postgres_data: {}
production_postgres_data_backups: {}
production_traefik: {}
production_django_media: {}
production_redis_data: {}
services:
django: &django
build:
context: .
dockerfile: ./compose/production/django/Dockerfile
image: lms_production_django
volumes:
- production_django_media:/app/lms/media
depends_on:
- postgres
- redis
env_file:
- ./.envs/.production/.django
- ./.envs/.production/.postgres
command: /start
postgres:
build:
context: .
dockerfile: ./compose/production/postgres/Dockerfile
image: lms_production_postgres
volumes:
- production_postgres_data:/var/lib/postgresql/data
- production_postgres_data_backups:/backups
env_file:
- ./.envs/.production/.postgres
traefik:
build:
context: .
dockerfile: ./compose/production/traefik/Dockerfile
image: lms_production_traefik
depends_on:
- django
volumes:
- production_traefik:/etc/traefik/acme
ports:
- '0.0.0.0:80:80'
- '0.0.0.0:443:443'
- '0.0.0.0:5555:5555'
redis:
image: docker.io/redis:6
volumes:
- production_redis_data:/data
celeryworker:
<<: *django
image: lms_production_celeryworker
command: /start-celeryworker
celerybeat:
<<: *django
image: lms_production_celerybeat
command: /start-celerybeat
flower:
<<: *django
image: lms_production_flower
command: /start-flower
nginx:
build:
context: .
dockerfile: ./compose/production/nginx/Dockerfile
image: lms_production_nginx
depends_on:
- django
volumes:
- production_django_media:/usr/share/nginx/media:ro

29
docs/Makefile Normal file
View file

@ -0,0 +1,29 @@
# 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 .

1
docs/__init__.py Normal file
View file

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

63
docs/conf.py Normal file
View file

@ -0,0 +1,63 @@
# 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"]

38
docs/howto.rst Normal file
View file

@ -0,0 +1,38 @@
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

23
docs/index.rst Normal file
View file

@ -0,0 +1,23 @@
.. 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`

46
docs/make.bat Normal file
View file

@ -0,0 +1,46 @@
@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

15
docs/users.rst Normal file
View file

@ -0,0 +1,15 @@
.. _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.users.models
:members:
:noindex:

38
justfile Normal file
View file

@ -0,0 +1,38 @@
export COMPOSE_FILE := "docker-compose.local.yml"
## Just does not yet manage signals for subprocesses reliably, which can lead to unexpected behavior.
## Exercise caution before expanding its usage in production environments.
## For more information, see https://github.com/casey/just/issues/2473 .
# Default command to list all available commands.
default:
@just --list
# build: Build python image.
build:
@echo "Building python image..."
@docker compose build
# up: Start up containers.
up:
@echo "Starting up containers..."
@docker compose up -d --remove-orphans
# down: Stop containers.
down:
@echo "Stopping containers..."
@docker compose down
# prune: Remove containers and their volumes.
prune *args:
@echo "Killing containers and removing volumes..."
@docker compose down -v {{args}}
# logs: View container logs
logs *args:
@docker compose logs -f {{args}}
# manage: Executes `manage.py` command.
manage +args:
@docker compose run --rm django python ./manage.py {{args}}

5
lms/__init__.py Normal file
View file

@ -0,0 +1,5 @@
__version__ = "0.1.0"
__version_info__ = tuple(
int(num) if num.isdigit() else num
for num in __version__.replace("-", ".", 1).split(".")
)

0
lms/accounts/__init__.py Normal file
View file

11
lms/accounts/adapters.py Normal file
View file

@ -0,0 +1,11 @@
from allauth.account.adapter import DefaultAccountAdapter
from django.contrib.sites.models import Site
current_site = Site.objects.get_current()
site_domain = current_site.domain
class CustomAccountAdapter(DefaultAccountAdapter):
def get_email_confirmation_url(self, request, emailconfirmation):
return f"http://{site_domain}/account/email-confirmation/{emailconfirmation.key}/"

27
lms/accounts/admin.py Normal file
View file

@ -0,0 +1,27 @@
from django.contrib import admin
from .models import *
class CustomUserAdmin(admin.ModelAdmin):
model = CustomUser
# تخصيص الحقول
fieldsets = (
(None, {'fields': ('username', 'password')}),
('Personal Info', {'fields': ('email', 'full_name', 'role')}),
('Permissions', {'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions')}),
('Important Dates', {'fields': ('last_login', 'date_joined')}),
)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('username', 'password1', 'password2', 'email', 'full_name', 'role'),
}),
)
list_display = ('username', 'email', 'full_name', 'role', 'is_staff', 'is_active')
search_fields = ('username', 'email', 'full_name')
ordering = ('username',)
admin.site.register(CustomUser, CustomUserAdmin)

6
lms/accounts/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'lms.accounts'

18
lms/accounts/models.py Normal file
View file

@ -0,0 +1,18 @@
from django.db import models
from django.contrib.auth.models import AbstractUser
class CustomUser(AbstractUser):
role = {
('student', 'student'),
('instructor', 'instructor'),
('admin', 'admin'),
}
first_name = None
last_name = None
email = models.EmailField(unique=True)
full_name = models.CharField(max_length=255, null=True)
role = models.CharField(max_length=255, null=True, choices=role)
def __str__(self):
return self.email

View file

@ -0,0 +1,89 @@
from rest_framework import serializers
class CustomLoginSerializer(LoginSerializer):
email = serializers.EmailField(required=True)
password = serializers.CharField(style={'input_type': 'password'})
def validate(self, attrs):
email = attrs.get('email')
password = attrs.get('password')
if not email or not password:
raise serializers.ValidationError("Please enter both email and password.")
User = get_user_model()
users = User.objects.filter(email=email)
if not users.exists():
raise serializers.ValidationError("Incorrect email.")
if users.count() > 1:
raise serializers.ValidationError("Multiple users found with this email. Please contact support.")
user = users.first()
if not user.check_password(password):
raise serializers.ValidationError("Incorrect password.")
if not self.is_email_verified(user):
raise serializers.ValidationError("Email not verified. Please verify your email first.")
attrs['user'] = user
return attrs
def is_email_verified(self, user):
if hasattr(user, 'email_verified'):
return user.email_verified
else:
try:
email_address = EmailAddress.objects.get(user=user, email=user.email)
return email_address.verified
except EmailAddress.DoesNotExist:
return False
class CustomRegisterSerializer(RegisterSerializer):
full_name = serializers.CharField(required=True)
def save(self, request):
user = super().save(request)
user.full_name = self.data.get('full_name', '')
user.save()
return user
class ChangeEmailSerializer(serializers.Serializer):
email = serializers.EmailField()

3
lms/accounts/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

5
lms/accounts/urls.py Normal file
View file

@ -0,0 +1,5 @@
from django.urls import path
from . import views
urlpatterns = [
path("change-email/", views.CustomConfirmEmailView.as_view(), name="change-email")
]

23
lms/accounts/views.py Normal file
View file

@ -0,0 +1,23 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework import status
from django.utils.translation import gettext as _
from allauth.account.models import EmailConfirmation, EmailConfirmationHMAC
from rest_framework.permissions import AllowAny
from .serializers import ChangeEmailSerializer
class CustomConfirmEmailView(APIView):
def post(self, request, *args, **kwargs):
serializer = ChangeEmailSerializer(data=request.data)
if serializer.is_valid():
user = request.user
new_email = serializer.validated_data['email']
# تغيير البريد الإلكتروني
email_address, created = EmailAddress.objects.get_or_create(user=user, email=new_email)
if not email_address.verified:
send_email_confirmation(request, user, email=email_address.email)
return Response({"message": "تم إرسال بريد تأكيد إلى البريد الإلكتروني الجديد."}, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)

0
lms/app/__init__.py Normal file
View file

39
lms/app/admin.py Normal file
View file

@ -0,0 +1,39 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import *
@admin.register(Course)
class CourseAdmin(admin.ModelAdmin):
list_display = ('title', 'instructor', 'created_at', 'updated_at')
search_fields = ('title', 'instructor__username')
list_filter = ('created_at', 'updated_at')
@admin.register(Module)
class ModuleAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'course', 'order')
search_fields = ('title', 'course__title')
list_filter = ('course',)
@admin.register(Lesson)
class LessonAdmin(admin.ModelAdmin):
list_display = ('title', 'module', 'order')
search_fields = ('title', 'module__title')
list_filter = ('module',)
@admin.register(Enrollment)
class EnrollmentAdmin(admin.ModelAdmin):
list_display = ('student', 'course', 'enrolled_at', 'completed')
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):
list_display = ('student', 'course', 'issued_at')
search_fields = ('student__username', 'course__title')
list_filter = ('issued_at',)

6
lms/app/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class AppConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'lms.app'

77
lms/app/models.py Normal file
View file

@ -0,0 +1,77 @@
from django.db import models
from uuid import uuid4
from django.contrib.auth import get_user_model
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")
description = models.TextField(verbose_name="Course Description")
instructor = models.ForeignKey(User, on_delete=models.CASCADE, related_name='courses_taught', verbose_name="Instructor")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created At")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Updated At")
def str(self):
return self.title
# 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")
description = models.TextField(verbose_name="Module Description")
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='modules', verbose_name="Course")
order = models.PositiveIntegerField(default=0, verbose_name="Order", unique=True)
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")
content = models.TextField(verbose_name="Lesson Content")
module = models.ForeignKey(Module, on_delete=models.CASCADE, related_name='lessons', verbose_name="Module")
order = models.PositiveIntegerField(default=0, verbose_name="Order")
file = models.FileField(upload_to='lesson_files/', null=True, blank=True, verbose_name="Attached File")
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")
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")
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")
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='certificates', verbose_name="Course")
issued_at = models.DateTimeField(auto_now_add=True, verbose_name="Issued At")
certificate_file = models.FileField(upload_to='certificates/', verbose_name="Certificate File")
def str(self):
return f"{self.student.username} - {self.course.title}"

19
lms/app/permissions.py Normal file
View file

@ -0,0 +1,19 @@
from rest_framework.permissions import BasePermission
class IsInstructor(BasePermission):
"""
Custom permission to allow access only to users with role 'instructor'.
"""
def has_permission(self, request, view):
# Ensure the user is authenticated and has a role of 'instructor'
return request.user.is_authenticated and request.user.role == 'instructor'
class IsAdmin(BasePermission):
"""
Custom permission to allow access only to users with role 'instructor'.
"""
def has_permission(self, request, view):
# Ensure the user is authenticated and has a role of 'instructor'
return request.user.is_authenticated and request.user.role == 'admin'

119
lms/app/serializers.py Normal file
View file

@ -0,0 +1,119 @@
from rest_framework import serializers
from .models import *
from dj_rest_auth.serializers import LoginSerializer
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
class CustomLoginSerializer(LoginSerializer):
email = serializers.EmailField(required=True)
password = serializers.CharField(style={'input_type': 'password'})
def validate(self, attrs):
email = attrs.get('email')
password = attrs.get('password')
if not email or not password:
raise serializers.ValidationError("Please enter both email and password.")
User = get_user_model()
users = User.objects.filter(email=email)
if not users.exists():
raise serializers.ValidationError("Incorrect email.")
if users.count() > 1:
raise serializers.ValidationError("Multiple users found with this email. Please contact support.")
user = users.first()
if not user.check_password(password):
raise serializers.ValidationError("Incorrect password.")
if not self.is_email_verified(user):
raise serializers.ValidationError("Email not verified. Please verify your email first.")
attrs['user'] = user
return attrs
def is_email_verified(self, user):
if hasattr(user, 'email_verified'):
return user.email_verified
else:
try:
email_address = EmailAddress.objects.get(user=user, email=user.email)
return email_address.verified
except EmailAddress.DoesNotExist:
return False
class CustomRegisterSerializer(RegisterSerializer):
full_name = serializers.CharField(required=True)
def save(self, request):
user = super().save(request)
user.full_name = self.data.get('full_name', '')
user.save()
return user
class CourseSerializer(serializers.HyperlinkedModelSerializer):
instructor_name = serializers.CharField(source='instructor.username', read_only=True)
class Meta:
model = Course
fields = ['url', 'id', 'title', 'description', 'instructor_name', 'created_at', 'updated_at']
class ModuleSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Module
fields = ['url', 'id', 'title', 'description', 'course', 'order']
extra_kwargs = {
'url': {'view_name': 'modules-detail', 'lookup_field': 'id'}
}
class LessonSerializer(serializers.HyperlinkedModelSerializer):
module = serializers.PrimaryKeyRelatedField(queryset=Module.objects.all())
class Meta:
model = Lesson
fields = ['url', 'id', 'title', 'content', 'module', 'order', 'file']
extra_kwargs = {
'url': {'view_name': 'lessons-detail', 'lookup_field': 'id'}
}
class EnrollmentSerializer(serializers.ModelSerializer):
class Meta:
model = Enrollment
fields = ['id', 'student', 'course', 'enrolled_at', 'completed']
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']

3
lms/app/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

13
lms/app/urls.py Normal file
View file

@ -0,0 +1,13 @@
from django.urls import path
from .views import *
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
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')
urlpatterns = router.urls

340
lms/app/views.py Normal file
View file

@ -0,0 +1,340 @@
from django.shortcuts import render
from .serializers import *
from .models import *
from rest_framework.viewsets import ModelViewSet
from rest_framework.response import Response
from rest_framework import status
from rest_framework.permissions import IsAuthenticated, BasePermission
from .permissions import IsInstructor, IsAdmin
class CourseViewSet(ModelViewSet):
"""
A ViewSet for viewing and editing Course instances.
"""
permission_classes = [IsAuthenticated,]
queryset = Course.objects.all()
serializer_class = CourseSerializer
def perform_create(self, serializer):
"""
Save the post data when creating a new course.
"""
serializer.save(instructor=self.request.user)
def perform_update(self, serializer):
"""
Ensure that only the instructor can update their course.
"""
course = self.get_object()
if course.instructor != self.request.user:
return Response(
{"detail": "You do not have permission to edit this course."},
status=status.HTTP_403_FORBIDDEN,
)
serializer.save()
def perform_destroy(self, instance):
"""
Ensure that only the instructor can delete their course.
"""
if instance.instructor != self.request.user:
return Response(
{"detail": "You do not have permission to delete this course."},
status=status.HTTP_403_FORBIDDEN,
)
instance.delete()
class ModuleViewSet(ModelViewSet):
"""
ViewSet for managing modules.
"""
serializer_class = ModuleSerializer
permission_classes = [IsAuthenticated]
lookup_field = 'id'
def get_queryset(self):
"""
Return modules only if the user is the course instructor.
"""
course_id = self.request.query_params.get('course_id')
if course_id:
course = Course.objects.filter(id=course_id).first()
if course:
return Module.objects.filter(course=course).order_by('order')
return Module.objects.none()
def perform_create(self, serializer):
"""
Allow only the course instructor to create a module.
"""
course_id = self.request.data.get('course')
course = Course.objects.filter(id=course_id, instructor=self.request.user).first()
if not course:
return Response(
{"detail": "You do not have permission to delete this course."},
status=status.HTTP_403_FORBIDDEN,
)
serializer.save(course=course)
def perform_update(self, serializer):
"""
Allow only the course instructor to update the module.
"""
module = self.get_object()
if module.course.instructor != self.request.user:
return Response(
{"detail": "You do not have permission to delete this course."},
status=status.HTTP_403_FORBIDDEN,
)
serializer.save()
def perform_destroy(self, instance):
"""
Allow only the course instructor to delete the module.
"""
if instance.course.instructor != self.request.user:
return Response(
{"detail": "You do not have permission to delete this course."},
status=status.HTTP_403_FORBIDDEN,
)
instance.delete()
class LessonViewSet(ModelViewSet):
"""
ViewSet for managing lessons.
"""
serializer_class = LessonSerializer
permission_classes = [IsAuthenticated]
lookup_field = 'id'
def get_queryset(self):
"""
Return lessons only if the user is authorized (instructor or student) and provides valid data.
"""
# حالة جلب كائن واحد
if self.kwargs.get(self.lookup_field): # 'id' by default
lesson = Lesson.objects.filter(id=self.kwargs[self.lookup_field]).first()
if lesson:
course = lesson.module.course
# التحقق من الصلاحيات
if course.instructor == self.request.user or Enrollment.objects.filter(student=self.request.user, course=course).exists():
return Lesson.objects.filter(id=lesson.id)
return Lesson.objects.none()
# حالة جلب مجموعة بناءً على module_id
module_id = self.request.query_params.get('module_id')
if module_id:
module = Module.objects.filter(id=module_id).first()
if module:
course = module.course
# التحقق من الصلاحيات
if course.instructor == self.request.user or Enrollment.objects.filter(student=self.request.user, course=course).exists():
queryset = Lesson.objects.filter(module=module).order_by('order')
print(f"Queryset: {queryset}")
return queryset
# في حالة عدم وجود صلاحيات أو عدم تطابق البيانات
return Lesson.objects.none()
def perform_create(self, serializer):
"""
Allow only the course instructor to create a lesson within their module.
"""
module_id = self.request.data.get('module')
module = Module.objects.filter(id=module_id, course__instructor=self.request.user).first()
if not module:
return Response(
{"detail": "You do not have permission to create a lesson in this module."},
status=status.HTTP_403_FORBIDDEN,
)
serializer.save(module=module)
def perform_update(self, serializer):
"""
Allow only the course instructor to update a lesson within their module.
"""
lesson = self.get_object()
if lesson.module.course.instructor != self.request.user:
return Response(
{"detail": "You do not have permission to update this lesson."},
status=status.HTTP_403_FORBIDDEN,
)
serializer.save()
def perform_destroy(self, instance):
"""
Allow only the course instructor to delete a lesson within their module.
"""
if instance.module.course.instructor != self.request.user:
return Response(
{"detail": "You do not have permission to delete this lesson."},
status=status.HTTP_403_FORBIDDEN,
)
instance.delete()
class EnrollmentViewSet(ModelViewSet):
queryset = Enrollment.objects.all()
serializer_class = EnrollmentSerializer
permission_classes = [IsAuthenticated]
def create(self, request, *args, **kwargs):
# Ensure the user is an instructor
if request.user.role != 'instructor':
return Response({"error": "Only instructors can enroll students"}, status=status.HTTP_403_FORBIDDEN)
# Get student and course data from the request
student_id = request.data.get('student_id')
course_id = request.data.get('course_id')
# Check if the student and course exist
try:
student = User.objects.get(id=student_id, role='student')
course = Course.objects.get(id=course_id)
except User.DoesNotExist:
return Response({"error": "Student not found"}, status=status.HTTP_404_NOT_FOUND)
except Course.DoesNotExist:
return Response({"error": "Course not found"}, status=status.HTTP_404_NOT_FOUND)
# Ensure the current instructor is the course instructor
if course.instructor != request.user:
return Response({"error": "You can only enroll students in your own courses"}, status=status.HTTP_403_FORBIDDEN)
# Create a new enrollment
enrollment = Enrollment.objects.create(student=student, course=course)
serializer = self.get_serializer(enrollment)
return Response(serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs):
# Ensure the user is an instructor
if request.user.role != 'instructor':
return Response({"error": "Only instructors can update enrollments"}, status=status.HTTP_403_FORBIDDEN)
# Get the enrollment object to update
enrollment = self.get_object()
# Ensure the current instructor is the course instructor
if enrollment.course.instructor != request.user:
return Response({"error": "You can only update enrollments in your own courses"}, status=status.HTTP_403_FORBIDDEN)
# Update the enrollment
serializer = self.get_serializer(enrollment, 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 instructor
if request.user.role != 'instructor':
return Response({"error": "Only instructors can delete enrollments"}, status=status.HTTP_403_FORBIDDEN)
# Get the enrollment object to delete
enrollment = self.get_object()
# Ensure the current instructor is the course instructor
if enrollment.course.instructor != request.user:
return Response({"error": "You can only delete enrollments in your own courses"}, status=status.HTTP_403_FORBIDDEN)
# Delete the enrollment
enrollment.delete()
return Response({"message": "Enrollment deleted successfully"}, status=status.HTTP_204_NO_CONTENT)
class QuizViewSet(ModelViewSet):
queryset = Quiz.objects.all()
serializer_class = QuizSerializer
permission_classes = [IsAuthenticated]
def create(self, request, *args, **kwargs):
# Ensure the user is an instructor
if request.user.role != 'instructor':
return Response({"error": "Only instructors 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({"error": "Course not found"}, status=status.HTTP_404_NOT_FOUND)
# Ensure the current instructor is the course instructor
if module.course.instructor != request.user:
return Response({"error": "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 instructor
if request.user.role != 'instructor':
return Response({"error": "Only instructors can update quizzes"}, status=status.HTTP_403_FORBIDDEN)
# Get the quiz object to update
quiz = self.get_object()
# Ensure the current instructor is the course instructor
if quiz.module.course.instructor != request.user:
return Response({"error": "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 instructor
if request.user.role != 'instructor':
return Response({"error": "Only instructors can delete quizzes"}, status=status.HTTP_403_FORBIDDEN)
# Get the quiz object to delete
quiz = self.get_object()
# Ensure the current instructor is the course instructor
if quiz.module.course.instructor != request.user:
return Response({"error": "You can only delete quizzes for your own courses"}, status=status.HTTP_403_FORBIDDEN)
# Delete the quiz
quiz.delete()
return Response({"message": "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 = [IsInstructor]
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({"error": "Student not found"}, status=status.HTTP_404_NOT_FOUND)
except Course.DoesNotExist:
return Response({"error": "Course not found"}, status=status.HTTP_404_NOT_FOUND)
# Ensure the current instructor is the course instructor
if course.instructor != request.user:
return Response({"error": "You can only create certificate for your own courses"}, status=status.HTTP_403_FORBIDDEN)
certificate = Certificate.objects.create(course=course, student=student)
serializer = self.get_serializer(certificate)
return Response(serializer.data, status=status.HTTP_201_CREATED)

14
lms/conftest.py Normal file
View file

@ -0,0 +1,14 @@
import pytest
from lms.users.models import User
from lms.users.tests.factories import UserFactory
@pytest.fixture(autouse=True)
def _media_storage(settings, tmpdir) -> None:
settings.MEDIA_ROOT = tmpdir.strpath
@pytest.fixture
def user(db) -> User:
return UserFactory()

5
lms/contrib/__init__.py Normal file
View file

@ -0,0 +1,5 @@
"""
To understand why this file is here, please read:
http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django
"""

View file

@ -0,0 +1,5 @@
"""
To understand why this file is here, please read:
http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django
"""

View file

@ -0,0 +1,43 @@
import django.contrib.sites.models
from django.contrib.sites.models import _simple_domain_name_validator
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = []
operations = [
migrations.CreateModel(
name="Site",
fields=[
(
"id",
models.AutoField(
verbose_name="ID",
serialize=False,
auto_created=True,
primary_key=True,
),
),
(
"domain",
models.CharField(
max_length=100,
verbose_name="domain name",
validators=[_simple_domain_name_validator],
),
),
("name", models.CharField(max_length=50, verbose_name="display name")),
],
options={
"ordering": ("domain",),
"db_table": "django_site",
"verbose_name": "site",
"verbose_name_plural": "sites",
},
bases=(models.Model,),
managers=[("objects", django.contrib.sites.models.SiteManager())],
),
]

View file

@ -0,0 +1,21 @@
import django.contrib.sites.models
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [("sites", "0001_initial")]
operations = [
migrations.AlterField(
model_name="site",
name="domain",
field=models.CharField(
max_length=100,
unique=True,
validators=[django.contrib.sites.models._simple_domain_name_validator],
verbose_name="domain name",
),
)
]

View file

@ -0,0 +1,63 @@
"""
To understand why this file is here, please read:
http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django
"""
from django.conf import settings
from django.db import migrations
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."""
site, created = site_model.objects.update_or_create(
id=settings.SITE_ID,
defaults={
"domain": domain,
"name": name,
},
)
if created:
# We provided the ID explicitly when creating the Site entry, therefore the DB
# sequence to auto-generate them wasn't used and is now out of sync. If we
# don't do anything, we'll get a unique constraint violation the next time a
# site is created.
# To avoid this, we need to manually update DB sequence and make sure it's
# greater than the maximum value.
max_id = site_model.objects.order_by("-id").first().id
with connection.cursor() as cursor:
cursor.execute("SELECT last_value from django_site_id_seq")
(current_id,) = cursor.fetchone()
if current_id <= max_id:
cursor.execute(
"alter sequence django_site_id_seq restart with %s",
[max_id + 1],
)
def update_site_forward(apps, schema_editor):
"""Set site domain and name."""
Site = apps.get_model("sites", "Site")
_update_or_create_site_with_sequence(
Site,
schema_editor.connection,
"example.com",
"Learning Management System",
)
def update_site_backward(apps, schema_editor):
"""Revert site domain and name to default."""
Site = apps.get_model("sites", "Site")
_update_or_create_site_with_sequence(
Site,
schema_editor.connection,
"example.com",
"example.com",
)
class Migration(migrations.Migration):
dependencies = [("sites", "0002_alter_domain_unique")]
operations = [migrations.RunPython(update_site_forward, update_site_backward)]

View file

@ -0,0 +1,21 @@
# Generated by Django 3.1.7 on 2021-02-04 14:49
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("sites", "0003_set_site_domain_and_name"),
]
operations = [
migrations.AlterModelOptions(
name="site",
options={
"ordering": ["domain"],
"verbose_name": "site",
"verbose_name_plural": "sites",
},
),
]

View file

@ -0,0 +1,5 @@
"""
To understand why this file is here, please read:
http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django
"""

View file

@ -0,0 +1,13 @@
/* These styles are generated from project.scss. */
.alert-debug {
color: black;
background-color: white;
border-color: #d6e9c6;
}
.alert-error {
color: #b94a48;
background-color: #f2dede;
border-color: #eed3d7;
}

View file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

1
lms/static/js/project.js Normal file
View file

@ -0,0 +1 @@
/* Project specific Javascript goes here. */

13
lms/templates/403.html Normal file
View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Forbidden (403){% endblock title %}
{% block content %}
<h1>Forbidden (403)</h1>
<p>
{% if exception %}
{{ exception }}
{% else %}
You're not allowed to access this page.
{% endif %}
</p>
{% endblock content %}

View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Forbidden (403){% endblock title %}
{% block content %}
<h1>Forbidden (403)</h1>
<p>
{% if exception %}
{{ exception }}
{% else %}
You're not allowed to access this page.
{% endif %}
</p>
{% endblock content %}

13
lms/templates/404.html Normal file
View file

@ -0,0 +1,13 @@
{% extends "base.html" %}
{% block title %}Page not found{% endblock title %}
{% block content %}
<h1>Page not found</h1>
<p>
{% if exception %}
{{ exception }}
{% else %}
This is not the page you were looking for.
{% endif %}
</p>
{% endblock content %}

10
lms/templates/500.html Normal file
View file

@ -0,0 +1,10 @@
{% extends "base.html" %}
{% block title %}Server Error{% endblock title %}
{% block content %}
<h1>Ooops!!! 500</h1>
<h3>Looks like something went wrong!</h3>
<p>
We track these errors automatically, but if the problem persists feel free to contact us. In the meantime, try refreshing.
</p>
{% endblock content %}

View file

@ -0,0 +1,10 @@
{% extends "account/email/base_message.txt" %}
{% load i18n %}
{% block content %}{% autoescape off %}{% blocktrans %}You're receiving this email because you or someone else has requested a password reset for your user account.
It can be safely ignored if you did not request a password reset. Click the link below to reset your password.{% endblocktrans %}
http://{{ current_site }}/account/reset-password/{{ uid }}/{{ token }}/
{% endautoescape %}{% endblock content %}

View file

@ -0,0 +1,7 @@
{% load i18n %}
{% load allauth %}
<div class="alert alert-error">
{% slot message %}
{% endslot %}
</div>

Some files were not shown because too many files have changed in this diff Show more