From e58c9c1f710544ece29da62b82f0a191134e4191 Mon Sep 17 00:00:00 2001 From: Ahmed Nagi Date: Fri, 10 Jan 2025 19:54:55 +0200 Subject: [PATCH] Update --- .devcontainer/bashrc.override.sh | 20 + .devcontainer/devcontainer.json | 68 +++ .dockerignore | 12 + .editorconfig | 27 ++ .envs/.local/.django | 14 + .envs/.local/.postgres | 7 + .envs/.production/.django | 45 ++ .envs/.production/.postgres | 7 + .gitattributes | 1 + .github/dependabot.yml | 98 +++++ .github/workflows/ci.yml | 59 +++ .gitignore | 279 +++++++++++++ .pre-commit-config.yaml | 50 +++ .readthedocs.yml | 20 + compose/local/django/Dockerfile | 92 ++++ compose/local/django/celery/beat/start | 8 + compose/local/django/celery/flower/start | 16 + compose/local/django/celery/worker/start | 7 + compose/local/django/start | 9 + compose/local/docs/Dockerfile | 62 +++ compose/local/docs/start | 7 + compose/production/django/Dockerfile | 98 +++++ compose/production/django/celery/beat/start | 8 + compose/production/django/celery/flower/start | 19 + compose/production/django/celery/worker/start | 8 + compose/production/django/entrypoint | 17 + compose/production/django/start | 29 ++ compose/production/nginx/Dockerfile | 2 + compose/production/nginx/default.conf | 7 + compose/production/postgres/Dockerfile | 6 + .../maintenance/_sourced/constants.sh | 5 + .../maintenance/_sourced/countdown.sh | 12 + .../postgres/maintenance/_sourced/messages.sh | 41 ++ .../postgres/maintenance/_sourced/yes_no.sh | 16 + .../production/postgres/maintenance/backup | 38 ++ .../production/postgres/maintenance/backups | 22 + .../production/postgres/maintenance/restore | 55 +++ .../production/postgres/maintenance/rmbackup | 36 ++ compose/production/traefik/Dockerfile | 5 + compose/production/traefik/traefik.yml | 90 ++++ config/__init__.py | 5 + config/api_router.py | 13 + config/asgi.py | 43 ++ config/celery_app.py | 28 ++ config/settings/__init__.py | 0 config/settings/base.py | 392 ++++++++++++++++++ config/settings/local.py | 83 ++++ config/settings/production.py | 206 +++++++++ config/settings/test.py | 38 ++ config/urls.py | 83 ++++ config/websocket.py | 13 + config/wsgi.py | 40 ++ docker-compose.docs.yml | 16 + docker-compose.local.yml | 80 ++++ docker-compose.production.yml | 83 ++++ docs/Makefile | 29 ++ docs/__init__.py | 1 + docs/conf.py | 63 +++ docs/howto.rst | 38 ++ docs/index.rst | 23 + docs/make.bat | 46 ++ docs/users.rst | 15 + justfile | 38 ++ lms/__init__.py | 5 + lms/accounts/__init__.py | 0 lms/accounts/adapters.py | 11 + lms/accounts/admin.py | 27 ++ lms/accounts/apps.py | 6 + lms/accounts/models.py | 18 + lms/accounts/serializers.py | 89 ++++ lms/accounts/tests.py | 3 + lms/accounts/urls.py | 5 + lms/accounts/views.py | 23 + lms/app/__init__.py | 0 lms/app/admin.py | 39 ++ lms/app/apps.py | 6 + lms/app/models.py | 77 ++++ lms/app/permissions.py | 19 + lms/app/serializers.py | 119 ++++++ lms/app/tests.py | 3 + lms/app/urls.py | 13 + lms/app/views.py | 340 +++++++++++++++ lms/conftest.py | 14 + lms/contrib/__init__.py | 5 + lms/contrib/sites/__init__.py | 5 + lms/contrib/sites/migrations/0001_initial.py | 43 ++ .../migrations/0002_alter_domain_unique.py | 21 + .../0003_set_site_domain_and_name.py | 63 +++ .../0004_alter_options_ordering_domain.py | 21 + lms/contrib/sites/migrations/__init__.py | 5 + lms/static/css/project.css | 13 + lms/static/fonts/.gitkeep | 0 lms/static/images/favicons/favicon.ico | Bin 0 -> 8348 bytes lms/static/js/project.js | 1 + lms/templates/403.html | 13 + lms/templates/403_csrf.html | 13 + lms/templates/404.html | 13 + lms/templates/500.html | 10 + .../email/password_reset_key_message.txt | 10 + lms/templates/allauth/elements/alert.html | 7 + lms/templates/allauth/elements/badge.html | 6 + lms/templates/allauth/elements/button.html | 20 + lms/templates/allauth/elements/field.html | 66 +++ lms/templates/allauth/elements/fields.html | 3 + lms/templates/allauth/elements/panel.html | 19 + lms/templates/allauth/elements/table.html | 6 + lms/templates/allauth/layouts/entrance.html | 31 ++ lms/templates/allauth/layouts/manage.html | 6 + lms/templates/base.html | 145 +++++++ lms/templates/pages/about.html | 3 + lms/templates/pages/home.html | 3 + lms/templates/users/user_detail.html | 41 ++ lms/templates/users/user_form.html | 31 ++ lms/users/__init__.py | 0 lms/users/adapters.py | 48 +++ lms/users/admin.py | 50 +++ lms/users/api/__init__.py | 0 lms/users/api/serializers.py | 13 + lms/users/api/views.py | 26 ++ lms/users/apps.py | 13 + lms/users/context_processors.py | 8 + lms/users/forms.py | 44 ++ lms/users/managers.py | 42 ++ lms/users/migrations/0001_initial.py | 112 +++++ lms/users/migrations/__init__.py | 0 lms/users/models.py | 39 ++ lms/users/tasks.py | 9 + lms/users/tests/__init__.py | 0 lms/users/tests/factories.py | 40 ++ lms/users/tests/test_admin.py | 65 +++ lms/users/tests/test_drf_urls.py | 21 + lms/users/tests/test_drf_views.py | 34 ++ lms/users/tests/test_forms.py | 35 ++ lms/users/tests/test_managers.py | 55 +++ lms/users/tests/test_models.py | 5 + lms/users/tests/test_swagger.py | 23 + lms/users/tests/test_tasks.py | 17 + lms/users/tests/test_urls.py | 19 + lms/users/tests/test_views.py | 101 +++++ lms/users/urls.py | 12 + lms/users/views.py | 46 ++ locale/README.md | 32 ++ locale/en_US/LC_MESSAGES/django.po | 12 + locale/fr_FR/LC_MESSAGES/django.po | 335 +++++++++++++++ locale/pt_BR/LC_MESSAGES/django.po | 315 ++++++++++++++ manage.py | 32 ++ merge_production_dotenvs_in_dotenv.py | 27 ++ pyproject.toml | 137 ++++++ requirements/base.txt | 30 ++ requirements/local.txt | 35 ++ requirements/production.txt | 11 + tests/__init__.py | 0 ...test_merge_production_dotenvs_in_dotenv.py | 34 ++ 153 files changed, 6115 insertions(+) create mode 100644 .devcontainer/bashrc.override.sh create mode 100644 .devcontainer/devcontainer.json create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .envs/.local/.django create mode 100644 .envs/.local/.postgres create mode 100644 .envs/.production/.django create mode 100644 .envs/.production/.postgres create mode 100644 .gitattributes create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .readthedocs.yml create mode 100644 compose/local/django/Dockerfile create mode 100644 compose/local/django/celery/beat/start create mode 100644 compose/local/django/celery/flower/start create mode 100644 compose/local/django/celery/worker/start create mode 100644 compose/local/django/start create mode 100644 compose/local/docs/Dockerfile create mode 100644 compose/local/docs/start create mode 100644 compose/production/django/Dockerfile create mode 100644 compose/production/django/celery/beat/start create mode 100644 compose/production/django/celery/flower/start create mode 100644 compose/production/django/celery/worker/start create mode 100644 compose/production/django/entrypoint create mode 100644 compose/production/django/start create mode 100644 compose/production/nginx/Dockerfile create mode 100644 compose/production/nginx/default.conf create mode 100644 compose/production/postgres/Dockerfile create mode 100644 compose/production/postgres/maintenance/_sourced/constants.sh create mode 100644 compose/production/postgres/maintenance/_sourced/countdown.sh create mode 100644 compose/production/postgres/maintenance/_sourced/messages.sh create mode 100644 compose/production/postgres/maintenance/_sourced/yes_no.sh create mode 100644 compose/production/postgres/maintenance/backup create mode 100644 compose/production/postgres/maintenance/backups create mode 100644 compose/production/postgres/maintenance/restore create mode 100644 compose/production/postgres/maintenance/rmbackup create mode 100644 compose/production/traefik/Dockerfile create mode 100644 compose/production/traefik/traefik.yml create mode 100644 config/__init__.py create mode 100644 config/api_router.py create mode 100644 config/asgi.py create mode 100644 config/celery_app.py create mode 100644 config/settings/__init__.py create mode 100644 config/settings/base.py create mode 100644 config/settings/local.py create mode 100644 config/settings/production.py create mode 100644 config/settings/test.py create mode 100644 config/urls.py create mode 100644 config/websocket.py create mode 100644 config/wsgi.py create mode 100644 docker-compose.docs.yml create mode 100644 docker-compose.local.yml create mode 100644 docker-compose.production.yml create mode 100644 docs/Makefile create mode 100644 docs/__init__.py create mode 100644 docs/conf.py create mode 100644 docs/howto.rst create mode 100644 docs/index.rst create mode 100644 docs/make.bat create mode 100644 docs/users.rst create mode 100644 justfile create mode 100644 lms/__init__.py create mode 100644 lms/accounts/__init__.py create mode 100644 lms/accounts/adapters.py create mode 100644 lms/accounts/admin.py create mode 100644 lms/accounts/apps.py create mode 100644 lms/accounts/models.py create mode 100644 lms/accounts/serializers.py create mode 100644 lms/accounts/tests.py create mode 100644 lms/accounts/urls.py create mode 100644 lms/accounts/views.py create mode 100644 lms/app/__init__.py create mode 100644 lms/app/admin.py create mode 100644 lms/app/apps.py create mode 100644 lms/app/models.py create mode 100644 lms/app/permissions.py create mode 100644 lms/app/serializers.py create mode 100644 lms/app/tests.py create mode 100644 lms/app/urls.py create mode 100644 lms/app/views.py create mode 100644 lms/conftest.py create mode 100644 lms/contrib/__init__.py create mode 100644 lms/contrib/sites/__init__.py create mode 100644 lms/contrib/sites/migrations/0001_initial.py create mode 100644 lms/contrib/sites/migrations/0002_alter_domain_unique.py create mode 100644 lms/contrib/sites/migrations/0003_set_site_domain_and_name.py create mode 100644 lms/contrib/sites/migrations/0004_alter_options_ordering_domain.py create mode 100644 lms/contrib/sites/migrations/__init__.py create mode 100644 lms/static/css/project.css create mode 100644 lms/static/fonts/.gitkeep create mode 100644 lms/static/images/favicons/favicon.ico create mode 100644 lms/static/js/project.js create mode 100644 lms/templates/403.html create mode 100644 lms/templates/403_csrf.html create mode 100644 lms/templates/404.html create mode 100644 lms/templates/500.html create mode 100644 lms/templates/account/email/password_reset_key_message.txt create mode 100644 lms/templates/allauth/elements/alert.html create mode 100644 lms/templates/allauth/elements/badge.html create mode 100644 lms/templates/allauth/elements/button.html create mode 100644 lms/templates/allauth/elements/field.html create mode 100644 lms/templates/allauth/elements/fields.html create mode 100644 lms/templates/allauth/elements/panel.html create mode 100644 lms/templates/allauth/elements/table.html create mode 100644 lms/templates/allauth/layouts/entrance.html create mode 100644 lms/templates/allauth/layouts/manage.html create mode 100644 lms/templates/base.html create mode 100644 lms/templates/pages/about.html create mode 100644 lms/templates/pages/home.html create mode 100644 lms/templates/users/user_detail.html create mode 100644 lms/templates/users/user_form.html create mode 100644 lms/users/__init__.py create mode 100644 lms/users/adapters.py create mode 100644 lms/users/admin.py create mode 100644 lms/users/api/__init__.py create mode 100644 lms/users/api/serializers.py create mode 100644 lms/users/api/views.py create mode 100644 lms/users/apps.py create mode 100644 lms/users/context_processors.py create mode 100644 lms/users/forms.py create mode 100644 lms/users/managers.py create mode 100644 lms/users/migrations/0001_initial.py create mode 100644 lms/users/migrations/__init__.py create mode 100644 lms/users/models.py create mode 100644 lms/users/tasks.py create mode 100644 lms/users/tests/__init__.py create mode 100644 lms/users/tests/factories.py create mode 100644 lms/users/tests/test_admin.py create mode 100644 lms/users/tests/test_drf_urls.py create mode 100644 lms/users/tests/test_drf_views.py create mode 100644 lms/users/tests/test_forms.py create mode 100644 lms/users/tests/test_managers.py create mode 100644 lms/users/tests/test_models.py create mode 100644 lms/users/tests/test_swagger.py create mode 100644 lms/users/tests/test_tasks.py create mode 100644 lms/users/tests/test_urls.py create mode 100644 lms/users/tests/test_views.py create mode 100644 lms/users/urls.py create mode 100644 lms/users/views.py create mode 100644 locale/README.md create mode 100644 locale/en_US/LC_MESSAGES/django.po create mode 100644 locale/fr_FR/LC_MESSAGES/django.po create mode 100644 locale/pt_BR/LC_MESSAGES/django.po create mode 100644 manage.py create mode 100644 merge_production_dotenvs_in_dotenv.py create mode 100644 pyproject.toml create mode 100644 requirements/base.txt create mode 100644 requirements/local.txt create mode 100644 requirements/production.txt create mode 100644 tests/__init__.py create mode 100644 tests/test_merge_production_dotenvs_in_dotenv.py diff --git a/.devcontainer/bashrc.override.sh b/.devcontainer/bashrc.override.sh new file mode 100644 index 0000000..bedddf6 --- /dev/null +++ b/.devcontainer/bashrc.override.sh @@ -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)" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..aa0ce70 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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" +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a602416 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.editorconfig +.gitattributes +.github +.gitignore +.gitlab-ci.yml +.idea +.pre-commit-config.yaml +.readthedocs.yml +.travis.yml +venv +.git +.envs/ diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c0ce342 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.envs/.local/.django b/.envs/.local/.django new file mode 100644 index 0000000..247287b --- /dev/null +++ b/.envs/.local/.django @@ -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 diff --git a/.envs/.local/.postgres b/.envs/.local/.postgres new file mode 100644 index 0000000..75b9252 --- /dev/null +++ b/.envs/.local/.postgres @@ -0,0 +1,7 @@ +# PostgreSQL +# ------------------------------------------------------------------------------ +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=lms +POSTGRES_USER=debug +POSTGRES_PASSWORD=debug diff --git a/.envs/.production/.django b/.envs/.production/.django new file mode 100644 index 0000000..2e19e37 --- /dev/null +++ b/.envs/.production/.django @@ -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 + diff --git a/.envs/.production/.postgres b/.envs/.production/.postgres new file mode 100644 index 0000000..75b9252 --- /dev/null +++ b/.envs/.production/.postgres @@ -0,0 +1,7 @@ +# PostgreSQL +# ------------------------------------------------------------------------------ +POSTGRES_HOST=postgres +POSTGRES_PORT=5432 +POSTGRES_DB=lms +POSTGRES_USER=debug +POSTGRES_PASSWORD=debug diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..affc9f5 --- /dev/null +++ b/.github/dependabot.yml @@ -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' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6c0d52d --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f2bd49 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..2bb191b --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..5564388 --- /dev/null +++ b/.readthedocs.yml @@ -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 diff --git a/compose/local/django/Dockerfile b/compose/local/django/Dockerfile new file mode 100644 index 0000000..79aaace --- /dev/null +++ b/compose/local/django/Dockerfile @@ -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"] diff --git a/compose/local/django/celery/beat/start b/compose/local/django/celery/beat/start new file mode 100644 index 0000000..8adc489 --- /dev/null +++ b/compose/local/django/celery/beat/start @@ -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' diff --git a/compose/local/django/celery/flower/start b/compose/local/django/celery/flower/start new file mode 100644 index 0000000..f8377ad --- /dev/null +++ b/compose/local/django/celery/flower/start @@ -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}\"" diff --git a/compose/local/django/celery/worker/start b/compose/local/django/celery/worker/start new file mode 100644 index 0000000..183a801 --- /dev/null +++ b/compose/local/django/celery/worker/start @@ -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' diff --git a/compose/local/django/start b/compose/local/django/start new file mode 100644 index 0000000..1549d13 --- /dev/null +++ b/compose/local/django/start @@ -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' diff --git a/compose/local/docs/Dockerfile b/compose/local/docs/Dockerfile new file mode 100644 index 0000000..ba9e579 --- /dev/null +++ b/compose/local/docs/Dockerfile @@ -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 diff --git a/compose/local/docs/start b/compose/local/docs/start new file mode 100644 index 0000000..96a94f5 --- /dev/null +++ b/compose/local/docs/start @@ -0,0 +1,7 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset + +exec make livehtml diff --git a/compose/production/django/Dockerfile b/compose/production/django/Dockerfile new file mode 100644 index 0000000..f65cb33 --- /dev/null +++ b/compose/production/django/Dockerfile @@ -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"] diff --git a/compose/production/django/celery/beat/start b/compose/production/django/celery/beat/start new file mode 100644 index 0000000..42ddca9 --- /dev/null +++ b/compose/production/django/celery/beat/start @@ -0,0 +1,8 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset + + +exec celery -A config.celery_app beat -l INFO diff --git a/compose/production/django/celery/flower/start b/compose/production/django/celery/flower/start new file mode 100644 index 0000000..c0b1cbe --- /dev/null +++ b/compose/production/django/celery/flower/start @@ -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}" diff --git a/compose/production/django/celery/worker/start b/compose/production/django/celery/worker/start new file mode 100644 index 0000000..af0c8f7 --- /dev/null +++ b/compose/production/django/celery/worker/start @@ -0,0 +1,8 @@ +#!/bin/bash + +set -o errexit +set -o pipefail +set -o nounset + + +exec celery -A config.celery_app worker -l INFO diff --git a/compose/production/django/entrypoint b/compose/production/django/entrypoint new file mode 100644 index 0000000..fe9a013 --- /dev/null +++ b/compose/production/django/entrypoint @@ -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 "$@" diff --git a/compose/production/django/start b/compose/production/django/start new file mode 100644 index 0000000..36b4246 --- /dev/null +++ b/compose/production/django/start @@ -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 diff --git a/compose/production/nginx/Dockerfile b/compose/production/nginx/Dockerfile new file mode 100644 index 0000000..ec2ad35 --- /dev/null +++ b/compose/production/nginx/Dockerfile @@ -0,0 +1,2 @@ +FROM docker.io/nginx:1.17.8-alpine +COPY ./compose/production/nginx/default.conf /etc/nginx/conf.d/default.conf diff --git a/compose/production/nginx/default.conf b/compose/production/nginx/default.conf new file mode 100644 index 0000000..562dba8 --- /dev/null +++ b/compose/production/nginx/default.conf @@ -0,0 +1,7 @@ +server { + listen 80; + server_name localhost; + location /media/ { + alias /usr/share/nginx/media/; + } +} diff --git a/compose/production/postgres/Dockerfile b/compose/production/postgres/Dockerfile new file mode 100644 index 0000000..176a5f1 --- /dev/null +++ b/compose/production/postgres/Dockerfile @@ -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 diff --git a/compose/production/postgres/maintenance/_sourced/constants.sh b/compose/production/postgres/maintenance/_sourced/constants.sh new file mode 100644 index 0000000..6ca4f0c --- /dev/null +++ b/compose/production/postgres/maintenance/_sourced/constants.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + + +BACKUP_DIR_PATH='/backups' +BACKUP_FILE_PREFIX='backup' diff --git a/compose/production/postgres/maintenance/_sourced/countdown.sh b/compose/production/postgres/maintenance/_sourced/countdown.sh new file mode 100644 index 0000000..e6cbfb6 --- /dev/null +++ b/compose/production/postgres/maintenance/_sourced/countdown.sh @@ -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 +} diff --git a/compose/production/postgres/maintenance/_sourced/messages.sh b/compose/production/postgres/maintenance/_sourced/messages.sh new file mode 100644 index 0000000..f6be756 --- /dev/null +++ b/compose/production/postgres/maintenance/_sourced/messages.sh @@ -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: ${@}" +} diff --git a/compose/production/postgres/maintenance/_sourced/yes_no.sh b/compose/production/postgres/maintenance/_sourced/yes_no.sh new file mode 100644 index 0000000..fd9cae1 --- /dev/null +++ b/compose/production/postgres/maintenance/_sourced/yes_no.sh @@ -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 +} diff --git a/compose/production/postgres/maintenance/backup b/compose/production/postgres/maintenance/backup new file mode 100644 index 0000000..f72304c --- /dev/null +++ b/compose/production/postgres/maintenance/backup @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + + +### Create a database backup. +### +### Usage: +### $ docker compose -f .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}'." diff --git a/compose/production/postgres/maintenance/backups b/compose/production/postgres/maintenance/backups new file mode 100644 index 0000000..a18937d --- /dev/null +++ b/compose/production/postgres/maintenance/backups @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + + +### View backups. +### +### Usage: +### $ docker compose -f .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}" diff --git a/compose/production/postgres/maintenance/restore b/compose/production/postgres/maintenance/restore new file mode 100644 index 0000000..c68f17d --- /dev/null +++ b/compose/production/postgres/maintenance/restore @@ -0,0 +1,55 @@ +#!/usr/bin/env bash + + +### Restore database from a backup. +### +### Parameters: +### <1> filename of an existing backup. +### +### Usage: +### $ docker compose -f .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." diff --git a/compose/production/postgres/maintenance/rmbackup b/compose/production/postgres/maintenance/rmbackup new file mode 100644 index 0000000..fdfd20e --- /dev/null +++ b/compose/production/postgres/maintenance/rmbackup @@ -0,0 +1,36 @@ +#!/usr/bin/env bash + +### Remove a database backup. +### +### Parameters: +### <1> filename of a backup to remove. +### +### Usage: +### $ docker-compose -f .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." diff --git a/compose/production/traefik/Dockerfile b/compose/production/traefik/Dockerfile new file mode 100644 index 0000000..c139a65 --- /dev/null +++ b/compose/production/traefik/Dockerfile @@ -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 diff --git a/compose/production/traefik/traefik.yml b/compose/production/traefik/traefik.yml new file mode 100644 index 0000000..dc0d2ec --- /dev/null +++ b/compose/production/traefik/traefik.yml @@ -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 diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..10f5014 --- /dev/null +++ b/config/__init__.py @@ -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",) diff --git a/config/api_router.py b/config/api_router.py new file mode 100644 index 0000000..5d6729a --- /dev/null +++ b/config/api_router.py @@ -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 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..daa334f --- /dev/null +++ b/config/asgi.py @@ -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) diff --git a/config/celery_app.py b/config/celery_app.py new file mode 100644 index 0000000..e6b9bfe --- /dev/null +++ b/config/celery_app.py @@ -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() diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..39f3f76 --- /dev/null +++ b/config/settings/base.py @@ -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... +# ------------------------------------------------------------------------------ diff --git a/config/settings/local.py b/config/settings/local.py new file mode 100644 index 0000000..3244f3a --- /dev/null +++ b/config/settings/local.py @@ -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... +# ------------------------------------------------------------------------------ diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..03be38e --- /dev/null +++ b/config/settings/production.py @@ -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 ", +) +# 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... +# ------------------------------------------------------------------------------ diff --git a/config/settings/test.py b/config/settings/test.py new file mode 100644 index 0000000..969bf00 --- /dev/null +++ b/config/settings/test.py @@ -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... +# ------------------------------------------------------------------------------ diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..f54fb10 --- /dev/null +++ b/config/urls.py @@ -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///', + 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 diff --git a/config/websocket.py b/config/websocket.py new file mode 100644 index 0000000..81adfbc --- /dev/null +++ b/config/websocket.py @@ -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!"}) diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..b344437 --- /dev/null +++ b/config/wsgi.py @@ -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) diff --git a/docker-compose.docs.yml b/docker-compose.docs.yml new file mode 100644 index 0000000..3122a51 --- /dev/null +++ b/docker-compose.docs.yml @@ -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 diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..af0390e --- /dev/null +++ b/docker-compose.local.yml @@ -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 diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..91ae248 --- /dev/null +++ b/docker-compose.production.yml @@ -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 diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..67e63cb --- /dev/null +++ b/docs/Makefile @@ -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 . diff --git a/docs/__init__.py b/docs/__init__.py new file mode 100644 index 0000000..8772c82 --- /dev/null +++ b/docs/__init__.py @@ -0,0 +1 @@ +# Included so that Django's startproject comment runs against the docs directory diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..e546820 --- /dev/null +++ b/docs/conf.py @@ -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"] diff --git a/docs/howto.rst b/docs/howto.rst new file mode 100644 index 0000000..6607748 --- /dev/null +++ b/docs/howto.rst @@ -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 `_ is the tool used to build documentation. + +Docstrings to Documentation +---------------------------------------------------------------------- + +The sphinx extension `apidoc `_ is used to automatically document code using signatures and docstrings. + +Numpy or Google style docstrings will be picked up from project files and available for documentation. See the `Napoleon `_ extension for details. + +For an in-use example, see the `page source <_sources/users.rst.txt>`_ for :ref:`users`. + +To compile all docstrings automatically into documentation source files, use the command: + :: + + make apidocs + + +This can be done in the docker container: + :: + + docker run --rm docs make apidocs diff --git a/docs/index.rst b/docs/index.rst new file mode 100644 index 0000000..3b2ebea --- /dev/null +++ b/docs/index.rst @@ -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` diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..555427f --- /dev/null +++ b/docs/make.bat @@ -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 diff --git a/docs/users.rst b/docs/users.rst new file mode 100644 index 0000000..ebaad04 --- /dev/null +++ b/docs/users.rst @@ -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: + diff --git a/justfile b/justfile new file mode 100644 index 0000000..e9ce737 --- /dev/null +++ b/justfile @@ -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}} diff --git a/lms/__init__.py b/lms/__init__.py new file mode 100644 index 0000000..3da9e5f --- /dev/null +++ b/lms/__init__.py @@ -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(".") +) diff --git a/lms/accounts/__init__.py b/lms/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lms/accounts/adapters.py b/lms/accounts/adapters.py new file mode 100644 index 0000000..8df3c14 --- /dev/null +++ b/lms/accounts/adapters.py @@ -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}/" + \ No newline at end of file diff --git a/lms/accounts/admin.py b/lms/accounts/admin.py new file mode 100644 index 0000000..23e1a41 --- /dev/null +++ b/lms/accounts/admin.py @@ -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) diff --git a/lms/accounts/apps.py b/lms/accounts/apps.py new file mode 100644 index 0000000..9b74489 --- /dev/null +++ b/lms/accounts/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'lms.accounts' diff --git a/lms/accounts/models.py b/lms/accounts/models.py new file mode 100644 index 0000000..1990e7d --- /dev/null +++ b/lms/accounts/models.py @@ -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 diff --git a/lms/accounts/serializers.py b/lms/accounts/serializers.py new file mode 100644 index 0000000..2a6270f --- /dev/null +++ b/lms/accounts/serializers.py @@ -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() + + + diff --git a/lms/accounts/tests.py b/lms/accounts/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/lms/accounts/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/lms/accounts/urls.py b/lms/accounts/urls.py new file mode 100644 index 0000000..ad65a1d --- /dev/null +++ b/lms/accounts/urls.py @@ -0,0 +1,5 @@ +from django.urls import path +from . import views +urlpatterns = [ + path("change-email/", views.CustomConfirmEmailView.as_view(), name="change-email") +] diff --git a/lms/accounts/views.py b/lms/accounts/views.py new file mode 100644 index 0000000..29bca1e --- /dev/null +++ b/lms/accounts/views.py @@ -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) \ No newline at end of file diff --git a/lms/app/__init__.py b/lms/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lms/app/admin.py b/lms/app/admin.py new file mode 100644 index 0000000..6c4d41e --- /dev/null +++ b/lms/app/admin.py @@ -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',) diff --git a/lms/app/apps.py b/lms/app/apps.py new file mode 100644 index 0000000..a6b0106 --- /dev/null +++ b/lms/app/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AppConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'lms.app' diff --git a/lms/app/models.py b/lms/app/models.py new file mode 100644 index 0000000..56836c1 --- /dev/null +++ b/lms/app/models.py @@ -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}" diff --git a/lms/app/permissions.py b/lms/app/permissions.py new file mode 100644 index 0000000..2f0f87c --- /dev/null +++ b/lms/app/permissions.py @@ -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' diff --git a/lms/app/serializers.py b/lms/app/serializers.py new file mode 100644 index 0000000..900c5b2 --- /dev/null +++ b/lms/app/serializers.py @@ -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'] \ No newline at end of file diff --git a/lms/app/tests.py b/lms/app/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/lms/app/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/lms/app/urls.py b/lms/app/urls.py new file mode 100644 index 0000000..7da3919 --- /dev/null +++ b/lms/app/urls.py @@ -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 \ No newline at end of file diff --git a/lms/app/views.py b/lms/app/views.py new file mode 100644 index 0000000..ce982ec --- /dev/null +++ b/lms/app/views.py @@ -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) + \ No newline at end of file diff --git a/lms/conftest.py b/lms/conftest.py new file mode 100644 index 0000000..607ea94 --- /dev/null +++ b/lms/conftest.py @@ -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() diff --git a/lms/contrib/__init__.py b/lms/contrib/__init__.py new file mode 100644 index 0000000..1c7ecc8 --- /dev/null +++ b/lms/contrib/__init__.py @@ -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 +""" diff --git a/lms/contrib/sites/__init__.py b/lms/contrib/sites/__init__.py new file mode 100644 index 0000000..1c7ecc8 --- /dev/null +++ b/lms/contrib/sites/__init__.py @@ -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 +""" diff --git a/lms/contrib/sites/migrations/0001_initial.py b/lms/contrib/sites/migrations/0001_initial.py new file mode 100644 index 0000000..fd76afb --- /dev/null +++ b/lms/contrib/sites/migrations/0001_initial.py @@ -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())], + ), + ] diff --git a/lms/contrib/sites/migrations/0002_alter_domain_unique.py b/lms/contrib/sites/migrations/0002_alter_domain_unique.py new file mode 100644 index 0000000..4a44a6a --- /dev/null +++ b/lms/contrib/sites/migrations/0002_alter_domain_unique.py @@ -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", + ), + ) + ] diff --git a/lms/contrib/sites/migrations/0003_set_site_domain_and_name.py b/lms/contrib/sites/migrations/0003_set_site_domain_and_name.py new file mode 100644 index 0000000..2a3ce8e --- /dev/null +++ b/lms/contrib/sites/migrations/0003_set_site_domain_and_name.py @@ -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)] diff --git a/lms/contrib/sites/migrations/0004_alter_options_ordering_domain.py b/lms/contrib/sites/migrations/0004_alter_options_ordering_domain.py new file mode 100644 index 0000000..f7118ca --- /dev/null +++ b/lms/contrib/sites/migrations/0004_alter_options_ordering_domain.py @@ -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", + }, + ), + ] diff --git a/lms/contrib/sites/migrations/__init__.py b/lms/contrib/sites/migrations/__init__.py new file mode 100644 index 0000000..1c7ecc8 --- /dev/null +++ b/lms/contrib/sites/migrations/__init__.py @@ -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 +""" diff --git a/lms/static/css/project.css b/lms/static/css/project.css new file mode 100644 index 0000000..f1d543d --- /dev/null +++ b/lms/static/css/project.css @@ -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; +} diff --git a/lms/static/fonts/.gitkeep b/lms/static/fonts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lms/static/images/favicons/favicon.ico b/lms/static/images/favicons/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..e1c1dd1a32a3a077c41a21e52bc7fb5ac90d3afb GIT binary patch literal 8348 zcmeHLX-gGh6rQ3V&`92%;lcmGu`Jl^WK@OV`^XKh08PZoaH%l?rdiiWq>kJ2@6vM zhAH8L6=kTRD1!xR`-2o^hS&}loN!U1#gF+=YxEq~uqf5#iBw%p0;w;5ehm+6a!sSu zh;X+$+}oF$X1Q6DwS~>Y_Hpw^(&QvJO<5ZCPrn5laNp%s{B3x1hf%*?eW>oS{<{4s^u_yG zpDyG!_iV#~G=q<~slG@0Sx47XXQ%O;HY7ILVf}B-)R$6GV^A78)t0tN9`tu3r9Z+xM?NgUd8geu=c`0?r;-KR&IQjKs zSB#VCpg8CPW&M}$sth@H=VS%t;23!!j};F)bb;W3EkA!4QmCsY_p81^TK0{;$g>e1Hl998^0M+r0-7ZSN+l_cMSRuEALTE@|d6+3{GMP^;_|<yhubb{FF1IPgH|0>SH%pC!c)uFI)H z?jv4y0uO{P5WE>~I<$r!RFo0lN4r{xm;Jy4p$h~b3S*a#)$Z*nI}&Kc_C+*zZHz1v zIR8TBVH7Y?Mp~ zofpsr+SP@>EX4e*bQ{nAUYtKrQ&-5d4j;FF{^-^Dt2^5I`HSb!|22PN26n3>hKPOy zW2D*A*v+c{bFKCwozbB{dObp~e&1Nxrj<4Ih4~w)M`nl08o}Wq2 z#Jt(k+M>+}JY(=2gm(gdUq)^@e%tX(F)RBtotnB2^y>YKz-5eg_qO&n%lJ1nuQmTY zxmyE1NWjl1EGvD?Z|n;neT;sa?Q;Ef-#%$BYxXAhD4xF+@M>*qrR!x^sPN98|BX4; z!$NJcKF`^C7f(<_vlp%b>`pxL^8Y<&?KFx{n_!5C9VqLA*CP@zhXs2t#dmrAKu?d_ y^&_r5_e@uesG}CO*g!3o?-kB+I^cA`>44J#rvpw0oDMi0a5~_0!0Eu>4*Uj$LD0AW literal 0 HcmV?d00001 diff --git a/lms/static/js/project.js b/lms/static/js/project.js new file mode 100644 index 0000000..d26d23b --- /dev/null +++ b/lms/static/js/project.js @@ -0,0 +1 @@ +/* Project specific Javascript goes here. */ diff --git a/lms/templates/403.html b/lms/templates/403.html new file mode 100644 index 0000000..5795603 --- /dev/null +++ b/lms/templates/403.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Forbidden (403){% endblock title %} +{% block content %} +

