Update
This commit is contained in:
parent
5133a243ab
commit
e58c9c1f71
153 changed files with 6115 additions and 0 deletions
20
.devcontainer/bashrc.override.sh
Normal file
20
.devcontainer/bashrc.override.sh
Normal 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)"
|
||||||
68
.devcontainer/devcontainer.json
Normal file
68
.devcontainer/devcontainer.json
Normal 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 container’s 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
12
.dockerignore
Normal 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
27
.editorconfig
Normal 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
14
.envs/.local/.django
Normal 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
7
.envs/.local/.postgres
Normal 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
45
.envs/.production/.django
Normal 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
|
||||||
|
|
||||||
7
.envs/.production/.postgres
Normal file
7
.envs/.production/.postgres
Normal 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
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
* text=auto
|
||||||
98
.github/dependabot.yml
vendored
Normal file
98
.github/dependabot.yml
vendored
Normal 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
59
.github/workflows/ci.yml
vendored
Normal 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
279
.gitignore
vendored
Normal 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
50
.pre-commit-config.yaml
Normal 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
20
.readthedocs.yml
Normal 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
|
||||||
92
compose/local/django/Dockerfile
Normal file
92
compose/local/django/Dockerfile
Normal 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"]
|
||||||
8
compose/local/django/celery/beat/start
Normal file
8
compose/local/django/celery/beat/start
Normal 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'
|
||||||
16
compose/local/django/celery/flower/start
Normal file
16
compose/local/django/celery/flower/start
Normal 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}\""
|
||||||
7
compose/local/django/celery/worker/start
Normal file
7
compose/local/django/celery/worker/start
Normal 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'
|
||||||
9
compose/local/django/start
Normal file
9
compose/local/django/start
Normal 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'
|
||||||
62
compose/local/docs/Dockerfile
Normal file
62
compose/local/docs/Dockerfile
Normal 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
7
compose/local/docs/start
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -o errexit
|
||||||
|
set -o pipefail
|
||||||
|
set -o nounset
|
||||||
|
|
||||||
|
exec make livehtml
|
||||||
98
compose/production/django/Dockerfile
Normal file
98
compose/production/django/Dockerfile
Normal 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"]
|
||||||
8
compose/production/django/celery/beat/start
Normal file
8
compose/production/django/celery/beat/start
Normal 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
|
||||||
19
compose/production/django/celery/flower/start
Normal file
19
compose/production/django/celery/flower/start
Normal 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}"
|
||||||
8
compose/production/django/celery/worker/start
Normal file
8
compose/production/django/celery/worker/start
Normal 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
|
||||||
17
compose/production/django/entrypoint
Normal file
17
compose/production/django/entrypoint
Normal 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 "$@"
|
||||||
29
compose/production/django/start
Normal file
29
compose/production/django/start
Normal 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
|
||||||
2
compose/production/nginx/Dockerfile
Normal file
2
compose/production/nginx/Dockerfile
Normal 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
|
||||||
7
compose/production/nginx/default.conf
Normal file
7
compose/production/nginx/default.conf
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
location /media/ {
|
||||||
|
alias /usr/share/nginx/media/;
|
||||||
|
}
|
||||||
|
}
|
||||||
6
compose/production/postgres/Dockerfile
Normal file
6
compose/production/postgres/Dockerfile
Normal 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
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
|
||||||
|
BACKUP_DIR_PATH='/backups'
|
||||||
|
BACKUP_FILE_PREFIX='backup'
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
41
compose/production/postgres/maintenance/_sourced/messages.sh
Normal file
41
compose/production/postgres/maintenance/_sourced/messages.sh
Normal 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: ${@}"
|
||||||
|
}
|
||||||
16
compose/production/postgres/maintenance/_sourced/yes_no.sh
Normal file
16
compose/production/postgres/maintenance/_sourced/yes_no.sh
Normal 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
|
||||||
|
}
|
||||||
38
compose/production/postgres/maintenance/backup
Normal file
38
compose/production/postgres/maintenance/backup
Normal 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}'."
|
||||||
22
compose/production/postgres/maintenance/backups
Normal file
22
compose/production/postgres/maintenance/backups
Normal 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}"
|
||||||
55
compose/production/postgres/maintenance/restore
Normal file
55
compose/production/postgres/maintenance/restore
Normal 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."
|
||||||
36
compose/production/postgres/maintenance/rmbackup
Normal file
36
compose/production/postgres/maintenance/rmbackup
Normal 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."
|
||||||
5
compose/production/traefik/Dockerfile
Normal file
5
compose/production/traefik/Dockerfile
Normal 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
|
||||||
90
compose/production/traefik/traefik.yml
Normal file
90
compose/production/traefik/traefik.yml
Normal 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
5
config/__init__.py
Normal 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
13
config/api_router.py
Normal 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
43
config/asgi.py
Normal 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
28
config/celery_app.py
Normal 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()
|
||||||
0
config/settings/__init__.py
Normal file
0
config/settings/__init__.py
Normal file
392
config/settings/base.py
Normal file
392
config/settings/base.py
Normal 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
83
config/settings/local.py
Normal 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...
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
206
config/settings/production.py
Normal file
206
config/settings/production.py
Normal 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
38
config/settings/test.py
Normal 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
83
config/urls.py
Normal 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
13
config/websocket.py
Normal 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
40
config/wsgi.py
Normal 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
16
docker-compose.docs.yml
Normal 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
80
docker-compose.local.yml
Normal 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
|
||||||
83
docker-compose.production.yml
Normal file
83
docker-compose.production.yml
Normal 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
29
docs/Makefile
Normal 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
1
docs/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
# Included so that Django's startproject comment runs against the docs directory
|
||||||
63
docs/conf.py
Normal file
63
docs/conf.py
Normal 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
38
docs/howto.rst
Normal 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
23
docs/index.rst
Normal 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
46
docs/make.bat
Normal 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
15
docs/users.rst
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
.. _users:
|
||||||
|
|
||||||
|
Users
|
||||||
|
======================================================================
|
||||||
|
|
||||||
|
Starting a new project, it’s highly recommended to set up a custom user model,
|
||||||
|
even if the default User model is sufficient for you.
|
||||||
|
|
||||||
|
This model behaves identically to the default user model,
|
||||||
|
but you’ll be able to customize it in the future if the need arises.
|
||||||
|
|
||||||
|
.. automodule:: lms.users.models
|
||||||
|
:members:
|
||||||
|
:noindex:
|
||||||
|
|
||||||
38
justfile
Normal file
38
justfile
Normal 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
5
lms/__init__.py
Normal 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
0
lms/accounts/__init__.py
Normal file
11
lms/accounts/adapters.py
Normal file
11
lms/accounts/adapters.py
Normal 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
27
lms/accounts/admin.py
Normal 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
6
lms/accounts/apps.py
Normal 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
18
lms/accounts/models.py
Normal 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
|
||||||
89
lms/accounts/serializers.py
Normal file
89
lms/accounts/serializers.py
Normal 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
3
lms/accounts/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
5
lms/accounts/urls.py
Normal file
5
lms/accounts/urls.py
Normal 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
23
lms/accounts/views.py
Normal 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
0
lms/app/__init__.py
Normal file
39
lms/app/admin.py
Normal file
39
lms/app/admin.py
Normal 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
6
lms/app/apps.py
Normal 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
77
lms/app/models.py
Normal 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
19
lms/app/permissions.py
Normal 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
119
lms/app/serializers.py
Normal 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
3
lms/app/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
13
lms/app/urls.py
Normal file
13
lms/app/urls.py
Normal 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
340
lms/app/views.py
Normal 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
14
lms/conftest.py
Normal 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
5
lms/contrib/__init__.py
Normal 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
|
||||||
|
"""
|
||||||
5
lms/contrib/sites/__init__.py
Normal file
5
lms/contrib/sites/__init__.py
Normal 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
|
||||||
|
"""
|
||||||
43
lms/contrib/sites/migrations/0001_initial.py
Normal file
43
lms/contrib/sites/migrations/0001_initial.py
Normal 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())],
|
||||||
|
),
|
||||||
|
]
|
||||||
21
lms/contrib/sites/migrations/0002_alter_domain_unique.py
Normal file
21
lms/contrib/sites/migrations/0002_alter_domain_unique.py
Normal 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",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
@ -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)]
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
5
lms/contrib/sites/migrations/__init__.py
Normal file
5
lms/contrib/sites/migrations/__init__.py
Normal 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
|
||||||
|
"""
|
||||||
13
lms/static/css/project.css
Normal file
13
lms/static/css/project.css
Normal 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;
|
||||||
|
}
|
||||||
0
lms/static/fonts/.gitkeep
Normal file
0
lms/static/fonts/.gitkeep
Normal file
BIN
lms/static/images/favicons/favicon.ico
Normal file
BIN
lms/static/images/favicons/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
1
lms/static/js/project.js
Normal file
1
lms/static/js/project.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/* Project specific Javascript goes here. */
|
||||||
13
lms/templates/403.html
Normal file
13
lms/templates/403.html
Normal 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/403_csrf.html
Normal file
13
lms/templates/403_csrf.html
Normal 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
13
lms/templates/404.html
Normal 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
10
lms/templates/500.html
Normal 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 %}
|
||||||
10
lms/templates/account/email/password_reset_key_message.txt
Normal file
10
lms/templates/account/email/password_reset_key_message.txt
Normal 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 %}
|
||||||
|
|
||||||
|
|
||||||
7
lms/templates/allauth/elements/alert.html
Normal file
7
lms/templates/allauth/elements/alert.html
Normal 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
Loading…
Add table
Reference in a new issue