Forbidden (403)

+

+ {% if exception %} + {{ exception }} + {% else %} + You're not allowed to access this page. + {% endif %} +

+{% endblock content %} diff --git a/lms/templates/403_csrf.html b/lms/templates/403_csrf.html new file mode 100644 index 0000000..5795603 --- /dev/null +++ b/lms/templates/403_csrf.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Forbidden (403){% endblock title %} +{% block content %} +

Forbidden (403)

+

+ {% if exception %} + {{ exception }} + {% else %} + You're not allowed to access this page. + {% endif %} +

+{% endblock content %} diff --git a/lms/templates/404.html b/lms/templates/404.html new file mode 100644 index 0000000..5111d3a --- /dev/null +++ b/lms/templates/404.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block title %}Page not found{% endblock title %} +{% block content %} +

Page not found

+

+ {% if exception %} + {{ exception }} + {% else %} + This is not the page you were looking for. + {% endif %} +

+{% endblock content %} diff --git a/lms/templates/500.html b/lms/templates/500.html new file mode 100644 index 0000000..1add004 --- /dev/null +++ b/lms/templates/500.html @@ -0,0 +1,10 @@ +{% extends "base.html" %} + +{% block title %}Server Error{% endblock title %} +{% block content %} +

Ooops!!! 500

+

Looks like something went wrong!

+

+ We track these errors automatically, but if the problem persists feel free to contact us. In the meantime, try refreshing. +

+{% endblock content %} diff --git a/lms/templates/account/email/password_reset_key_message.txt b/lms/templates/account/email/password_reset_key_message.txt new file mode 100644 index 0000000..7b2fdd3 --- /dev/null +++ b/lms/templates/account/email/password_reset_key_message.txt @@ -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 %} + + diff --git a/lms/templates/allauth/elements/alert.html b/lms/templates/allauth/elements/alert.html new file mode 100644 index 0000000..535d394 --- /dev/null +++ b/lms/templates/allauth/elements/alert.html @@ -0,0 +1,7 @@ +{% load i18n %} +{% load allauth %} + +
+ {% slot message %} +{% endslot %} +
diff --git a/lms/templates/allauth/elements/badge.html b/lms/templates/allauth/elements/badge.html new file mode 100644 index 0000000..e86669b --- /dev/null +++ b/lms/templates/allauth/elements/badge.html @@ -0,0 +1,6 @@ +{% load allauth %} + + + {% slot %} +{% endslot %} + diff --git a/lms/templates/allauth/elements/button.html b/lms/templates/allauth/elements/button.html new file mode 100644 index 0000000..b88a209 --- /dev/null +++ b/lms/templates/allauth/elements/button.html @@ -0,0 +1,20 @@ +{% load allauth %} + +{% comment %} djlint:off {% endcomment %} +<{% if attrs.href %}a href="{{ attrs.href }}"{% else %}button{% endif %} + {% if attrs.form %}form="{{ attrs.form }}"{% endif %} + {% if attrs.id %}id="{{ attrs.id }}"{% endif %} + {% if attrs.name %}name="{{ attrs.name }}"{% endif %} + {% if attrs.type %}type="{{ attrs.type }}"{% endif %} + class="btn +{% if 'success' in attrs.tags %}btn-success +{% elif 'warning' in attrs.tags %}btn-warning +{% elif 'secondary' in attrs.tags %}btn-secondary +{% elif 'danger' in attrs.tags %}btn-danger +{% elif 'primary' in attrs.tags %}btn-primary +{% else %}btn-primary +{% endif %}" +> + {% slot %} + {% endslot %} + diff --git a/lms/templates/allauth/elements/field.html b/lms/templates/allauth/elements/field.html new file mode 100644 index 0000000..dc5f303 --- /dev/null +++ b/lms/templates/allauth/elements/field.html @@ -0,0 +1,66 @@ +{% load allauth %} +{% load crispy_forms_tags %} + +{% if attrs.type == "textarea" %} +
+
+ +
+ +
+{% elif attrs.type == "radio" %} +
+
+
+ + +
+
+
+{% else %} +
+ +
+
+ +
+{% endif %} +{% if slots.help_text %} +
{% slot help_text %}{% endslot %}
+{% endif %} diff --git a/lms/templates/allauth/elements/fields.html b/lms/templates/allauth/elements/fields.html new file mode 100644 index 0000000..ae8e104 --- /dev/null +++ b/lms/templates/allauth/elements/fields.html @@ -0,0 +1,3 @@ +{% load crispy_forms_tags %} + +{{ attrs.form|crispy }} diff --git a/lms/templates/allauth/elements/panel.html b/lms/templates/allauth/elements/panel.html new file mode 100644 index 0000000..43a7a54 --- /dev/null +++ b/lms/templates/allauth/elements/panel.html @@ -0,0 +1,19 @@ +{% load allauth %} + +
+
+
+

+ {% slot title %} + {% endslot %} +

+ {% slot body %} + {% endslot %} + {% if slots.actions %} +
    + {% for action in slots.actions %}
  • {{ action }}
  • {% endfor %} +
+ {% endif %} +
+
+
diff --git a/lms/templates/allauth/elements/table.html b/lms/templates/allauth/elements/table.html new file mode 100644 index 0000000..13cc5da --- /dev/null +++ b/lms/templates/allauth/elements/table.html @@ -0,0 +1,6 @@ +{% load allauth %} + + + {% slot %} +{% endslot %} +
diff --git a/lms/templates/allauth/layouts/entrance.html b/lms/templates/allauth/layouts/entrance.html new file mode 100644 index 0000000..d3eb631 --- /dev/null +++ b/lms/templates/allauth/layouts/entrance.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} +{% load i18n %} +{% block bodyclass %}bg-light{% endblock bodyclass %} + +{% block css %}{{ block.super }}{% endblock css %} +{% block title %} + {% block head_title %} + {% trans "Sign In" %} + {% endblock head_title %} +{% endblock title %} +{% block body %} +
+
+ {% if messages %} + {% for message in messages %} +
+ {{ message }} + +
+ {% endfor %} + {% endif %} + {% block content %} + {% endblock content %} + {% block extra_body %} + {% endblock extra_body %} +
+
+{% endblock body %} diff --git a/lms/templates/allauth/layouts/manage.html b/lms/templates/allauth/layouts/manage.html new file mode 100644 index 0000000..75b4959 --- /dev/null +++ b/lms/templates/allauth/layouts/manage.html @@ -0,0 +1,6 @@ +{% extends "base.html" %} + +{% block main %} + {% block content %} + {% endblock content %} +{% endblock main %} diff --git a/lms/templates/base.html b/lms/templates/base.html new file mode 100644 index 0000000..d86942b --- /dev/null +++ b/lms/templates/base.html @@ -0,0 +1,145 @@ + +{% load static i18n compress%} +{% get_current_language as LANGUAGE_CODE %} + + + + + + {% block title %} + Learning Management System + {% endblock title %} + + + + + + {% block css %} + + + + + + +{% compress css %} + +{% endcompress %} + + +{% endblock css %} + +{# Placed at the top of the document so pages load faster with defer #} +{% block javascript %} + + + + + + + + + + +{% compress js %} + +{% endcompress %} + + +{% endblock javascript %} + + + {% block body %} +
+ +
+
+ {% if messages %} + {% for message in messages %} +
+ {{ message }} + +
+ {% endfor %} + {% endif %} + {% block main %} + {% block content %} +

Use this document as a way to quick start any new project.

+ {% endblock content %} + {% endblock main %} + +
+ {% endblock body %} + + {% block modal %} + {% endblock modal %} + {% block inline_javascript %} + {% comment %} + Script tags with only code, no src (defer by default). To run + with a "defer" so that you run inline code: + + {% endcomment %} + {% endblock inline_javascript %} + + diff --git a/lms/templates/pages/about.html b/lms/templates/pages/about.html new file mode 100644 index 0000000..3070348 --- /dev/null +++ b/lms/templates/pages/about.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} + + diff --git a/lms/templates/pages/home.html b/lms/templates/pages/home.html new file mode 100644 index 0000000..3070348 --- /dev/null +++ b/lms/templates/pages/home.html @@ -0,0 +1,3 @@ +{% extends "base.html" %} + + diff --git a/lms/templates/users/user_detail.html b/lms/templates/users/user_detail.html new file mode 100644 index 0000000..b028dc9 --- /dev/null +++ b/lms/templates/users/user_detail.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% load static %} + +{% block title %} + User: + + {{ object.name }} + + +{% endblock title %} +{% block content %} +
+
+
+

+ + + {{ object.name }} + +

+
+
+ {% if object == request.user %} + +
+
+ My Info + E-Mail + MFA + +
+
+ + {% endif %} +
+{% endblock content %} diff --git a/lms/templates/users/user_form.html b/lms/templates/users/user_form.html new file mode 100644 index 0000000..8e23327 --- /dev/null +++ b/lms/templates/users/user_form.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% load crispy_forms_tags %} + +{% block title %} + + + {{ user.name }} + + +{% endblock title %} +{% block content %} +

+ + + {{ user.name }} + + +

+
+ {% csrf_token %} + {{ form|crispy }} +
+
+ +
+
+
+{% endblock content %} diff --git a/lms/users/__init__.py b/lms/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lms/users/adapters.py b/lms/users/adapters.py new file mode 100644 index 0000000..f8ac6bd --- /dev/null +++ b/lms/users/adapters.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +import typing + +from allauth.account.adapter import DefaultAccountAdapter +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from django.conf import settings + +if typing.TYPE_CHECKING: + from allauth.socialaccount.models import SocialLogin + from django.http import HttpRequest + + from lms.users.models import User + + +class AccountAdapter(DefaultAccountAdapter): + def is_open_for_signup(self, request: HttpRequest) -> bool: + return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) + + +class SocialAccountAdapter(DefaultSocialAccountAdapter): + def is_open_for_signup( + self, + request: HttpRequest, + sociallogin: SocialLogin, + ) -> bool: + return getattr(settings, "ACCOUNT_ALLOW_REGISTRATION", True) + + def populate_user( + self, + request: HttpRequest, + sociallogin: SocialLogin, + data: dict[str, typing.Any], + ) -> User: + """ + Populates user information from social provider info. + + See: https://docs.allauth.org/en/latest/socialaccount/advanced.html#creating-and-populating-user-instances + """ + user = super().populate_user(request, sociallogin, data) + if not user.name: + if name := data.get("name"): + user.name = name + elif first_name := data.get("first_name"): + user.name = first_name + if last_name := data.get("last_name"): + user.name += f" {last_name}" + return user diff --git a/lms/users/admin.py b/lms/users/admin.py new file mode 100644 index 0000000..daa04ff --- /dev/null +++ b/lms/users/admin.py @@ -0,0 +1,50 @@ +from allauth.account.decorators import secure_admin_login +from django.conf import settings +from django.contrib import admin +from django.contrib.auth import admin as auth_admin +from django.utils.translation import gettext_lazy as _ + +from .forms import UserAdminChangeForm +from .forms import UserAdminCreationForm +from .models import User + +if settings.DJANGO_ADMIN_FORCE_ALLAUTH: + # Force the `admin` sign in process to go through the `django-allauth` workflow: + # https://docs.allauth.org/en/latest/common/admin.html#admin + admin.autodiscover() + admin.site.login = secure_admin_login(admin.site.login) # type: ignore[method-assign] + + +@admin.register(User) +class UserAdmin(auth_admin.UserAdmin): + form = UserAdminChangeForm + add_form = UserAdminCreationForm + fieldsets = ( + (None, {"fields": ("email", "password")}), + (_("Personal info"), {"fields": ("name",)}), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + ), + }, + ), + (_("Important dates"), {"fields": ("last_login", "date_joined")}), + ) + list_display = ["email", "name", "is_superuser"] + search_fields = ["name"] + ordering = ["id"] + add_fieldsets = ( + ( + None, + { + "classes": ("wide",), + "fields": ("email", "password1", "password2"), + }, + ), + ) diff --git a/lms/users/api/__init__.py b/lms/users/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lms/users/api/serializers.py b/lms/users/api/serializers.py new file mode 100644 index 0000000..bce5a90 --- /dev/null +++ b/lms/users/api/serializers.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + +from lms.users.models import User + + +class UserSerializer(serializers.ModelSerializer[User]): + class Meta: + model = User + fields = ["name", "url"] + + extra_kwargs = { + "url": {"view_name": "api:user-detail", "lookup_field": "pk"}, + } diff --git a/lms/users/api/views.py b/lms/users/api/views.py new file mode 100644 index 0000000..fcc1bad --- /dev/null +++ b/lms/users/api/views.py @@ -0,0 +1,26 @@ +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.mixins import ListModelMixin +from rest_framework.mixins import RetrieveModelMixin +from rest_framework.mixins import UpdateModelMixin +from rest_framework.response import Response +from rest_framework.viewsets import GenericViewSet + +from lms.users.models import User + +from .serializers import UserSerializer + + +class UserViewSet(RetrieveModelMixin, ListModelMixin, UpdateModelMixin, GenericViewSet): + serializer_class = UserSerializer + queryset = User.objects.all() + lookup_field = "pk" + + def get_queryset(self, *args, **kwargs): + assert isinstance(self.request.user.id, int) + return self.queryset.filter(id=self.request.user.id) + + @action(detail=False) + def me(self, request): + serializer = UserSerializer(request.user, context={"request": request}) + return Response(status=status.HTTP_200_OK, data=serializer.data) diff --git a/lms/users/apps.py b/lms/users/apps.py new file mode 100644 index 0000000..d36fcbf --- /dev/null +++ b/lms/users/apps.py @@ -0,0 +1,13 @@ +import contextlib + +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class UsersConfig(AppConfig): + name = "lms.users" + verbose_name = _("Users") + + def ready(self): + with contextlib.suppress(ImportError): + import lms.users.signals # noqa: F401 diff --git a/lms/users/context_processors.py b/lms/users/context_processors.py new file mode 100644 index 0000000..e2633ae --- /dev/null +++ b/lms/users/context_processors.py @@ -0,0 +1,8 @@ +from django.conf import settings + + +def allauth_settings(request): + """Expose some settings from django-allauth in templates.""" + return { + "ACCOUNT_ALLOW_REGISTRATION": settings.ACCOUNT_ALLOW_REGISTRATION, + } diff --git a/lms/users/forms.py b/lms/users/forms.py new file mode 100644 index 0000000..3a4bf08 --- /dev/null +++ b/lms/users/forms.py @@ -0,0 +1,44 @@ +from allauth.account.forms import SignupForm +from allauth.socialaccount.forms import SignupForm as SocialSignupForm +from django.contrib.auth import forms as admin_forms +from django.forms import EmailField +from django.utils.translation import gettext_lazy as _ + +from .models import User + + +class UserAdminChangeForm(admin_forms.UserChangeForm): + class Meta(admin_forms.UserChangeForm.Meta): # type: ignore[name-defined] + model = User + field_classes = {"email": EmailField} + + +class UserAdminCreationForm(admin_forms.UserCreationForm): + """ + Form for User Creation in the Admin Area. + To change user signup, see UserSignupForm and UserSocialSignupForm. + """ + + class Meta(admin_forms.UserCreationForm.Meta): # type: ignore[name-defined] + model = User + fields = ("email",) + field_classes = {"email": EmailField} + error_messages = { + "email": {"unique": _("This email has already been taken.")}, + } + + +class UserSignupForm(SignupForm): + """ + Form that will be rendered on a user sign up section/screen. + Default fields will be added automatically. + Check UserSocialSignupForm for accounts created from social. + """ + + +class UserSocialSignupForm(SocialSignupForm): + """ + Renders the form when user has signed up using social accounts. + Default fields will be added automatically. + See UserSignupForm otherwise. + """ diff --git a/lms/users/managers.py b/lms/users/managers.py new file mode 100644 index 0000000..d8beaa4 --- /dev/null +++ b/lms/users/managers.py @@ -0,0 +1,42 @@ +from typing import TYPE_CHECKING + +from django.contrib.auth.hashers import make_password +from django.contrib.auth.models import UserManager as DjangoUserManager + +if TYPE_CHECKING: + from .models import User # noqa: F401 + + +class UserManager(DjangoUserManager["User"]): + """Custom manager for the User model.""" + + def _create_user(self, email: str, password: str | None, **extra_fields): + """ + Create and save a user with the given email and password. + """ + if not email: + msg = "The given email must be set" + raise ValueError(msg) + email = self.normalize_email(email) + user = self.model(email=email, **extra_fields) + user.password = make_password(password) + user.save(using=self._db) + return user + + def create_user(self, email: str, password: str | None = None, **extra_fields): # type: ignore[override] + extra_fields.setdefault("is_staff", False) + extra_fields.setdefault("is_superuser", False) + return self._create_user(email, password, **extra_fields) + + def create_superuser(self, email: str, password: str | None = None, **extra_fields): # type: ignore[override] + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + msg = "Superuser must have is_staff=True." + raise ValueError(msg) + if extra_fields.get("is_superuser") is not True: + msg = "Superuser must have is_superuser=True." + raise ValueError(msg) + + return self._create_user(email, password, **extra_fields) diff --git a/lms/users/migrations/0001_initial.py b/lms/users/migrations/0001_initial.py new file mode 100644 index 0000000..37b67aa --- /dev/null +++ b/lms/users/migrations/0001_initial.py @@ -0,0 +1,112 @@ +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +from django.db import migrations +from django.db import models + +import lms.users.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ] + + operations = [ + migrations.CreateModel( + name="User", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("password", models.CharField(max_length=128, verbose_name="password")), + ( + "last_login", + models.DateTimeField( + blank=True, null=True, verbose_name="last login", + ), + ), + ( + "is_superuser", + models.BooleanField( + default=False, + help_text="Designates that this user has all permissions without explicitly assigning them.", + verbose_name="superuser status", + ), + ), + ( + "email", + models.EmailField( + unique=True, max_length=254, verbose_name="email address", + ), + ), + ( + "is_staff", + models.BooleanField( + default=False, + help_text="Designates whether the user can log into this admin site.", + verbose_name="staff status", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", + verbose_name="active", + ), + ), + ( + "date_joined", + models.DateTimeField( + default=django.utils.timezone.now, verbose_name="date joined", + ), + ), + ( + "name", + models.CharField( + blank=True, max_length=255, verbose_name="Name of User", + ), + ), + ( + "groups", + models.ManyToManyField( + blank=True, + help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", + related_name="user_set", + related_query_name="user", + to="auth.Group", + verbose_name="groups", + ), + ), + ( + "user_permissions", + models.ManyToManyField( + blank=True, + help_text="Specific permissions for this user.", + related_name="user_set", + related_query_name="user", + to="auth.Permission", + verbose_name="user permissions", + ), + ), + ], + options={ + "verbose_name": "user", + "verbose_name_plural": "users", + "abstract": False, + }, + managers=[ + ("objects", lms.users.models.UserManager()), + ], + ), + ] diff --git a/lms/users/migrations/__init__.py b/lms/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lms/users/models.py b/lms/users/models.py new file mode 100644 index 0000000..90f635d --- /dev/null +++ b/lms/users/models.py @@ -0,0 +1,39 @@ + +from typing import ClassVar + +from django.contrib.auth.models import AbstractUser +from django.db.models import CharField +from django.db.models import EmailField +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from .managers import UserManager + + +class User(AbstractUser): + """ + Default custom user model for Learning Management System. + If adding fields that need to be filled at user signup, + check forms.SignupForm and forms.SocialSignupForms accordingly. + """ + + # First and last name do not cover name patterns around the globe + name = CharField(_("Name of User"), blank=True, max_length=255) + first_name = None # type: ignore[assignment] + last_name = None # type: ignore[assignment] + email = EmailField(_("email address"), unique=True) + username = None # type: ignore[assignment] + + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + + objects: ClassVar[UserManager] = UserManager() + + def get_absolute_url(self) -> str: + """Get URL for user's detail view. + + Returns: + str: URL for user detail. + + """ + return reverse("users:detail", kwargs={"pk": self.id}) diff --git a/lms/users/tasks.py b/lms/users/tasks.py new file mode 100644 index 0000000..ca51cd7 --- /dev/null +++ b/lms/users/tasks.py @@ -0,0 +1,9 @@ +from celery import shared_task + +from .models import User + + +@shared_task() +def get_users_count(): + """A pointless Celery task to demonstrate usage.""" + return User.objects.count() diff --git a/lms/users/tests/__init__.py b/lms/users/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lms/users/tests/factories.py b/lms/users/tests/factories.py new file mode 100644 index 0000000..9b4f244 --- /dev/null +++ b/lms/users/tests/factories.py @@ -0,0 +1,40 @@ +from collections.abc import Sequence +from typing import Any + +from factory import Faker +from factory import post_generation +from factory.django import DjangoModelFactory + +from lms.users.models import User + + +class UserFactory(DjangoModelFactory[User]): + email = Faker("email") + name = Faker("name") + + @post_generation + def password(self, create: bool, extracted: Sequence[Any], **kwargs): # noqa: FBT001 + password = ( + extracted + if extracted + else Faker( + "password", + length=42, + special_chars=True, + digits=True, + upper_case=True, + lower_case=True, + ).evaluate(None, None, extra={"locale": None}) + ) + self.set_password(password) + + @classmethod + def _after_postgeneration(cls, instance, create, results=None): + """Save again the instance if creating and at least one hook ran.""" + if create and results and not cls._meta.skip_postgeneration_save: + # Some post-generation hooks ran, and may have modified us. + instance.save() + + class Meta: + model = User + django_get_or_create = ["email"] diff --git a/lms/users/tests/test_admin.py b/lms/users/tests/test_admin.py new file mode 100644 index 0000000..05d25b2 --- /dev/null +++ b/lms/users/tests/test_admin.py @@ -0,0 +1,65 @@ +import contextlib +from http import HTTPStatus +from importlib import reload + +import pytest +from django.contrib import admin +from django.contrib.auth.models import AnonymousUser +from django.urls import reverse +from pytest_django.asserts import assertRedirects + +from lms.users.models import User + + +class TestUserAdmin: + def test_changelist(self, admin_client): + url = reverse("admin:users_user_changelist") + response = admin_client.get(url) + assert response.status_code == HTTPStatus.OK + + def test_search(self, admin_client): + url = reverse("admin:users_user_changelist") + response = admin_client.get(url, data={"q": "test"}) + assert response.status_code == HTTPStatus.OK + + def test_add(self, admin_client): + url = reverse("admin:users_user_add") + response = admin_client.get(url) + assert response.status_code == HTTPStatus.OK + + response = admin_client.post( + url, + data={ + "email": "new-admin@example.com", + "password1": "My_R@ndom-P@ssw0rd", + "password2": "My_R@ndom-P@ssw0rd", + }, + ) + assert response.status_code == HTTPStatus.FOUND + assert User.objects.filter(email="new-admin@example.com").exists() + + def test_view_user(self, admin_client): + user = User.objects.get(email="admin@example.com") + url = reverse("admin:users_user_change", kwargs={"object_id": user.pk}) + response = admin_client.get(url) + assert response.status_code == HTTPStatus.OK + + @pytest.fixture + def _force_allauth(self, settings): + settings.DJANGO_ADMIN_FORCE_ALLAUTH = True + # Reload the admin module to apply the setting change + import lms.users.admin as users_admin + + with contextlib.suppress(admin.sites.AlreadyRegistered): # type: ignore[attr-defined] + reload(users_admin) + + @pytest.mark.django_db + @pytest.mark.usefixtures("_force_allauth") + def test_allauth_login(self, rf, settings): + request = rf.get("/fake-url") + request.user = AnonymousUser() + response = admin.site.login(request) + + # The `admin` login view should redirect to the `allauth` login view + target_url = reverse(settings.LOGIN_URL) + "?next=" + request.path + assertRedirects(response, target_url, fetch_redirect_response=False) diff --git a/lms/users/tests/test_drf_urls.py b/lms/users/tests/test_drf_urls.py new file mode 100644 index 0000000..eb913e8 --- /dev/null +++ b/lms/users/tests/test_drf_urls.py @@ -0,0 +1,21 @@ +from django.urls import resolve +from django.urls import reverse + +from lms.users.models import User + + +def test_user_detail(user: User): + assert ( + reverse("api:user-detail", kwargs={"pk": user.pk}) == f"/api/users/{user.pk}/" + ) + assert resolve(f"/api/users/{user.pk}/").view_name == "api:user-detail" + + +def test_user_list(): + assert reverse("api:user-list") == "/api/users/" + assert resolve("/api/users/").view_name == "api:user-list" + + +def test_user_me(): + assert reverse("api:user-me") == "/api/users/me/" + assert resolve("/api/users/me/").view_name == "api:user-me" diff --git a/lms/users/tests/test_drf_views.py b/lms/users/tests/test_drf_views.py new file mode 100644 index 0000000..e4e504e --- /dev/null +++ b/lms/users/tests/test_drf_views.py @@ -0,0 +1,34 @@ +import pytest +from rest_framework.test import APIRequestFactory + +from lms.users.api.views import UserViewSet +from lms.users.models import User + + +class TestUserViewSet: + @pytest.fixture + def api_rf(self) -> APIRequestFactory: + return APIRequestFactory() + + def test_get_queryset(self, user: User, api_rf: APIRequestFactory): + view = UserViewSet() + request = api_rf.get("/fake-url/") + request.user = user + + view.request = request + + assert user in view.get_queryset() + + def test_me(self, user: User, api_rf: APIRequestFactory): + view = UserViewSet() + request = api_rf.get("/fake-url/") + request.user = user + + view.request = request + + response = view.me(request) # type: ignore[call-arg, arg-type, misc] + + assert response.data == { + "url": f"http://testserver/api/users/{user.pk}/", + "name": user.name, + } diff --git a/lms/users/tests/test_forms.py b/lms/users/tests/test_forms.py new file mode 100644 index 0000000..5c452b3 --- /dev/null +++ b/lms/users/tests/test_forms.py @@ -0,0 +1,35 @@ +"""Module for all Form Tests.""" + +from django.utils.translation import gettext_lazy as _ + +from lms.users.forms import UserAdminCreationForm +from lms.users.models import User + + +class TestUserAdminCreationForm: + """ + Test class for all tests related to the UserAdminCreationForm + """ + + def test_username_validation_error_msg(self, user: User): + """ + Tests UserAdminCreation Form's unique validator functions correctly by testing: + 1) A new user with an existing username cannot be added. + 2) Only 1 error is raised by the UserCreation Form + 3) The desired error message is raised + """ + + # The user already exists, + # hence cannot be created. + form = UserAdminCreationForm( + { + "email": user.email, + "password1": user.password, + "password2": user.password, + }, + ) + + assert not form.is_valid() + assert len(form.errors) == 1 + assert "email" in form.errors + assert form.errors["email"][0] == _("This email has already been taken.") diff --git a/lms/users/tests/test_managers.py b/lms/users/tests/test_managers.py new file mode 100644 index 0000000..0d2d1ad --- /dev/null +++ b/lms/users/tests/test_managers.py @@ -0,0 +1,55 @@ +from io import StringIO + +import pytest +from django.core.management import call_command + +from lms.users.models import User + + +@pytest.mark.django_db +class TestUserManager: + def test_create_user(self): + user = User.objects.create_user( + email="john@example.com", + password="something-r@nd0m!", # noqa: S106 + ) + assert user.email == "john@example.com" + assert not user.is_staff + assert not user.is_superuser + assert user.check_password("something-r@nd0m!") + assert user.username is None + + def test_create_superuser(self): + user = User.objects.create_superuser( + email="admin@example.com", + password="something-r@nd0m!", # noqa: S106 + ) + assert user.email == "admin@example.com" + assert user.is_staff + assert user.is_superuser + assert user.username is None + + def test_create_superuser_username_ignored(self): + user = User.objects.create_superuser( + email="test@example.com", + password="something-r@nd0m!", # noqa: S106 + ) + assert user.username is None + + +@pytest.mark.django_db +def test_createsuperuser_command(): + """Ensure createsuperuser command works with our custom manager.""" + out = StringIO() + command_result = call_command( + "createsuperuser", + "--email", + "henry@example.com", + interactive=False, + stdout=out, + ) + + assert command_result is None + assert out.getvalue() == "Superuser created successfully.\n" + user = User.objects.get(email="henry@example.com") + assert not user.has_usable_password() diff --git a/lms/users/tests/test_models.py b/lms/users/tests/test_models.py new file mode 100644 index 0000000..4d3a71c --- /dev/null +++ b/lms/users/tests/test_models.py @@ -0,0 +1,5 @@ +from lms.users.models import User + + +def test_user_get_absolute_url(user: User): + assert user.get_absolute_url() == f"/users/{user.pk}/" diff --git a/lms/users/tests/test_swagger.py b/lms/users/tests/test_swagger.py new file mode 100644 index 0000000..5db371d --- /dev/null +++ b/lms/users/tests/test_swagger.py @@ -0,0 +1,23 @@ +from http import HTTPStatus + +import pytest +from django.urls import reverse + + +def test_swagger_accessible_by_admin(admin_client): + url = reverse("api-docs") + response = admin_client.get(url) + assert response.status_code == HTTPStatus.OK + + +@pytest.mark.django_db +def test_swagger_ui_not_accessible_by_normal_user(client): + url = reverse("api-docs") + response = client.get(url) + assert response.status_code == HTTPStatus.FORBIDDEN + + +def test_api_schema_generated_successfully(admin_client): + url = reverse("api-schema") + response = admin_client.get(url) + assert response.status_code == HTTPStatus.OK diff --git a/lms/users/tests/test_tasks.py b/lms/users/tests/test_tasks.py new file mode 100644 index 0000000..e85896b --- /dev/null +++ b/lms/users/tests/test_tasks.py @@ -0,0 +1,17 @@ +import pytest +from celery.result import EagerResult + +from lms.users.tasks import get_users_count +from lms.users.tests.factories import UserFactory + +pytestmark = pytest.mark.django_db + + +def test_user_count(settings): + """A basic test to execute the get_users_count Celery task.""" + batch_size = 3 + UserFactory.create_batch(batch_size) + settings.CELERY_TASK_ALWAYS_EAGER = True + task_result = get_users_count.delay() + assert isinstance(task_result, EagerResult) + assert task_result.result == batch_size diff --git a/lms/users/tests/test_urls.py b/lms/users/tests/test_urls.py new file mode 100644 index 0000000..7be2ff0 --- /dev/null +++ b/lms/users/tests/test_urls.py @@ -0,0 +1,19 @@ +from django.urls import resolve +from django.urls import reverse + +from lms.users.models import User + + +def test_detail(user: User): + assert reverse("users:detail", kwargs={"pk": user.pk}) == f"/users/{user.pk}/" + assert resolve(f"/users/{user.pk}/").view_name == "users:detail" + + +def test_update(): + assert reverse("users:update") == "/users/~update/" + assert resolve("/users/~update/").view_name == "users:update" + + +def test_redirect(): + assert reverse("users:redirect") == "/users/~redirect/" + assert resolve("/users/~redirect/").view_name == "users:redirect" diff --git a/lms/users/tests/test_views.py b/lms/users/tests/test_views.py new file mode 100644 index 0000000..3e5db16 --- /dev/null +++ b/lms/users/tests/test_views.py @@ -0,0 +1,101 @@ +from http import HTTPStatus + +import pytest +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.models import AnonymousUser +from django.contrib.messages.middleware import MessageMiddleware +from django.contrib.sessions.middleware import SessionMiddleware +from django.http import HttpRequest +from django.http import HttpResponseRedirect +from django.test import RequestFactory +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ + +from lms.users.forms import UserAdminChangeForm +from lms.users.models import User +from lms.users.tests.factories import UserFactory +from lms.users.views import UserRedirectView +from lms.users.views import UserUpdateView +from lms.users.views import user_detail_view + +pytestmark = pytest.mark.django_db + + +class TestUserUpdateView: + """ + TODO: + extracting view initialization code as class-scoped fixture + would be great if only pytest-django supported non-function-scoped + fixture db access -- this is a work-in-progress for now: + https://github.com/pytest-dev/pytest-django/pull/258 + """ + + def dummy_get_response(self, request: HttpRequest): + return None + + def test_get_success_url(self, user: User, rf: RequestFactory): + view = UserUpdateView() + request = rf.get("/fake-url/") + request.user = user + + view.request = request + assert view.get_success_url() == f"/users/{user.pk}/" + + def test_get_object(self, user: User, rf: RequestFactory): + view = UserUpdateView() + request = rf.get("/fake-url/") + request.user = user + + view.request = request + + assert view.get_object() == user + + def test_form_valid(self, user: User, rf: RequestFactory): + view = UserUpdateView() + request = rf.get("/fake-url/") + + # Add the session/message middleware to the request + SessionMiddleware(self.dummy_get_response).process_request(request) + MessageMiddleware(self.dummy_get_response).process_request(request) + request.user = user + + view.request = request + + # Initialize the form + form = UserAdminChangeForm() + form.cleaned_data = {} + form.instance = user + view.form_valid(form) + + messages_sent = [m.message for m in messages.get_messages(request)] + assert messages_sent == [_("Information successfully updated")] + + +class TestUserRedirectView: + def test_get_redirect_url(self, user: User, rf: RequestFactory): + view = UserRedirectView() + request = rf.get("/fake-url") + request.user = user + + view.request = request + assert view.get_redirect_url() == f"/users/{user.pk}/" + + +class TestUserDetailView: + def test_authenticated(self, user: User, rf: RequestFactory): + request = rf.get("/fake-url/") + request.user = UserFactory() + response = user_detail_view(request, pk=user.pk) + + assert response.status_code == HTTPStatus.OK + + def test_not_authenticated(self, user: User, rf: RequestFactory): + request = rf.get("/fake-url/") + request.user = AnonymousUser() + response = user_detail_view(request, pk=user.pk) + login_url = reverse(settings.LOGIN_URL) + + assert isinstance(response, HttpResponseRedirect) + assert response.status_code == HTTPStatus.FOUND + assert response.url == f"{login_url}?next=/fake-url/" diff --git a/lms/users/urls.py b/lms/users/urls.py new file mode 100644 index 0000000..56c246c --- /dev/null +++ b/lms/users/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from .views import user_detail_view +from .views import user_redirect_view +from .views import user_update_view + +app_name = "users" +urlpatterns = [ + path("~redirect/", view=user_redirect_view, name="redirect"), + path("~update/", view=user_update_view, name="update"), + path("/", view=user_detail_view, name="detail"), +] diff --git a/lms/users/views.py b/lms/users/views.py new file mode 100644 index 0000000..d6cf208 --- /dev/null +++ b/lms/users/views.py @@ -0,0 +1,46 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.messages.views import SuccessMessageMixin +from django.db.models import QuerySet +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from django.views.generic import DetailView +from django.views.generic import RedirectView +from django.views.generic import UpdateView + +from lms.users.models import User + + +class UserDetailView(LoginRequiredMixin, DetailView): + model = User + slug_field = "id" + slug_url_kwarg = "id" + + +user_detail_view = UserDetailView.as_view() + + +class UserUpdateView(LoginRequiredMixin, SuccessMessageMixin, UpdateView): + model = User + fields = ["name"] + success_message = _("Information successfully updated") + + def get_success_url(self) -> str: + assert self.request.user.is_authenticated # type guard + return self.request.user.get_absolute_url() + + def get_object(self, queryset: QuerySet | None=None) -> User: + assert self.request.user.is_authenticated # type guard + return self.request.user + + +user_update_view = UserUpdateView.as_view() + + +class UserRedirectView(LoginRequiredMixin, RedirectView): + permanent = False + + def get_redirect_url(self) -> str: + return reverse("users:detail", kwargs={"pk": self.request.user.pk}) + + +user_redirect_view = UserRedirectView.as_view() diff --git a/locale/README.md b/locale/README.md new file mode 100644 index 0000000..8a220a9 --- /dev/null +++ b/locale/README.md @@ -0,0 +1,32 @@ +# Translations + +Start by configuring the `LANGUAGES` settings in `base.py`, by uncommenting languages you are willing to support. Then, translation strings will be placed in this folder when running: + +```bash +docker compose -f docker-compose.local.yml run --rm django python manage.py makemessages --all --no-location +``` + +This should generate `django.po` (stands for Portable Object) files under each locale `/LC_MESSAGES/django.po`. Each translatable string in the codebase is collected with its `msgid` and need to be translated as `msgstr`, for example: + +```po +msgid "users" +msgstr "utilisateurs" +``` + +Once all translations are done, they need to be compiled into `.mo` files (stands for Machine Object), which are the actual binary files used by the application: + +```bash +docker compose -f docker-compose.local.yml run --rm django python manage.py compilemessages +``` + +Note that the `.po` files are NOT used by the application directly, so if the `.mo` files are out of date, the content won't appear as translated even if the `.po` files are up-to-date. + +## Production + +The production image runs `compilemessages` automatically at build time, so as long as your translated source files (PO) are up-to-date, you're good to go. + +## Add a new language + +1. Update the [`LANGUAGES` setting](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-LANGUAGES) to your project's base settings. +2. Create the locale folder for the language next to this file, e.g. `fr_FR` for French. Make sure the case is correct. +3. Run `makemessages` (as instructed above) to generate the PO files for the new language. diff --git a/locale/en_US/LC_MESSAGES/django.po b/locale/en_US/LC_MESSAGES/django.po new file mode 100644 index 0000000..24c75d4 --- /dev/null +++ b/locale/en_US/LC_MESSAGES/django.po @@ -0,0 +1,12 @@ +# Translations for the Learning Management System project +# Copyright (C) 2025 Ahmed Nagi +# Ahmed Nagi , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: 0.1.0\n" +"Language: en-US\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" diff --git a/locale/fr_FR/LC_MESSAGES/django.po b/locale/fr_FR/LC_MESSAGES/django.po new file mode 100644 index 0000000..2db6321 --- /dev/null +++ b/locale/fr_FR/LC_MESSAGES/django.po @@ -0,0 +1,335 @@ +# Translations for the Learning Management System project +# Copyright (C) 2025 Ahmed Nagi +# Ahmed Nagi , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: 0.1.0\n" +"Language: fr-FR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +#: lms/templates/account/account_inactive.html:5 +#: lms/templates/account/account_inactive.html:8 +msgid "Account Inactive" +msgstr "Compte inactif" + +#: lms/templates/account/account_inactive.html:10 +msgid "This account is inactive." +msgstr "Ce compte est inactif." + +#: lms/templates/account/email.html:7 +msgid "Account" +msgstr "Compte" + +#: lms/templates/account/email.html:10 +msgid "E-mail Addresses" +msgstr "Adresses e-mail" + +#: lms/templates/account/email.html:13 +msgid "The following e-mail addresses are associated with your account:" +msgstr "Les adresses e-mail suivantes sont associées à votre compte :" + +#: lms/templates/account/email.html:27 +msgid "Verified" +msgstr "Vérifié" + +#: lms/templates/account/email.html:29 +msgid "Unverified" +msgstr "Non vérifié" + +#: lms/templates/account/email.html:31 +msgid "Primary" +msgstr "Primaire" + +#: lms/templates/account/email.html:37 +msgid "Make Primary" +msgstr "Changer Primaire" + +#: lms/templates/account/email.html:38 +msgid "Re-send Verification" +msgstr "Renvoyer vérification" + +#: lms/templates/account/email.html:39 +msgid "Remove" +msgstr "Supprimer" + +#: lms/templates/account/email.html:46 +msgid "Warning:" +msgstr "Avertissement:" + +#: lms/templates/account/email.html:46 +msgid "" +"You currently do not have any e-mail address set up. You should really add " +"an e-mail address so you can receive notifications, reset your password, etc." +msgstr "" +"Vous n'avez actuellement aucune adresse e-mail configurée. Vous devriez ajouter " +"une adresse e-mail pour reçevoir des notifications, réinitialiser votre mot " +"de passe, etc." + +#: lms/templates/account/email.html:51 +msgid "Add E-mail Address" +msgstr "Ajouter une adresse e-mail" + +#: lms/templates/account/email.html:56 +msgid "Add E-mail" +msgstr "Ajouter e-mail" + +#: lms/templates/account/email.html:66 +msgid "Do you really want to remove the selected e-mail address?" +msgstr "Voulez-vous vraiment supprimer l'adresse e-mail sélectionnée ?" + +#: lms/templates/account/email_confirm.html:6 +#: lms/templates/account/email_confirm.html:10 +msgid "Confirm E-mail Address" +msgstr "Confirmez votre adresse email" + +#: lms/templates/account/email_confirm.html:16 +#, python-format +msgid "" +"Please confirm that %(email)s is an e-mail " +"address for user %(user_display)s." +msgstr "" +"Veuillez confirmer que %(email)s est un e-mail " +"adresse de l'utilisateur %(user_display)s." + +#: lms/templates/account/email_confirm.html:20 +msgid "Confirm" +msgstr "Confirm" + +#: lms/templates/account/email_confirm.html:27 +#, python-format +msgid "" +"This e-mail confirmation link expired or is invalid. Please issue a new e-mail confirmation request." +msgstr "" +"Ce lien de confirmation par e-mail a expiré ou n'est pas valide. Veuillez" + "émettre une nouvelle demande de confirmation " +"par e-mail." + +#: lms/templates/account/login.html:7 +#: lms/templates/account/login.html:11 +#: lms/templates/account/login.html:56 +#: lms/templates/base.html:72 +msgid "Sign In" +msgstr "S'identifier" + +#: lms/templates/account/login.html:17 +msgid "Please sign in with one of your existing third party accounts:" +msgstr "Veuillez vous connecter avec l'un de vos comptes tiers existants :" + +#: lms/templates/account/login.html:19 +#, python-format +msgid "" +"Or, sign up for a %(site_name)s account and " +"sign in below:" +msgstr "" +"Ou, créez un compte %(site_name)s et " +"connectez-vous ci-dessous :" + +#: lms/templates/account/login.html:32 +msgid "or" +msgstr "ou" + +#: lms/templates/account/login.html:41 +#, python-format +msgid "" +"If you have not created an account yet, then please sign up first." +msgstr "" +"Si vous n'avez pas encore créé de compte, veuillez d'abord vous inscrire." + +#: lms/templates/account/login.html:55 +msgid "Forgot Password?" +msgstr "Mot de passe oublié?" + +#: lms/templates/account/logout.html:5 +#: lms/templates/account/logout.html:8 +#: lms/templates/account/logout.html:17 +#: lms/templates/base.html:61 +msgid "Sign Out" +msgstr "Se déconnecter" + +#: lms/templates/account/logout.html:10 +msgid "Are you sure you want to sign out?" +msgstr "Êtes-vous certain de vouloir vous déconnecter?" + +#: lms/templates/account/password_change.html:6 +#: lms/templates/account/password_change.html:9 +#: lms/templates/account/password_change.html:14 +#: lms/templates/account/password_reset_from_key.html:5 +#: lms/templates/account/password_reset_from_key.html:8 +#: lms/templates/account/password_reset_from_key_done.html:4 +#: lms/templates/account/password_reset_from_key_done.html:7 +msgid "Change Password" +msgstr "Changer le mot de passe" + +#: lms/templates/account/password_reset.html:7 +#: lms/templates/account/password_reset.html:11 +#: lms/templates/account/password_reset_done.html:6 +#: lms/templates/account/password_reset_done.html:9 +msgid "Password Reset" +msgstr "Réinitialisation du mot de passe" + +#: lms/templates/account/password_reset.html:16 +msgid "" +"Forgotten your password? Enter your e-mail address below, and we'll send you " +"an e-mail allowing you to reset it." +msgstr "" +"Mot de passe oublié? Entrez votre adresse e-mail ci-dessous, et nous vous " +"enverrons un e-mail vous permettant de le réinitialiser." + +#: lms/templates/account/password_reset.html:21 +msgid "Reset My Password" +msgstr "Réinitialiser mon mot de passe" + +#: lms/templates/account/password_reset.html:24 +msgid "Please contact us if you have any trouble resetting your password." +msgstr "" +"Veuillez nous contacter si vous rencontrez des difficultés pour réinitialiser" +"votre mot de passe." + +#: lms/templates/account/password_reset_done.html:15 +msgid "" +"We have sent you an e-mail. Please contact us if you do not receive it " +"within a few minutes." +msgstr "" +"Nous vous avons envoyé un e-mail. Veuillez nous contacter si vous ne le " +"recevez pas d'ici quelques minutes." + +#: lms/templates/account/password_reset_from_key.html:8 +msgid "Bad Token" +msgstr "Token Invalide" + +#: lms/templates/account/password_reset_from_key.html:12 +#, python-format +msgid "" +"The password reset link was invalid, possibly because it has already been " +"used. Please request a new password reset." +msgstr "" +"Le lien de réinitialisation du mot de passe n'était pas valide, peut-être parce " +"qu'il a déjà été utilisé. Veuillez faire une " +"nouvelle demande de réinitialisation de mot de passe." + +#: lms/templates/account/password_reset_from_key.html:18 +msgid "change password" +msgstr "changer le mot de passe" + +#: lms/templates/account/password_reset_from_key.html:21 +#: lms/templates/account/password_reset_from_key_done.html:8 +msgid "Your password is now changed." +msgstr "Votre mot de passe est maintenant modifié." + +#: lms/templates/account/password_set.html:6 +#: lms/templates/account/password_set.html:9 +#: lms/templates/account/password_set.html:14 +msgid "Set Password" +msgstr "Définir le mot de passe" + +#: lms/templates/account/signup.html:6 +msgid "Signup" +msgstr "S'inscrire" + +#: lms/templates/account/signup.html:9 +#: lms/templates/account/signup.html:19 +#: lms/templates/base.html:67 +msgid "Sign Up" +msgstr "S'inscrire" + +#: lms/templates/account/signup.html:11 +#, python-format +msgid "" +"Already have an account? Then please sign in." +msgstr "" +"Vous avez déjà un compte? Alors veuillez vous connecter." + +#: lms/templates/account/signup_closed.html:5 +#: lms/templates/account/signup_closed.html:8 +msgid "Sign Up Closed" +msgstr "Inscriptions closes" + +#: lms/templates/account/signup_closed.html:10 +msgid "We are sorry, but the sign up is currently closed." +msgstr "Désolé, mais l'inscription est actuellement fermée." + +#: lms/templates/account/verification_sent.html:5 +#: lms/templates/account/verification_sent.html:8 +#: lms/templates/account/verified_email_required.html:5 +#: lms/templates/account/verified_email_required.html:8 +msgid "Verify Your E-mail Address" +msgstr "Vérifiez votre adresse e-mail" + +#: lms/templates/account/verification_sent.html:10 +msgid "" +"We have sent an e-mail to you for verification. Follow the link provided to " +"finalize the signup process. Please contact us if you do not receive it " +"within a few minutes." +msgstr "Nous vous avons envoyé un e-mail pour vérification. Suivez le lien fourni " +"pour finalisez le processus d'inscription. Veuillez nous contacter si vous ne le " +"recevez pas d'ici quelques minutes." + +#: lms/templates/account/verified_email_required.html:12 +msgid "" +"This part of the site requires us to verify that\n" +"you are who you claim to be. For this purpose, we require that you\n" +"verify ownership of your e-mail address. " +msgstr "" +"Cette partie du site nous oblige à vérifier que\n" +"vous êtes qui vous prétendez être. Nous vous demandons donc de\n" +"vérifier la propriété de votre adresse e-mail." + +#: lms/templates/account/verified_email_required.html:16 +msgid "" +"We have sent an e-mail to you for\n" +"verification. Please click on the link inside this e-mail. Please\n" +"contact us if you do not receive it within a few minutes." +msgstr "" +"Nous vous avons envoyé un e-mail pour\n" +"vérification. Veuillez cliquer sur le lien contenu dans cet e-mail. Veuillez nous\n" +"contacter si vous ne le recevez pas d'ici quelques minutes." + +#: lms/templates/account/verified_email_required.html:20 +#, python-format +msgid "" +"Note: you can still change your e-" +"mail address." +msgstr "" +"Remarque : vous pouvez toujours changer votre e-" +"adresse e-mail." + +#: lms/templates/base.html:57 +msgid "My Profile" +msgstr "Mon Profil" + +#: lms/users/admin.py:17 +msgid "Personal info" +msgstr "Personal info" + +#: lms/users/admin.py:19 +msgid "Permissions" +msgstr "Permissions" + +#: lms/users/admin.py:30 +msgid "Important dates" +msgstr "Dates importantes" + +#: lms/users/apps.py:7 +msgid "Users" +msgstr "Utilisateurs" + +#: lms/users/forms.py:24 +#: lms/users/tests/test_forms.py:36 +msgid "This username has already been taken." +msgstr "Ce nom d'utilisateur est déjà pris." + +#: lms/users/models.py:15 +msgid "Name of User" +msgstr "Nom de l'utilisateur" + +#: lms/users/views.py:23 +msgid "Information successfully updated" +msgstr "Informations mises à jour avec succès" diff --git a/locale/pt_BR/LC_MESSAGES/django.po b/locale/pt_BR/LC_MESSAGES/django.po new file mode 100644 index 0000000..4636cf3 --- /dev/null +++ b/locale/pt_BR/LC_MESSAGES/django.po @@ -0,0 +1,315 @@ +# Translations for the Learning Management System project +# Copyright (C) 2025 Ahmed Nagi +# Ahmed Nagi , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: 0.1.0\n" +"Language: pt-BR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +#: lms/templates/account/account_inactive.html:5 +#: lms/templates/account/account_inactive.html:8 +msgid "Account Inactive" +msgstr "Conta Inativa" + +#: lms/templates/account/account_inactive.html:10 +msgid "This account is inactive." +msgstr "Esta conta está inativa." + +#: lms/templates/account/email.html:7 +msgid "Account" +msgstr "Conta" + +#: lms/templates/account/email.html:10 +msgid "E-mail Addresses" +msgstr "Endereços de E-mail" + +#: lms/templates/account/email.html:13 +msgid "The following e-mail addresses are associated with your account:" +msgstr "Os seguintes endereços de e-mail estão associados à sua conta:" + +#: lms/templates/account/email.html:27 +msgid "Verified" +msgstr "Verificado" + +#: lms/templates/account/email.html:29 +msgid "Unverified" +msgstr "Não verificado" + +#: lms/templates/account/email.html:31 +msgid "Primary" +msgstr "Primário" + +#: lms/templates/account/email.html:37 +msgid "Make Primary" +msgstr "Tornar Primário" + +#: lms/templates/account/email.html:38 +msgid "Re-send Verification" +msgstr "Reenviar verificação" + +#: lms/templates/account/email.html:39 +msgid "Remove" +msgstr "Remover" + +#: lms/templates/account/email.html:46 +msgid "Warning:" +msgstr "Aviso:" + +#: lms/templates/account/email.html:46 +msgid "" +"You currently do not have any e-mail address set up. You should really add " +"an e-mail address so you can receive notifications, reset your password, etc." +msgstr "" +"No momento, você não tem nenhum endereço de e-mail configurado. Você " +"realmente deve adicionar um endereço de e-mail para receber notificações, " +"redefinir sua senha etc." + +#: lms/templates/account/email.html:51 +msgid "Add E-mail Address" +msgstr "Adicionar Endereço de E-mail" + +#: lms/templates/account/email.html:56 +msgid "Add E-mail" +msgstr "Adicionar E-mail" + +#: lms/templates/account/email.html:66 +msgid "Do you really want to remove the selected e-mail address?" +msgstr "Você realmente deseja remover o endereço de e-mail selecionado?" + +#: lms/templates/account/email_confirm.html:6 +#: lms/templates/account/email_confirm.html:10 +msgid "Confirm E-mail Address" +msgstr "Confirme o endereço de e-mail" + +#: lms/templates/account/email_confirm.html:16 +#, python-format +msgid "" +"Please confirm that %(email)s is an e-mail " +"address for user %(user_display)s." +msgstr "" +"Confirme se %(email)s é um endereço de " +"e-mail do usuário %(user_display)s." + +#: lms/templates/account/email_confirm.html:20 +msgid "Confirm" +msgstr "Confirmar" + +#: lms/templates/account/email_confirm.html:27 +#, python-format +msgid "" +"This e-mail confirmation link expired or is invalid. Please issue a new e-mail confirmation request." +msgstr "Este link de confirmação de e-mail expirou ou é inválido. " +"Por favor, emita um novo pedido de confirmação por e-mail." + +#: lms/templates/account/login.html:7 +#: lms/templates/account/login.html:11 +#: lms/templates/account/login.html:56 +#: lms/templates/base.html:72 +msgid "Sign In" +msgstr "Entrar" + +#: lms/templates/account/login.html:17 +msgid "Please sign in with one of your existing third party accounts:" +msgstr "Faça login com uma de suas contas de terceiros existentes:" + +#: lms/templates/account/login.html:19 +#, python-format +msgid "" +"Or, sign up for a %(site_name)s account and " +"sign in below:" +msgstr "Ou, cadastre-se para uma conta em %(site_name)s e entre abaixo:" + +#: lms/templates/account/login.html:32 +msgid "or" +msgstr "ou" + +#: lms/templates/account/login.html:41 +#, python-format +msgid "" +"If you have not created an account yet, then please sign up first." +msgstr "Se você ainda não criou uma conta, registre-se primeiro." + +#: lms/templates/account/login.html:55 +msgid "Forgot Password?" +msgstr "Esqueceu sua senha?" + +#: lms/templates/account/logout.html:5 +#: lms/templates/account/logout.html:8 +#: lms/templates/account/logout.html:17 +#: lms/templates/base.html:61 +msgid "Sign Out" +msgstr "Sair" + +#: lms/templates/account/logout.html:10 +msgid "Are you sure you want to sign out?" +msgstr "Você tem certeza que deseja sair?" + +#: lms/templates/account/password_change.html:6 +#: lms/templates/account/password_change.html:9 +#: lms/templates/account/password_change.html:14 +#: lms/templates/account/password_reset_from_key.html:5 +#: lms/templates/account/password_reset_from_key.html:8 +#: lms/templates/account/password_reset_from_key_done.html:4 +#: lms/templates/account/password_reset_from_key_done.html:7 +msgid "Change Password" +msgstr "Alterar Senha" + +#: lms/templates/account/password_reset.html:7 +#: lms/templates/account/password_reset.html:11 +#: lms/templates/account/password_reset_done.html:6 +#: lms/templates/account/password_reset_done.html:9 +msgid "Password Reset" +msgstr "Redefinição de senha" + +#: lms/templates/account/password_reset.html:16 +msgid "" +"Forgotten your password? Enter your e-mail address below, and we'll send you " +"an e-mail allowing you to reset it." +msgstr "Esqueceu sua senha? Digite seu endereço de e-mail abaixo e enviaremos um e-mail permitindo que você o redefina." + +#: lms/templates/account/password_reset.html:21 +msgid "Reset My Password" +msgstr "Redefinir minha senha" + +#: lms/templates/account/password_reset.html:24 +msgid "Please contact us if you have any trouble resetting your password." +msgstr "Entre em contato conosco se tiver algum problema para redefinir sua senha." + +#: lms/templates/account/password_reset_done.html:15 +msgid "" +"We have sent you an e-mail. Please contact us if you do not receive it " +"within a few minutes." +msgstr "Enviamos um e-mail para você. Entre em contato conosco se você não recebê-lo dentro de alguns minutos." + +#: lms/templates/account/password_reset_from_key.html:8 +msgid "Bad Token" +msgstr "Token Inválido" + +#: lms/templates/account/password_reset_from_key.html:12 +#, python-format +msgid "" +"The password reset link was invalid, possibly because it has already been " +"used. Please request a new password reset." +msgstr "O link de redefinição de senha era inválido, possivelmente porque já foi usado. " +"Solicite uma nova redefinição de senha." + +#: lms/templates/account/password_reset_from_key.html:18 +msgid "change password" +msgstr "alterar senha" + +#: lms/templates/account/password_reset_from_key.html:21 +#: lms/templates/account/password_reset_from_key_done.html:8 +msgid "Your password is now changed." +msgstr "Sua senha agora foi alterada." + +#: lms/templates/account/password_set.html:6 +#: lms/templates/account/password_set.html:9 +#: lms/templates/account/password_set.html:14 +msgid "Set Password" +msgstr "Definir Senha" + +#: lms/templates/account/signup.html:6 +msgid "Signup" +msgstr "Cadastro" + +#: lms/templates/account/signup.html:9 +#: lms/templates/account/signup.html:19 +#: lms/templates/base.html:67 +msgid "Sign Up" +msgstr "Cadastro" + +#: lms/templates/account/signup.html:11 +#, python-format +msgid "" +"Already have an account? Then please sign in." +msgstr "já tem uma conta? Então, por favor, faça login." + +#: lms/templates/account/signup_closed.html:5 +#: lms/templates/account/signup_closed.html:8 +msgid "Sign Up Closed" +msgstr "Inscrições encerradas" + +#: lms/templates/account/signup_closed.html:10 +msgid "We are sorry, but the sign up is currently closed." +msgstr "Lamentamos, mas as inscrições estão encerradas no momento." + +#: lms/templates/account/verification_sent.html:5 +#: lms/templates/account/verification_sent.html:8 +#: lms/templates/account/verified_email_required.html:5 +#: lms/templates/account/verified_email_required.html:8 +msgid "Verify Your E-mail Address" +msgstr "Verifique seu endereço de e-mail" + +#: lms/templates/account/verification_sent.html:10 +msgid "" +"We have sent an e-mail to you for verification. Follow the link provided to " +"finalize the signup process. Please contact us if you do not receive it " +"within a few minutes." +msgstr "Enviamos um e-mail para você para verificação. Siga o link fornecido para finalizar o processo de inscrição. Entre em contato conosco se você não recebê-lo dentro de alguns minutos." + +#: lms/templates/account/verified_email_required.html:12 +msgid "" +"This part of the site requires us to verify that\n" +"you are who you claim to be. For this purpose, we require that you\n" +"verify ownership of your e-mail address. " +msgstr "Esta parte do site exige que verifiquemos se você é quem afirma ser.\n" +"Para esse fim, exigimos que você verifique a propriedade\n" +"do seu endereço de e-mail." + +#: lms/templates/account/verified_email_required.html:16 +msgid "" +"We have sent an e-mail to you for\n" +"verification. Please click on the link inside this e-mail. Please\n" +"contact us if you do not receive it within a few minutes." +msgstr "Enviamos um e-mail para você para verificação.\n" +"Por favor, clique no link dentro deste e-mail.\n" +"Entre em contato conosco se você não recebê-lo dentro de alguns minutos." + +#: lms/templates/account/verified_email_required.html:20 +#, python-format +msgid "" +"Note: you can still change your e-" +"mail address." +msgstr "Nota: você ainda pode alterar seu endereço de e-mail." + +#: lms/templates/base.html:57 +msgid "My Profile" +msgstr "Meu perfil" + +#: lms/users/admin.py:17 +msgid "Personal info" +msgstr "Informação pessoal" + +#: lms/users/admin.py:19 +msgid "Permissions" +msgstr "Permissões" + +#: lms/users/admin.py:30 +msgid "Important dates" +msgstr "Datas importantes" + +#: lms/users/apps.py:7 +msgid "Users" +msgstr "Usuários" + +#: lms/users/forms.py:24 +#: lms/users/tests/test_forms.py:36 +msgid "This username has already been taken." +msgstr "Este nome de usuário já foi usado." + +#: lms/users/models.py:15 +msgid "Name of User" +msgstr "Nome do Usuário" + +#: lms/users/views.py:23 +msgid "Information successfully updated" +msgstr "Informação atualizada com sucesso" diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..415960e --- /dev/null +++ b/manage.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# ruff: noqa +import os +import sys +from pathlib import Path + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") + + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django + except ImportError: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) + + raise + + # This allows easy placement of apps within the interior + # lms directory. + current_path = Path(__file__).parent.resolve() + sys.path.append(str(current_path / "lms")) + + execute_from_command_line(sys.argv) diff --git a/merge_production_dotenvs_in_dotenv.py b/merge_production_dotenvs_in_dotenv.py new file mode 100644 index 0000000..c83ed71 --- /dev/null +++ b/merge_production_dotenvs_in_dotenv.py @@ -0,0 +1,27 @@ +# ruff: noqa +import os +from collections.abc import Sequence +from pathlib import Path + +BASE_DIR = Path(__file__).parent.resolve() +PRODUCTION_DOTENVS_DIR = BASE_DIR / ".envs" / ".production" +PRODUCTION_DOTENV_FILES = [ + PRODUCTION_DOTENVS_DIR / ".django", + PRODUCTION_DOTENVS_DIR / ".postgres", +] +DOTENV_FILE = BASE_DIR / ".env" + + +def merge( + output_file: Path, + files_to_merge: Sequence[Path], +) -> None: + merged_content = "" + for merge_file in files_to_merge: + merged_content += merge_file.read_text() + merged_content += os.linesep + output_file.write_text(merged_content) + + +if __name__ == "__main__": + merge(DOTENV_FILE, PRODUCTION_DOTENV_FILES) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3d123bd --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,137 @@ +# ==== pytest ==== +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "--ds=config.settings.test --reuse-db --import-mode=importlib" +python_files = [ + "tests.py", + "test_*.py", +] + +# ==== Coverage ==== +[tool.coverage.run] +include = ["lms/**"] +omit = ["*/migrations/*", "*/tests/*"] +plugins = ["django_coverage_plugin"] + +# ==== mypy ==== +[tool.mypy] +python_version = "3.12" +check_untyped_defs = true +ignore_missing_imports = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unused_configs = true +plugins = [ + "mypy_django_plugin.main", + "mypy_drf_plugin.main", +] + +[[tool.mypy.overrides]] +# Django migrations should not produce any errors: +module = "*.migrations.*" +ignore_errors = true + +[tool.django-stubs] +django_settings_module = "config.settings.test" + +# ==== djLint ==== +[tool.djlint] +blank_line_after_tag = "load,extends" +close_void_tags = true +format_css = true +format_js = true +# TODO: remove T002 when fixed https://github.com/djlint/djLint/issues/687 +ignore = "H006,H030,H031,T002" +include = "H017,H035" +indent = 2 +max_line_length = 119 +profile = "django" + +[tool.djlint.css] +indent_size = 2 + +[tool.djlint.js] +indent_size = 2 + +[tool.ruff] +target-version = "py312" +# Exclude a variety of commonly ignored directories. +extend-exclude = [ + "*/migrations/*.py", + "staticfiles/*", +] + +[tool.ruff.lint] +select = [ + "F", + "E", + "W", + "C90", + "I", + "N", + "UP", + "YTT", + # "ANN", # flake8-annotations: we should support this in the future but 100+ errors atm + "ASYNC", + "S", + "BLE", + "FBT", + "B", + "A", + "COM", + "C4", + "DTZ", + "T10", + "DJ", + "EM", + "EXE", + "FA", + 'ISC', + "ICN", + "G", + 'INP', + 'PIE', + "T20", + 'PYI', + 'PT', + "Q", + "RSE", + "RET", + "SLF", + "SLOT", + "SIM", + "TID", + "TCH", + "INT", + # "ARG", # Unused function argument + "PTH", + "ERA", + "PD", + "PGH", + "PL", + "TRY", + "FLY", + # "NPY", + # "AIR", + "PERF", + # "FURB", + # "LOG", + "RUF", +] +ignore = [ + "S101", # Use of assert detected https://docs.astral.sh/ruff/rules/assert/ + "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` + "SIM102", # sometimes it's better to nest + "UP038", # Checks for uses of isinstance/issubclass that take a tuple + # of types for comparison. + # Deactivated because it can make the code slow: + # https://github.com/astral-sh/ruff/issues/7871 +] +# The fixes in extend-unsafe-fixes will require +# provide the `--unsafe-fixes` flag when fixing. +extend-unsafe-fixes = [ + "UP038", +] + +[tool.ruff.lint.isort] +force-single-line = true diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..aaac36d --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,30 @@ +python-slugify==8.0.4 # https://github.com/un33k/python-slugify +Pillow==11.1.0 # https://github.com/python-pillow/Pillow +rcssmin==1.1.2 # https://github.com/ndparker/rcssmin +argon2-cffi==23.1.0 # https://github.com/hynek/argon2_cffi +whitenoise==6.8.2 # https://github.com/evansd/whitenoise +redis==5.2.1 # https://github.com/redis/redis-py +hiredis==3.1.0 # https://github.com/redis/hiredis-py +celery==5.4.0 # pyup: < 6.0 # https://github.com/celery/celery +django-celery-beat==2.7.0 # https://github.com/celery/django-celery-beat +flower==2.0.1 # https://github.com/mher/flower +uvicorn[standard]==0.34.0 # https://github.com/encode/uvicorn +uvicorn-worker==0.3.0 # https://github.com/Kludex/uvicorn-worker + +# Django +# ------------------------------------------------------------------------------ +django==5.0.10 # pyup: < 5.1 # https://www.djangoproject.com/ +django-environ==0.11.2 # https://github.com/joke2k/django-environ +django-model-utils==5.0.0 # https://github.com/jazzband/django-model-utils +django-allauth[mfa]==65.3.1 # https://github.com/pennersr/django-allauth +django-crispy-forms==2.3 # https://github.com/django-crispy-forms/django-crispy-forms +crispy-bootstrap5==2024.10 # https://github.com/django-crispy-forms/crispy-bootstrap5 +django-compressor==4.5.1 # https://github.com/django-compressor/django-compressor +django-redis==5.4.0 # https://github.com/jazzband/django-redis +# Django REST Framework +djangorestframework==3.15.2 # https://github.com/encode/django-rest-framework +dj-rest-auth +django-cors-headers==4.6.0 # https://github.com/adamchainz/django-cors-headers +# DRF-spectacular for api documentation +drf-spectacular==0.28.0 # https://github.com/tfranzel/drf-spectacular + diff --git a/requirements/local.txt b/requirements/local.txt new file mode 100644 index 0000000..2cb6863 --- /dev/null +++ b/requirements/local.txt @@ -0,0 +1,35 @@ +-r production.txt + +Werkzeug[watchdog]==3.1.3 # https://github.com/pallets/werkzeug +ipdb==0.13.13 # https://github.com/gotcha/ipdb +psycopg[c]==3.2.3 # https://github.com/psycopg/psycopg +watchfiles==1.0.4 # https://github.com/samuelcolvin/watchfiles + +# Testing +# ------------------------------------------------------------------------------ +mypy==1.13.0 # https://github.com/python/mypy +django-stubs[compatible-mypy]==5.1.1 # https://github.com/typeddjango/django-stubs +pytest==8.3.4 # https://github.com/pytest-dev/pytest +pytest-sugar==1.0.0 # https://github.com/Frozenball/pytest-sugar +djangorestframework-stubs==3.15.2 # https://github.com/typeddjango/djangorestframework-stubs + +# Documentation +# ------------------------------------------------------------------------------ +sphinx==8.1.3 # https://github.com/sphinx-doc/sphinx +sphinx-autobuild==2024.10.3 # https://github.com/GaretJax/sphinx-autobuild + +# Code quality +# ------------------------------------------------------------------------------ +ruff==0.8.6 # https://github.com/astral-sh/ruff +coverage==7.6.10 # https://github.com/nedbat/coveragepy +djlint==1.36.4 # https://github.com/Riverside-Healthcare/djLint +pre-commit==4.0.1 # https://github.com/pre-commit/pre-commit + +# Django +# ------------------------------------------------------------------------------ +factory-boy==3.3.1 # https://github.com/FactoryBoy/factory_boy + +django-debug-toolbar==4.4.6 # https://github.com/jazzband/django-debug-toolbar +django-extensions==3.2.3 # https://github.com/django-extensions/django-extensions +django-coverage-plugin==3.1.0 # https://github.com/nedbat/django_coverage_plugin +pytest-django==4.9.0 # https://github.com/pytest-dev/pytest-django diff --git a/requirements/production.txt b/requirements/production.txt new file mode 100644 index 0000000..2f7e33c --- /dev/null +++ b/requirements/production.txt @@ -0,0 +1,11 @@ +# PRECAUTION: avoid production dependencies that aren't in development + +-r base.txt + +gunicorn==23.0.0 # https://github.com/benoitc/gunicorn +psycopg[c]==3.2.3 # https://github.com/psycopg/psycopg +sentry-sdk==2.19.2 # https://github.com/getsentry/sentry-python + +# Django +# ------------------------------------------------------------------------------ +django-anymail[mailgun]==12.0 # https://github.com/anymail/django-anymail diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_merge_production_dotenvs_in_dotenv.py b/tests/test_merge_production_dotenvs_in_dotenv.py new file mode 100644 index 0000000..c0e68f6 --- /dev/null +++ b/tests/test_merge_production_dotenvs_in_dotenv.py @@ -0,0 +1,34 @@ +from pathlib import Path + +import pytest + +from merge_production_dotenvs_in_dotenv import merge + + +@pytest.mark.parametrize( + ("input_contents", "expected_output"), + [ + ([], ""), + ([""], "\n"), + (["JANE=doe"], "JANE=doe\n"), + (["SEP=true", "AR=ator"], "SEP=true\nAR=ator\n"), + (["A=0", "B=1", "C=2"], "A=0\nB=1\nC=2\n"), + (["X=x\n", "Y=y", "Z=z\n"], "X=x\n\nY=y\nZ=z\n\n"), + ], +) +def test_merge( + tmp_path: Path, + input_contents: list[str], + expected_output: str, +): + output_file = tmp_path / ".env" + + files_to_merge = [] + for num, input_content in enumerate(input_contents, start=1): + merge_file = tmp_path / f".service{num}" + merge_file.write_text(input_content) + files_to_merge.append(merge_file) + + merge(output_file, files_to_merge) + + assert output_file.read_text() == expected_output