From 50a5e016c82320442b35945d18112d6baa59887a Mon Sep 17 00:00:00 2001 From: alireza Date: Thu, 21 Nov 2024 01:35:08 +0330 Subject: [PATCH] init project first commit --- .env.dev | 19 + .env.prod | 26 + .gitignore | 419 ++++++++++++++ Dockerfile | 32 ++ Dockerfile.prod | 58 ++ Jenkinsfile | 35 ++ README.md | 0 apps/account/__init__.py | 0 apps/account/admin/__init__.py | 4 + apps/account/admin/professor.py | 56 ++ apps/account/admin/student.py | 56 ++ apps/account/admin/user.py | 94 ++++ apps/account/apps.py | 7 + apps/account/custom_user_login.py | 25 + apps/account/doc.py | 0 apps/account/management/__init__.py | 0 apps/account/management/commands/__init__,py | 0 .../management/commands/create_groups.py | 52 ++ apps/account/manager.py | 83 +++ apps/account/migrations/0001_initial.py | 116 ++++ .../migrations/0002_alter_user_birthdate.py | 18 + .../migrations/0003_auto_20241120_1741.py | 22 + apps/account/migrations/__init__.py | 0 apps/account/models/__init__.py | 4 + apps/account/models/groups.py | 95 ++++ apps/account/models/user.py | 84 +++ apps/account/permissions.py | 12 + apps/account/serializers/__init__.py | 2 + apps/account/serializers/user.py | 152 +++++ apps/account/tasks.py | 61 ++ apps/account/tests.py | 3 + apps/account/urls.py | 35 ++ apps/account/views/__init__.py | 1 + apps/account/views/user.py | 238 ++++++++ apps/api/__init__.py | 0 apps/api/admin.py | 3 + apps/api/apps.py | 6 + apps/api/models.py | 3 + apps/api/tests.py | 3 + apps/api/urls.py | 9 + apps/api/views.py | 33 ++ apps/course/__init__.py | 0 apps/course/admin/__init__.py | 2 + apps/course/admin/course.py | 87 +++ apps/course/admin/lesson.py | 18 + apps/course/apps.py | 6 + apps/course/migrations/__init__.py | 0 apps/course/models/__init__.py | 2 + apps/course/models/course.py | 166 ++++++ apps/course/models/lesson.py | 34 ++ apps/course/serializers/__init__.py | 1 + apps/course/serializers/course.py | 107 ++++ apps/course/tests.py | 3 + apps/course/urls.py | 15 + apps/course/views/__init__.py | 1 + apps/course/views/course.py | 98 ++++ apps/quiz/__init__.py | 0 apps/quiz/admin.py | 3 + apps/quiz/apps.py | 6 + apps/quiz/migrations/__init__.py | 0 apps/quiz/models/__init__.py | 0 apps/quiz/models/participant.py | 68 +++ apps/quiz/models/quiz.py | 53 ++ apps/quiz/tests.py | 3 + apps/quiz/views.py | 3 + config/__init__.py | 8 + config/asgi.py | 16 + config/celery.py | 22 + config/language_code_middleware.py | 19 + config/redis_config.py | 15 + config/settings/__init__.py | 0 config/settings/base.py | 308 ++++++++++ config/settings/develop.py | 17 + config/settings/production.py | 120 ++++ config/test_auth_middleware.py | 29 + config/urls.py | 53 ++ config/wsgi.py | 16 + docker-compose.prod.yml | 85 +++ docker-compose.yml | 37 ++ dynamic_preferences/__init__.py | 2 + dynamic_preferences/admin.py | 114 ++++ dynamic_preferences/api/__init__.py | 0 dynamic_preferences/api/serializers.py | 71 +++ dynamic_preferences/api/viewsets.py | 179 ++++++ dynamic_preferences/apps.py | 25 + .../dynamic_preferences_registry.py | 15 + dynamic_preferences/exceptions.py | 32 ++ dynamic_preferences/forms.py | 152 +++++ .../locale/ar/LC_MESSAGES/django.po | 60 ++ .../locale/de/LC_MESSAGES/django.po | 71 +++ .../locale/fa/LC_MESSAGES/django.po | 71 +++ .../locale/fr/LC_MESSAGES/django.po | 59 ++ .../locale/pl/LC_MESSAGES/django.po | 71 +++ dynamic_preferences/management/__init__.py | 1 + .../management/commands/__init__.py | 1 + .../management/commands/checkpreferences.py | 76 +++ dynamic_preferences/managers.py | 239 ++++++++ .../migrations/0001_initial.py | 50 ++ .../migrations/0002_auto_20150712_0332.py | 27 + .../migrations/0003_auto_20151223_1407.py | 33 ++ .../migrations/0004_move_user_model.py | 19 + .../migrations/0005_auto_20181120_0848.py | 31 ++ .../migrations/0006_auto_20191001_2236.py | 25 + dynamic_preferences/migrations/__init__.py | 0 dynamic_preferences/models.py | 135 +++++ dynamic_preferences/preferences.py | 104 ++++ dynamic_preferences/processors.py | 10 + dynamic_preferences/registries.py | 234 ++++++++ dynamic_preferences/serializers.py | 484 ++++++++++++++++ dynamic_preferences/settings.py | 70 +++ dynamic_preferences/signals.py | 4 + .../templates/dynamic_preferences/base.html | 15 + .../dynamic_preferences/dyna_change_form.html | 13 + .../templates/dynamic_preferences/form.html | 13 + .../dynamic_preferences/sections.html | 8 + .../dynamic_preferences/testcontext.html | 0 dynamic_preferences/types.py | 525 ++++++++++++++++++ dynamic_preferences/urls.py | 33 ++ dynamic_preferences/users/__init__.py | 0 dynamic_preferences/users/admin.py | 21 + dynamic_preferences/users/apps.py | 18 + dynamic_preferences/users/forms.py | 33 ++ .../users/migrations/0001_initial.py | 59 ++ .../migrations/0002_auto_20200821_0837.py | 35 ++ .../users/migrations/__init__.py | 0 dynamic_preferences/users/models.py | 15 + dynamic_preferences/users/registries.py | 8 + dynamic_preferences/users/serializers.py | 5 + dynamic_preferences/users/urls.py | 16 + dynamic_preferences/users/views.py | 18 + dynamic_preferences/users/viewsets.py | 15 + dynamic_preferences/utils.py | 18 + dynamic_preferences/views.py | 59 ++ entrypoint.sh | 6 + manage.py | 22 + requirements.txt | 85 +++ runner.sh | 12 + templates/admin/includes/fieldset.html | 95 ++++ .../admin/includes/object_delete_summary.html | 7 + templates/admin/index.html | 213 +++++++ templates/docs.html | 69 +++ templates/fields/json_editor_field.html | 38 ++ templates/fields/jsonlanguage_field.html | 38 ++ templates/name_finder.html | 93 ++++ utils/__init__.py | 234 ++++++++ utils/calculate_distance.py | 50 ++ utils/convert_currency.py | 70 +++ utils/exceptions.py | 31 ++ utils/json_editor_field.py | 30 + utils/keyval_field.py | 229 ++++++++ utils/pageless.py | 19 + utils/redis.py | 70 +++ utils/schema.py | 14 + utils/thumbail.py | 16 + utils/validators.py | 28 + 155 files changed, 8148 insertions(+) create mode 100644 .env.dev create mode 100644 .env.prod create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Dockerfile.prod create mode 100644 Jenkinsfile create mode 100644 README.md create mode 100644 apps/account/__init__.py create mode 100644 apps/account/admin/__init__.py create mode 100644 apps/account/admin/professor.py create mode 100644 apps/account/admin/student.py create mode 100644 apps/account/admin/user.py create mode 100644 apps/account/apps.py create mode 100644 apps/account/custom_user_login.py create mode 100644 apps/account/doc.py create mode 100644 apps/account/management/__init__.py create mode 100644 apps/account/management/commands/__init__,py create mode 100644 apps/account/management/commands/create_groups.py create mode 100644 apps/account/manager.py create mode 100644 apps/account/migrations/0001_initial.py create mode 100644 apps/account/migrations/0002_alter_user_birthdate.py create mode 100644 apps/account/migrations/0003_auto_20241120_1741.py create mode 100644 apps/account/migrations/__init__.py create mode 100644 apps/account/models/__init__.py create mode 100644 apps/account/models/groups.py create mode 100644 apps/account/models/user.py create mode 100644 apps/account/permissions.py create mode 100644 apps/account/serializers/__init__.py create mode 100644 apps/account/serializers/user.py create mode 100644 apps/account/tasks.py create mode 100644 apps/account/tests.py create mode 100644 apps/account/urls.py create mode 100644 apps/account/views/__init__.py create mode 100644 apps/account/views/user.py create mode 100644 apps/api/__init__.py create mode 100644 apps/api/admin.py create mode 100644 apps/api/apps.py create mode 100644 apps/api/models.py create mode 100644 apps/api/tests.py create mode 100644 apps/api/urls.py create mode 100644 apps/api/views.py create mode 100644 apps/course/__init__.py create mode 100644 apps/course/admin/__init__.py create mode 100644 apps/course/admin/course.py create mode 100644 apps/course/admin/lesson.py create mode 100644 apps/course/apps.py create mode 100644 apps/course/migrations/__init__.py create mode 100644 apps/course/models/__init__.py create mode 100644 apps/course/models/course.py create mode 100644 apps/course/models/lesson.py create mode 100644 apps/course/serializers/__init__.py create mode 100644 apps/course/serializers/course.py create mode 100644 apps/course/tests.py create mode 100644 apps/course/urls.py create mode 100644 apps/course/views/__init__.py create mode 100644 apps/course/views/course.py create mode 100644 apps/quiz/__init__.py create mode 100644 apps/quiz/admin.py create mode 100644 apps/quiz/apps.py create mode 100644 apps/quiz/migrations/__init__.py create mode 100644 apps/quiz/models/__init__.py create mode 100644 apps/quiz/models/participant.py create mode 100644 apps/quiz/models/quiz.py create mode 100644 apps/quiz/tests.py create mode 100644 apps/quiz/views.py create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/celery.py create mode 100644 config/language_code_middleware.py create mode 100644 config/redis_config.py create mode 100644 config/settings/__init__.py create mode 100644 config/settings/base.py create mode 100644 config/settings/develop.py create mode 100644 config/settings/production.py create mode 100644 config/test_auth_middleware.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 dynamic_preferences/__init__.py create mode 100644 dynamic_preferences/admin.py create mode 100644 dynamic_preferences/api/__init__.py create mode 100644 dynamic_preferences/api/serializers.py create mode 100644 dynamic_preferences/api/viewsets.py create mode 100644 dynamic_preferences/apps.py create mode 100644 dynamic_preferences/dynamic_preferences_registry.py create mode 100644 dynamic_preferences/exceptions.py create mode 100644 dynamic_preferences/forms.py create mode 100644 dynamic_preferences/locale/ar/LC_MESSAGES/django.po create mode 100644 dynamic_preferences/locale/de/LC_MESSAGES/django.po create mode 100644 dynamic_preferences/locale/fa/LC_MESSAGES/django.po create mode 100644 dynamic_preferences/locale/fr/LC_MESSAGES/django.po create mode 100644 dynamic_preferences/locale/pl/LC_MESSAGES/django.po create mode 100644 dynamic_preferences/management/__init__.py create mode 100644 dynamic_preferences/management/commands/__init__.py create mode 100644 dynamic_preferences/management/commands/checkpreferences.py create mode 100644 dynamic_preferences/managers.py create mode 100644 dynamic_preferences/migrations/0001_initial.py create mode 100644 dynamic_preferences/migrations/0002_auto_20150712_0332.py create mode 100644 dynamic_preferences/migrations/0003_auto_20151223_1407.py create mode 100644 dynamic_preferences/migrations/0004_move_user_model.py create mode 100644 dynamic_preferences/migrations/0005_auto_20181120_0848.py create mode 100644 dynamic_preferences/migrations/0006_auto_20191001_2236.py create mode 100644 dynamic_preferences/migrations/__init__.py create mode 100644 dynamic_preferences/models.py create mode 100644 dynamic_preferences/preferences.py create mode 100644 dynamic_preferences/processors.py create mode 100644 dynamic_preferences/registries.py create mode 100644 dynamic_preferences/serializers.py create mode 100644 dynamic_preferences/settings.py create mode 100644 dynamic_preferences/signals.py create mode 100644 dynamic_preferences/templates/dynamic_preferences/base.html create mode 100644 dynamic_preferences/templates/dynamic_preferences/dyna_change_form.html create mode 100644 dynamic_preferences/templates/dynamic_preferences/form.html create mode 100644 dynamic_preferences/templates/dynamic_preferences/sections.html create mode 100644 dynamic_preferences/templates/dynamic_preferences/testcontext.html create mode 100644 dynamic_preferences/types.py create mode 100644 dynamic_preferences/urls.py create mode 100644 dynamic_preferences/users/__init__.py create mode 100644 dynamic_preferences/users/admin.py create mode 100644 dynamic_preferences/users/apps.py create mode 100644 dynamic_preferences/users/forms.py create mode 100644 dynamic_preferences/users/migrations/0001_initial.py create mode 100644 dynamic_preferences/users/migrations/0002_auto_20200821_0837.py create mode 100644 dynamic_preferences/users/migrations/__init__.py create mode 100644 dynamic_preferences/users/models.py create mode 100644 dynamic_preferences/users/registries.py create mode 100644 dynamic_preferences/users/serializers.py create mode 100644 dynamic_preferences/users/urls.py create mode 100644 dynamic_preferences/users/views.py create mode 100644 dynamic_preferences/users/viewsets.py create mode 100644 dynamic_preferences/utils.py create mode 100644 dynamic_preferences/views.py create mode 100755 entrypoint.sh create mode 100755 manage.py create mode 100644 requirements.txt create mode 100644 runner.sh create mode 100644 templates/admin/includes/fieldset.html create mode 100644 templates/admin/includes/object_delete_summary.html create mode 100644 templates/admin/index.html create mode 100644 templates/docs.html create mode 100644 templates/fields/json_editor_field.html create mode 100644 templates/fields/jsonlanguage_field.html create mode 100644 templates/name_finder.html create mode 100644 utils/__init__.py create mode 100644 utils/calculate_distance.py create mode 100644 utils/convert_currency.py create mode 100644 utils/exceptions.py create mode 100644 utils/json_editor_field.py create mode 100644 utils/keyval_field.py create mode 100644 utils/pageless.py create mode 100644 utils/redis.py create mode 100644 utils/schema.py create mode 100644 utils/thumbail.py create mode 100644 utils/validators.py diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..025a936 --- /dev/null +++ b/.env.dev @@ -0,0 +1,19 @@ + +# DJANGO_ALLOWED_HOSTS=127.0.0.1,* +# DJANGO_SETTINGS_MODULE=config.settings.base + + +# #[database.POSTGRES] + +# POSTGRES_USER=postgres2 +# POSTGRES_DB=aquila +# POSTGRES_PASSWORD=admin +# POSTGRES_PORT=5432 +# POSTGRES_HOST=postgres +# DATABASE=aquila + + +# #[captcha] +# captcha_public_key="6LdkezEdAAAAAHFBxFSL6xJOYHxC66R274uVrqhC" +# captcha_private_key="6LdkezEdAAAAAMw997urKO6dOW8L223ql555KeaO" + diff --git a/.env.prod b/.env.prod new file mode 100644 index 0000000..2586f22 --- /dev/null +++ b/.env.prod @@ -0,0 +1,26 @@ +# DJANGO_ALLOWED_HOSTS=127.0.0.1,aqila.nwhco.ir,www.aqila.nwhco.ir,*.nwhco.ir,188.40.92.124,88.99.212.243 +# DJANGO_SETTINGS_MODULE=config.settings.production + + +# #[database.POSTGRES] +# POSTGRES_USER="pg-user" +# POSTGRES_DB="aqila" +# POSTGRES_PASSWORD="fdhd484fgsfddsdaf5@4df8g?90)(dfg78" +# POSTGRES_PORT="5432" +# POSTGRES_HOST="postgres" + + +# REDIS_URL=redis://aqila_redis:6379/0 +# # celery +# CELERY_BROKER=redis://aqila_redis:6379/0 +# CELERY_BACKEND=redis://aqila_redis:6379/0 +# FLOWER_UNAUTHENTICATED_API=true +# TIMEZONE="Asia/Tehran" +# CELERY_TIMEZONE="Asia/Tehran" + + +# #[captcha] +# captcha_public_key="6LdgCjseAAAAAIwg41-kyyulmwDtqD2Gk3THIwy2" +# captcha_private_key="6LdgCjseAAAAAPHMsIHuQgYAGTJ7_QlhqG4G0NyS" + +# FCM_API_KEY="" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24c8e96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,419 @@ + settings.json +# migrations/ +.DS_Store +local-cdn/ +# .env-dev +# .env-prod +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +static/ +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +# In the name of Allah +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +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: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# dotenv +.env + +# virtualenv +.venv +venv/ +ENV/ +.vscode +.idea + +*.mp4 +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +.DS_Store +*.sqlite3 +media/ +*.pyc +*.db +*.pid + +# Ignore Django Migrations in Development if you are working on team + +#Only for Development only +#**/migrations/** +#!**/migrations/__init__.py + +#comment migrations ignorance bcz we need it to be exist + + +#server gitignore +passenger_wsgi.py +.htaccess +static/uploads/ +static/quran_audios +tmp/ +Pipfile.lock +quran-pages-audios/*.zip +quran.sql +tafsir.sql +output_file.sql + +src +calendar.json +apps/mafatih/data/mafatih_indonesia/*.json +apps/mafatih/data/mafatih_indonesia/1 +apps/mafatih/data/Germany Duas/*.xlsx +!apps/mafatih/data/mafatih_indonesia/final_jun_11.json +volumes/ + +apps/mafatih/data/*.json +apps/ahkam/data/*.json +!apps/ahkam/data/makarem_fa_data.json + +mediafiles/* +wabot/ +Sabeel Media Content/ + + +*.lock +*.toml +# 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/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +# # .env +# .venv +# # env/ +# venv/ +# ENV/ +# env.bak/ +# venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# 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 +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +# .env +# .env.development.local +# .env.test.local +# .env.production.local +# .env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f0b0c6b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# pull official base image +FROM python:3.9 + +# set work directory +WORKDIR /usr/src/app + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +RUN apt-get update +# RUN apt-get install -y vim +# RUN apt-get install -y ffmpeg +# RUN apt-get install -y cron +# install dependencies +RUN pip install --upgrade pip + +COPY ./requirements.txt . +COPY .env.dev .env + +RUN --mount=type=cache,target=/root/.cache pip install -r requirements.txt + +# copy entrypoint.sh +COPY ./entrypoint.sh . +RUN sed -i 's/\r$//g' /usr/src/app/entrypoint.sh +RUN chmod +x /usr/src/app/entrypoint.sh + +# copy project +COPY . . + +# run entrypoint.sh +# ENTRYPOINT ["/usr/src/app/entrypoint.sh"] diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..f814ce5 --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,58 @@ +# pull official base image +FROM python:3.9-alpine + +# set work directory +WORKDIR /usr/src/app + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# install psycopg2 dependencies +RUN apk update && apk add --no-cache \ + git \ + wget \ + unzip \ + curl \ + postgresql-dev \ + gcc \ + python3-dev \ + musl-dev \ + jpeg-dev \ + zlib-dev \ + freetype-dev \ + gnupg \ + chromium \ + chromium-chromedriver \ + harfbuzz \ + nss \ + freetype \ + ttf-freefont \ + mesa-gl \ + alsa-lib + + +# Set environment variables for Chrome +ENV CHROME_BIN=/usr/bin/chromium-browser +ENV CHROME_DRIVER=/usr/bin/chromedriver + +# install dependencies +RUN pip install --upgrade pip +#RUN python -m pip install Pillow + +COPY ./requirements.txt . +COPY .env.prod .env + +RUN --mount=type=cache,target=/root/.cache pip install -r requirements.txt + +# copy entrypoint.sh +COPY ./entrypoint.sh . +RUN sed -i 's/\r$//g' /usr/src/app/entrypoint.sh +RUN chmod +x /usr/src/app/entrypoint.sh + +# copy project +COPY . . +# Set display port to avoid crash +ENV DISPLAY=:99 +# run entrypoint.sh +ENTRYPOINT ["/usr/src/app/entrypoint.sh"] \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..c046c2b --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,35 @@ +pipeline { + environment { + develop_server_ip = '' + develop_server_name = '' + production_server_ip = "88.99.212.243" + production_server_name = "newhorizon_germany_001_server" + project_path = "/projects/imam-javad/imam-javad_backend" + version = "master" + gitBranch = "origin/master" + } + agent any + stages { + stage('deploy'){ + steps{ + script{ + if(gitBranch=="origin/master"){ + withCredentials([usernamePassword(credentialsId: production_server_name, usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD')]) { + sh 'sshpass -p $PASSWORD ssh -p 1782 $USERNAME@$production_server_ip -o StrictHostKeyChecking=no "cd $project_path && ./runner.sh"' + + def lastCommit = sh(script: 'git log -1 --pretty=format:"%h - %s (%an)"', returnStdout: true).trim() + sh """ + curl -F chat_id=1457670318 \ + -F message_thread_id=6 \ + -F document=@/var/jenkins_home/jobs/${env.JOB_NAME}/builds/${env.BUILD_NUMBER}/log \ + -F caption='Project name: #${env.JOB_NAME} \nBuild status is ${currentBuild.currentResult} \nBuild url: ${BUILD_URL} \nLast Commit: ${lastCommit}' \ + https://api.telegram.org/bot7207581748:AAFeymryw7S44D86LYfWqYK-tSNeV3TOwBs/sendDocument + """ + } + } + } + } + } + } +} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/__init__.py b/apps/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/admin/__init__.py b/apps/account/admin/__init__.py new file mode 100644 index 0000000..414639d --- /dev/null +++ b/apps/account/admin/__init__.py @@ -0,0 +1,4 @@ + +from .user import * +from .professor import * +from .student import * \ No newline at end of file diff --git a/apps/account/admin/professor.py b/apps/account/admin/professor.py new file mode 100644 index 0000000..583293e --- /dev/null +++ b/apps/account/admin/professor.py @@ -0,0 +1,56 @@ +from django.contrib import admin +from django.contrib.auth.forms import UserChangeForm, UsernameField +from django.contrib.auth.admin import UserAdmin +from django.utils.translation import gettext_lazy as _ +from rest_framework.authtoken.models import TokenProxy +from ajaxdatatable.admin import AjaxDatatable + +from django.contrib import admin +from apps.account.models import User +from django import forms +from django.contrib import admin +from django.urls import path, reverse +from django.shortcuts import render, redirect +from django.contrib import messages + +from apps.account.models import ProfessorUser + + + +@admin.register(ProfessorUser) +class ProfessorUserAdmin(UserAdmin, AjaxDatatable): + list_display = ( + 'email', 'fullname', 'user_type','last_login', 'date_joined', + ) + ordering = 'last_login', + readonly_fields = ('date_joined',) + exclude = ('password', 'user_permissions') + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password1', 'password2'), + }), + ) + search_fields = ( + 'email', 'fullname', 'username', + ) + fieldsets = ( + (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}), + (_('Permissions'), { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'password'), + }), + (_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}), + ) + + def save_model(self, request, obj, form, change): + if not change: + obj.set_password(form.cleaned_data['password1']) + obj.user_type = User.UserType.PROFESSOR + super().save_model(request, obj, form, change) + + @admin.display(description='Phone Number') + def _phone_number(self, obj): + return obj.phone_number + + +# admin.site.unregister(TokenProxy) diff --git a/apps/account/admin/student.py b/apps/account/admin/student.py new file mode 100644 index 0000000..2b1cf11 --- /dev/null +++ b/apps/account/admin/student.py @@ -0,0 +1,56 @@ +from django.contrib import admin +from django.contrib.auth.forms import UserChangeForm, UsernameField +from django.contrib.auth.admin import UserAdmin +from django.utils.translation import gettext_lazy as _ +from rest_framework.authtoken.models import TokenProxy +from ajaxdatatable.admin import AjaxDatatable + +from django.contrib import admin +from apps.account.models import User +from django import forms +from django.contrib import admin +from django.urls import path, reverse +from django.shortcuts import render, redirect +from django.contrib import messages + +from apps.account.models import StudentUser + + + +@admin.register(StudentUser) +class StudentUserAdmin(UserAdmin, AjaxDatatable): + list_display = ( + 'email', 'fullname', 'user_type','last_login', 'date_joined', + ) + ordering = 'last_login', + readonly_fields = ('date_joined',) + exclude = ('password', 'user_permissions') + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password1', 'password2'), + }), + ) + search_fields = ( + 'email', 'fullname', 'username', + ) + fieldsets = ( + (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}), + (_('Permissions'), { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'password'), + }), + (_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}), + ) + + def save_model(self, request, obj, form, change): + if not change: + obj.set_password(form.cleaned_data['password1']) + obj.user_type = User.UserType.PROFESSOR + super().save_model(request, obj, form, change) + + @admin.display(description='Phone Number') + def _phone_number(self, obj): + return obj.phone_number + + +# admin.site.unregister(TokenProxy) diff --git a/apps/account/admin/user.py b/apps/account/admin/user.py new file mode 100644 index 0000000..0f43854 --- /dev/null +++ b/apps/account/admin/user.py @@ -0,0 +1,94 @@ +from django.contrib import admin +from django.contrib.auth.forms import UserChangeForm, UsernameField +from django.contrib.auth.admin import UserAdmin +from django.utils.translation import gettext_lazy as _ +from rest_framework.authtoken.models import TokenProxy +from ajaxdatatable.admin import AjaxDatatable + +from apps.account.models import User +from django import forms +from django.contrib import admin +from django.urls import path, reverse +from django.shortcuts import render, redirect +from django.contrib import messages + +from apps.account.models import ClientUser, AdminUser + + + +@admin.register(User) +class UserAdmin(UserAdmin, AjaxDatatable): + list_display = ( + 'email', 'fullname', 'user_type','last_login', 'date_joined', + ) + ordering = 'last_login', + readonly_fields = ('date_joined',) + exclude = ('password', 'user_permissions') + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password1', 'password2'), + }), + ) + search_fields = ( + 'email', 'fullname', 'username', + ) + fieldsets = ( + (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}), + (_('Permissions'), { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'password'), + }), + (_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}), + ) + + def save_model(self, request, obj, form, change): + if not change: + obj.set_password(form.cleaned_data['password1']) + + # obj.user_type = User.UserType.CLIENT + super().save_model(request, obj, form, change) + + @admin.display(description='Phone Number') + def _phone_number(self, obj): + return obj.phone_number + + + + +@admin.register(AdminUser) +class AdminUserAdmin(UserAdmin, AjaxDatatable): + list_display = ( + 'email', 'fullname', 'user_type','last_login', 'date_joined', + ) + ordering = 'last_login', + readonly_fields = ('date_joined',) + exclude = ('password', 'user_permissions') + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password1', 'password2'), + }), + ) + search_fields = ( + 'email', 'fullname', 'username', + ) + fieldsets = ( + (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}), + (_('Permissions'), { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'password'), + }), + (_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}), + ) + + def save_model(self, request, obj, form, change): + if not change: + obj.set_password(form.cleaned_data['password1']) + + # obj.user_type = User.UserType.CLIENT + super().save_model(request, obj, form, change) + + @admin.display(description='Phone Number') + def _phone_number(self, obj): + return obj.phone_number + +admin.site.unregister(TokenProxy) diff --git a/apps/account/apps.py b/apps/account/apps.py new file mode 100644 index 0000000..1a13bc5 --- /dev/null +++ b/apps/account/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AccountConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.account' + icon = 'mi-person' \ No newline at end of file diff --git a/apps/account/custom_user_login.py b/apps/account/custom_user_login.py new file mode 100644 index 0000000..2a41354 --- /dev/null +++ b/apps/account/custom_user_login.py @@ -0,0 +1,25 @@ +from django.contrib.auth.backends import BaseBackend +from django.db.models import Q + +from apps.account.models import User +from utils.exceptions import UserNotFoundException +from rest_framework.exceptions import AuthenticationFailed + + +class CustomLoginBackend(BaseBackend): + """ + Authenticate with username email and phone_number. + """ + + def authenticate(self, request, username=None, password=None): + if user := self.get_user(username): + if user.check_password(password): + return user + + return None + + def get_user(self, username): + try: + return User.objects.filter(Q(email=username) | Q(phone_number=username)).first() + except Exception.DoesNotExist: + return None diff --git a/apps/account/doc.py b/apps/account/doc.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/management/__init__.py b/apps/account/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/management/commands/__init__,py b/apps/account/management/commands/__init__,py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/management/commands/create_groups.py b/apps/account/management/commands/create_groups.py new file mode 100644 index 0000000..2bec3c9 --- /dev/null +++ b/apps/account/management/commands/create_groups.py @@ -0,0 +1,52 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType + +from apps.account.models import User + + + + +class Command(BaseCommand): + help = 'Create default groups and assign permissions to them' + + def handle(self, *args, **kwargs): + # تعریف گروه‌ها و پرمیشن‌ها + groups_permissions = { + "Professor Group": [ + "view_user", "add_user", "change_user" + ], + "Client Group": [ + "view_user" + ], + "Admin Group": [ + "view_user", "add_user", "change_user", "delete_user" + ], + "Super Admin Group": [ + "view_user", "add_user", "change_user", "delete_user", "manage_permissions" + ], + "Student Group": [ + "view_user" + ] + } + + content_type = ContentType.objects.get_for_model(User) + + for group_name, permissions in groups_permissions.items(): + group, created = Group.objects.get_or_create(name=group_name) + if created: + self.stdout.write(self.style.SUCCESS(f"Group '{group_name}' created successfully.")) + else: + self.stdout.write(self.style.WARNING(f"Group '{group_name}' already exists.")) + + for perm_codename in permissions: + permission, created = Permission.objects.get_or_create( + codename=perm_codename, + defaults={ + 'name': f"Can {perm_codename.replace('_', ' ')} User", + 'content_type': content_type + } + ) + group.permissions.add(permission) + + self.stdout.write(self.style.SUCCESS("All groups and permissions have been created successfully.")) diff --git a/apps/account/manager.py b/apps/account/manager.py new file mode 100644 index 0000000..db93a79 --- /dev/null +++ b/apps/account/manager.py @@ -0,0 +1,83 @@ + +from django.contrib.auth.models import BaseUserManager + + +from django.db.models import Manager + + + +class UserManager(BaseUserManager): + + def create_user( + self, + email: str = None, + fullname: str = None, + password: str = None, + **extra_fields + ): + email = UserManager.normalize_email(email) + user = self.model( + email=email, + fullname=fullname, + **extra_fields + ) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email, fullname, password): + user = self.create_user( + email=email, + fullname=fullname, + password=password, + ) + user.is_admin = True + user.is_staff = True + user.is_superuser = True + user.is_active = True + user.user_type="super_admin" + user.save(using=self._db) + return user + + + def change_user_type(self, new_user_type): + # حذف گروه‌های فعلی + old_group_name = f"{self.user_type.capitalize()} Group" + old_group = Group.objects.filter(name=old_group_name).first() + if old_group: + self.groups.remove(old_group) + + # تغییر نوع کاربر + self.user_type = new_user_type + + # افزودن گروه جدید + new_group_name = f"{new_user_type.capitalize()} Group" + new_group, _ = Group.objects.get_or_create(name=new_group_name) + self.groups.add(new_group) + + # ذخیره تغییرات + self.save() + + + +class ProfessorUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="professor") + + +class ClientUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="client") + +class AdminUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="admin") + + +class SuperAdminUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="super_admin") + +class StudentUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="student") diff --git a/apps/account/migrations/0001_initial.py b/apps/account/migrations/0001_initial.py new file mode 100644 index 0000000..ea3b60c --- /dev/null +++ b/apps/account/migrations/0001_initial.py @@ -0,0 +1,116 @@ +# Generated by Django 3.2.4 on 2024-11-19 08:43 + +import dj_language.field +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields +import utils.validators + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dj_language', '0002_auto_20220120_1344'), + ('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(help_text="Enter the user's email address.", max_length=254, unique=True, verbose_name='Email Address')), + ('fullname', models.CharField(help_text='Enter the full name of the user.', max_length=255, verbose_name='Full Name')), + ('birthdate', models.DateField(verbose_name='birthdate')), + ('avatar', models.ImageField(blank=True, null=True, upload_to='users/avatars/%Y/%m/')), + ('phone_number', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, unique=True, validators=[utils.validators.validate_possible_number], verbose_name='phone')), + ('gender', models.CharField(blank=True, choices=[('male', 'Male'), ('female', 'Female')], help_text="Select the user's gender.", max_length=20, null=True, verbose_name='Gender')), + ('user_type', models.CharField(choices=[('professor', 'Professor'), ('client', 'Client'), ('student', 'Student'), ('admin', 'Admin'), ('super_admin', 'Super Admin')], default='client', help_text='Type of the user.', max_length=20, verbose_name='User Type')), + ('device_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='device id')), + ('fcm', models.CharField(blank=True, max_length=512, null=True)), + ('date_joined', models.DateTimeField(auto_now_add=True, help_text='The date and time the user registered.', verbose_name='Date Joined')), + ('is_staff', models.BooleanField(default=False)), + ('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')), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('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')), + ('language', dj_language.field.LanguageField(default=69, limit_choices_to={'status': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dj_language.language', verbose_name='language')), + ('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', + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='AdminUser', + fields=[ + ], + options={ + 'verbose_name': 'Admin User', + 'verbose_name_plural': 'Admin Users', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='ClientUser', + fields=[ + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'ordering': ('-id',), + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='ProfessorUser', + fields=[ + ], + options={ + 'verbose_name': 'Professor User', + 'verbose_name_plural': 'Professor Users', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='StudentUser', + fields=[ + ], + options={ + 'verbose_name': 'Student User', + 'verbose_name_plural': 'Student Users', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='SuperAdminUser', + fields=[ + ], + options={ + 'verbose_name': 'Super Admin User', + 'verbose_name_plural': 'Super Admin Users', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + ] diff --git a/apps/account/migrations/0002_alter_user_birthdate.py b/apps/account/migrations/0002_alter_user_birthdate.py new file mode 100644 index 0000000..0e0350f --- /dev/null +++ b/apps/account/migrations/0002_alter_user_birthdate.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2024-11-19 08:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='birthdate', + field=models.DateField(blank=True, null=True, verbose_name='birthdate'), + ), + ] diff --git a/apps/account/migrations/0003_auto_20241120_1741.py b/apps/account/migrations/0003_auto_20241120_1741.py new file mode 100644 index 0000000..fd0f54b --- /dev/null +++ b/apps/account/migrations/0003_auto_20241120_1741.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.4 on 2024-11-20 17:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0002_alter_user_birthdate'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={'ordering': ('-id',), 'verbose_name': 'All Users', 'verbose_name_plural': 'All Users'}, + ), + migrations.AddField( + model_name='user', + name='info', + field=models.TextField(blank=True, null=True, verbose_name='Info'), + ), + ] diff --git a/apps/account/migrations/__init__.py b/apps/account/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/models/__init__.py b/apps/account/models/__init__.py new file mode 100644 index 0000000..6876ad1 --- /dev/null +++ b/apps/account/models/__init__.py @@ -0,0 +1,4 @@ + + +from .user import * +from .groups import * \ No newline at end of file diff --git a/apps/account/models/groups.py b/apps/account/models/groups.py new file mode 100644 index 0000000..3831644 --- /dev/null +++ b/apps/account/models/groups.py @@ -0,0 +1,95 @@ +from apps.account.models import User +from apps.account.manager import * + +from django.contrib.auth.models import Group + + + + + +class ProfessorUser(User): + objects = ProfessorUserManager() + + def save(self, *args, **kwargs): + self.user_type = User.UserType.PROFESSOR + super().save(*args, **kwargs) + + group, _ = Group.objects.get_or_create(name="Professor Group") + self.groups.add(group) + + class Meta: + proxy = True + verbose_name = "Professor User" + verbose_name_plural = "Professor Users" + + + + +class ClientUser(User): + objects = ClientUserManager() + + def save(self, *args, **kwargs): + self.user_type = User.UserType.CLIENT + super().save(*args, **kwargs) + + group, _ = Group.objects.get_or_create(name="Client Group") + self.groups.add(group) + + + class Meta: + proxy = True + + verbose_name = 'user' + verbose_name_plural = 'users' + ordering = ('-id',) + + + +class AdminUser(User): + objects = AdminUserManager() + + def save(self, *args, **kwargs): + self.user_type = User.UserType.ADMIN + super().save(*args, **kwargs) + + group, _ = Group.objects.get_or_create(name="Admin Group") + self.groups.add(group) + + class Meta: + proxy = True + verbose_name = "Admin User" + verbose_name_plural = "Admin Users" + + + +class SuperAdminUser(User): + objects = SuperAdminUserManager() + + def save(self, *args, **kwargs): + self.user_type = User.UserType.SUPER_ADMIN + self.is_staff = True + super().save(*args, **kwargs) + + + + class Meta: + proxy = True + verbose_name = "Super Admin User" + verbose_name_plural = "Super Admin Users" + + + +class StudentUser(User): + objects = StudentUserManager() + + def save(self, *args, **kwargs): + self.user_type = User.UserType.STUDENT + super().save(*args, **kwargs) + + group, _ = Group.objects.get_or_create(name="Student Group") + self.groups.add(group) + + class Meta: + proxy = True + verbose_name = "Student User" + verbose_name_plural = "Student Users" \ No newline at end of file diff --git a/apps/account/models/user.py b/apps/account/models/user.py new file mode 100644 index 0000000..0015041 --- /dev/null +++ b/apps/account/models/user.py @@ -0,0 +1,84 @@ +import random +from dj_language.field import LanguageField +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone +from phonenumber_field.modelfields import PhoneNumberField +from utils.validators import validate_possible_number +from apps.account.manager import UserManager + + + +class User(AbstractUser): + class UserType(models.TextChoices): + PROFESSOR = 'professor', 'Professor' + CLIENT = 'client', 'Client' + STUDENT = 'student', "Student" + ADMIN = 'admin', 'Admin' + SUPER_ADMIN = 'super_admin', 'Super Admin' + + class GenderChoices(models.TextChoices): + MALE = 'male', 'Male' + FEMALE = 'female', 'Female' + + email = models.EmailField(unique=True, verbose_name="Email Address", help_text="Enter the user's email address.") + fullname = models.CharField(max_length=255, verbose_name="Full Name", help_text="Enter the full name of the user.") + birthdate = models.DateField(verbose_name=_('birthdate'), null=True, blank=True) + + avatar = models.ImageField(null=True, blank=True, upload_to='users/avatars/%Y/%m/') + phone_number = PhoneNumberField(unique=True, validators=[validate_possible_number], null=True, blank=True, verbose_name=_('phone')) + language = LanguageField(null=True) + username = None + last_name = None + first_name = None + gender = models.CharField( + max_length=20, choices=GenderChoices.choices, null=True, blank=True, verbose_name=_('Gender'), help_text="Select the user's gender." + ) + user_type = models.CharField( + max_length=20, + choices=UserType.choices, + default=UserType.CLIENT, + verbose_name="User Type", + help_text="Type of the user." + ) + device_id = models.CharField(verbose_name=_('device id'), max_length=255, null=True, blank=True) + fcm = models.CharField(max_length=512, null=True, blank=True) + date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Date Joined", help_text="The date and time the user registered.") + is_staff = models.BooleanField(default=False) + is_active = models.BooleanField(default=True, verbose_name="Active", help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.") + deleted_at = models.DateTimeField(null=True, blank=True) + info = models.TextField(verbose_name="Info", null=True, blank=True) + objects = UserManager() + + + EMAIL_FIELD = "email" + USERNAME_FIELD = "email" + REQUIRED_FIELDS = ["fullname", ] + + def soft_delete(self): + self.deleted_at = timezone.now() + self.is_active = False + number = str(random.randint(1000000000, 9999999999)) # ایجاد یک عدد رندوم 10 رقمی + self.phone_number = f'{self.phone_number}:deleted{number}' + self.email = f'{self.email}:deleted{number}' if self.email else None + self.save() + + # def clean(self): + # super().clean() + # if self.email == "": + # # fix db uniqueness error bcz of django charfield null to empty string conversion + # self.email = None + + def __str__(self): + return f"{self.email} - {self.get_full_name()}" + + + def get_full_name(self): + return self.fullname + + + class Meta: + ordering = ("-id",) + verbose_name = "All Users" + verbose_name_plural = "All Users" diff --git a/apps/account/permissions.py b/apps/account/permissions.py new file mode 100644 index 0000000..65616b5 --- /dev/null +++ b/apps/account/permissions.py @@ -0,0 +1,12 @@ + + + + + +from rest_framework.permissions import BasePermission + + +class IsActiveUser(BasePermission): + + def has_permission(self, request, view): + return request.user and request.user.is_active \ No newline at end of file diff --git a/apps/account/serializers/__init__.py b/apps/account/serializers/__init__.py new file mode 100644 index 0000000..7e669dd --- /dev/null +++ b/apps/account/serializers/__init__.py @@ -0,0 +1,2 @@ + +from .user import * diff --git a/apps/account/serializers/user.py b/apps/account/serializers/user.py new file mode 100644 index 0000000..34aaf63 --- /dev/null +++ b/apps/account/serializers/user.py @@ -0,0 +1,152 @@ + +from rest_framework import serializers +from rest_framework.authtoken.models import Token +from django.contrib.auth.password_validation import validate_password +from django.utils.translation import gettext_lazy as _ +from apps.account.models import User +from utils import FileFieldSerializer, absolute_url +from utils.validators import validate_type_code + + + +class UserProfileSerializer(serializers.ModelSerializer): + avatar = FileFieldSerializer(required=False) + password = serializers.CharField(write_only=True, required=False, validators=[validate_password]) + fullname = serializers.CharField(required=False) + class Meta: + model = User + fields = ['id', 'fullname', 'avatar', 'email', 'phone_number', 'password', 'info'] + read_only_fields = ['email', 'info'] + + # def validate_email(self, value): + # if User.objects.filter(email=value).exists(): + # raise serializers.ValidationError("This email is already registered.") + # return value + + def update(self, instance, validated_data): + password = validated_data.pop('password', None) + if password: + instance.set_password(password) + # Update other fields + for attr, value in validated_data.items(): + if value is not None: + setattr(instance, attr, value) + + instance.save() + return instance + + +class UserRegisterSerializer(serializers.ModelSerializer): + password_confirmation = serializers.CharField(write_only=True) + fcm = serializers.CharField(required=False) + device_id = serializers.CharField(required=False) + email = serializers.EmailField() + + class Meta: + model = User + fields = ['id','fullname', 'email', 'password', 'password_confirmation', 'fcm', 'device_id'] + extra_kwargs = { + 'fullname': {'required': True,}, + 'email': {'required': True,}, + 'password': {'required': True,}, + 'password_confirmation': {'required': True,}, + } + + def validate_email(self, value): + if User.objects.filter(email=value).exists(): + raise serializers.ValidationError("This email is already registered.") + return value + + + def validate(self, data): + password = data.get('password') + password_confirmation = data.get('password_confirmation') + if password and password_confirmation and password != password_confirmation: + raise serializers.ValidationError("Passwords do not match.") + if len(password) < 8: + raise serializers.ValidationError("Password must be at least 8 characters long.") + + data.pop('password_confirmation', None) + data.pop('fcm', None) + data.pop('device_id', None) + return data + + + +class UserVerifySerializer(serializers.ModelSerializer): + code = serializers.CharField(max_length=5, validators=[validate_type_code]) + email = serializers.EmailField() + + class Meta: + model = User + fields = ["email", "code"] + extra_kwargs = { + 'email': {'required': True,}, + 'code': {'required': True,}, + } + + +class UserLoginSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + token = serializers.CharField(allow_null=True, read_only=True, required=False) + fullname = serializers.CharField(allow_null=True, read_only=True, required=False) + avatar = serializers.CharField(allow_null=True, read_only=True, required=False) + email = serializers.EmailField(write_only=True) + password = serializers.CharField(style={'input_type': 'password'}, trim_whitespace=False) + fcm = serializers.CharField(required=False) + device_id = serializers.CharField(required=False) + + + class Meta: + model = User + fields = ['id', 'phone_number', 'password', 'fullname', 'avatar', 'email', 'token', 'fcm', 'device_id'] + + def get_token(self, obj): + token, created = Token.objects.get_or_create(user=obj) + return token.key + + def validate(self, data): + data.pop('fcm', None) + data.pop('device_id', None) + return data + + + + +class UserRecoverPasswordSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + + class Meta: + model = User + fields = ['email',] + extra_kwargs = { + 'email': {'required': True,}, + } + + +class UserResetPasswordSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + password_confirmation = serializers.CharField(write_only=True) + + class Meta: + model = User + fields = ['password', 'password_confirmation'] + extra_kwargs = { + 'password': {'required': True,}, + 'password_confirmation': {'required': True,}, + } + + + def validate(self, data): + password = data.get('password') + password_confirmation = data.get('password_confirmation') + if password and password_confirmation and password != password_confirmation: + raise serializers.ValidationError("Passwords do not match.") + if len(password) < 8: + raise serializers.ValidationError("Password must be at least 8 characters long.") + + data.pop('password_confirmation', None) + + return data + + diff --git a/apps/account/tasks.py b/apps/account/tasks.py new file mode 100644 index 0000000..40217de --- /dev/null +++ b/apps/account/tasks.py @@ -0,0 +1,61 @@ +import time +from config.settings import base as settings + +from celery import shared_task +import requests +import json + +@shared_task +def send_otp_code(phone_number, code): + BASE_URL_SERVICE = "https://console.melipayamak.com/api/send/simple/" + + phone_number = str(phone_number) + code = str(code) + print(code) + data = {'from': '50004001410202', 'to': phone_number, 'text': code} + response = requests.post(f'{BASE_URL_SERVICE}{settings.OTP_SERIVCE_KEY}', + json=data) + + print(response.json()) + + +def send_otp_code_whatsapp(phone_number, code): + phone = phone_number + if phone.startswith('0'): + phone = phone[1:] + phone = '98' + phone + + urls = [ + "https://7103.api.greenapi.com/waInstance7103107557/sendMessage/dcc7cc469e274389aa3ea4d6dae9d4d126b8b07a09be41c28e", + "https://7103.api.greenapi.com/waInstance7103109151/sendMessage/ed9cbea884cc49fd8032862f1bceca2074f373540dca483382", + "https://7103.api.greenapi.com/waInstance7103109158/sendMessage/92d032caca1541799a4623cfcc86f449ea7f3205b30848eeab", + "https://7103.api.greenapi.com/waInstance7103109163/sendMessage/d31a08b5816c432daa6e256e181274d1d334e4256d3c4555a7", + + ] + payload = { + "chatId": f"{phone}@c.us", + "message": f"Habib App --aqila-- {code}" + } + headers = { + 'Content-Type': 'application/json' + } + + for url in urls: + response = requests.request("POST", url=url, headers=headers, data=json.dumps(payload)) + response.encoding = 'utf-8' + response_data = response.json() + + invoke_status = response_data.get('invokeStatus', {}) + status = invoke_status.get('status', '') + + print(f'>>>>>>>> {response_data}') + print(f"Response: {status}") + + if status != "QUOTE_ALLOWED": + print("OTP sent successfully.") + break + else: + print("QUOTE_ALLOWED error, trying next URL...") + time.sleep(2) + + diff --git a/apps/account/tests.py b/apps/account/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/account/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/account/urls.py b/apps/account/urls.py new file mode 100644 index 0000000..9f3229d --- /dev/null +++ b/apps/account/urls.py @@ -0,0 +1,35 @@ + +from django.urls import path, include + +from rest_framework.routers import DefaultRouter + +from apps.account import views + + + +urlpatterns = [ + # URL for user registration, accepts POST requests for creating new user instances. + + path('register/', views.UserRegisterView.as_view(), name='user-register'), + path('verify/', views.UserVerifyView.as_view(), name='user-verify'), + path('login/', views.UserLoginView.as_view(), name='user-login'), + + + # path('notif/', views.NotificationListView.as_view(), name='user-notif'), + # path('notif/read', views.NotificationReadAllView.as_view(), name='user-notif-read-all'), + + + # # URL to get user details, supports GET for fetching user profile based on the provided token. + path('profile/', views.UserProfileView.as_view(), name='user-profile'), + + path('recover/', views.UserRecoverPassword.as_view(), name='user-recover'), + path('reset/', views.UserResetPassword.as_view(), name='user-reset'), + + + # # URL to update user details, supports PUT to update user fields like phone or email given a token. + path('profile/update/', views.UserUpdateView.as_view(), name='user-update'), + + # # delete user account + path('profile/delete/', views.UserDeleteView.as_view(), name='user-delete'), + +] \ No newline at end of file diff --git a/apps/account/views/__init__.py b/apps/account/views/__init__.py new file mode 100644 index 0000000..f4a2da0 --- /dev/null +++ b/apps/account/views/__init__.py @@ -0,0 +1 @@ +from .user import * diff --git a/apps/account/views/user.py b/apps/account/views/user.py new file mode 100644 index 0000000..54b2540 --- /dev/null +++ b/apps/account/views/user.py @@ -0,0 +1,238 @@ +import logging +import requests +import json +from rest_framework.generics import CreateAPIView, RetrieveUpdateAPIView, GenericAPIView, RetrieveAPIView, UpdateAPIView, ListAPIView +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.authtoken.models import Token +from rest_framework.exceptions import AuthenticationFailed +from django.utils.translation import gettext_lazy as _ +from django.shortcuts import get_object_or_404 +from rest_framework.authtoken.models import Token + +from django.utils import timezone +from rest_framework.authentication import TokenAuthentication +from django.contrib.auth import authenticate +from phonenumbers import parse, region_code_for_number +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from utils.exceptions import InvaliedCodeVrify, ExpiredCodeException, ServiceUnavailableException +from apps.account.models import User +from apps.account.serializers import UserRegisterSerializer, UserProfileSerializer, UserVerifySerializer, UserLoginSerializer, UserRecoverPasswordSerializer, UserResetPasswordSerializer +from utils.redis import RedisManager +from utils import send_email, is_valid_email +from config.settings import base as settings +from apps.account.permissions import IsActiveUser + +logger = logging.getLogger(__name__) + + + + + +class UserRegisterView(CreateAPIView): + permission_classes = [AllowAny] + serializer_class = UserRegisterSerializer + + + @swagger_auto_schema( + request_body=UserRegisterSerializer, + responses={201: 'User registered successfully', 400: 'Bad request'} + ) + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + + code = RedisManager.generate_otp_code() + logger.info(f"phone= {data['email']}") + print(f' send {code}/{data["email"]}') + phone_number = RedisManager().add_to_redis(code, **data) + + send_email([data['email']], code) + password = data.pop('password') + return Response( + data= { + "user": data, + "message": "The otp code was sent to the user's email" + }, + status=status.HTTP_202_ACCEPTED, + ) + + + +class UserVerifyView(CreateAPIView): + permission_classes = [AllowAny] + serializer_class = UserVerifySerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + try: + verify_data = RedisManager().get_by_redis(data['email']) + if not verify_data: + raise ExpiredCodeException("Verification data not found or expired.") + except (ServiceUnavailableException) as e: + return Response({"detail": str(e)}, status=e.status_code) + except ExpiredCodeException: + raise ExpiredCodeException("The verification code has expired.") + + + code = self.valied_code(data['code'], verify_data['code']) + del verify_data['code'] + user = self.perform_create( + email=serializer.data['email'],**verify_data + ) + Token.objects.filter(user=user).delete() + token = Token.objects.create(user=user) + return Response(data={ + 'token': str(token), + 'user_id': user.id, + 'phone_number': str(user.phone_number), + 'email': str(user.email), + 'fullname': str(user.fullname), + 'avatar': str(user.avatar) if user.avatar else None + }, status=status.HTTP_201_CREATED) + + def valied_code(self, current_code, save_code): + if (current_code and save_code) and ( current_code != save_code): + raise InvaliedCodeVrify() + return current_code + + def perform_create(self, *args, **kwargs): + email = kwargs.get('email') + user = User.objects.filter(email=email).first() + if user: + if kwargs['password']: + user.is_active = True + user.deletion_date = None + user.last_login = timezone.now() + user.set_password(kwargs['password']) + user.save() + else: + user = User.objects.create(**kwargs) + user.set_password(kwargs['password']) + user.last_login = timezone.now() + user.is_active = True + user.save() + + return user + + +class UserLoginView(CreateAPIView): + permission_classes = [AllowAny] + serializer_class = UserLoginSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + user = authenticate(request, username=request.data['email'], password=data['password']) + if not user: + raise AuthenticationFailed(_('Unable to log in with provided credentials.')) + user.last_login = timezone.now() + user.is_active = True + user.save + token, created = Token.objects.get_or_create(user=user) + serializer_data = serializer.data + serializer_data['token'] = token.key + + return Response({ + "id": user.id, + "fullname": user.fullname, + "email": user.email, + "token": token.key, + "avatar": request.build_absolute_uri(user.avatar.url) if user.avatar else None, + }, status=status.HTTP_201_CREATED) + + +class UserProfileView(RetrieveAPIView): + serializer_class = UserProfileSerializer + permission_classes = [IsAuthenticated, IsActiveUser] + queryset = User.objects.all() + + def get_object(self): + return self.request.user + + +class UserUpdateView(UpdateAPIView): + permission_classes = [IsAuthenticated, IsActiveUser] + serializer_class = UserProfileSerializer + + def get_object(self): + return self.request.user + + +class UserRecoverPassword(CreateAPIView): + serializer_class = UserRecoverPasswordSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + user = get_object_or_404(User, email=data['email']) + code = RedisManager.generate_otp_code() + print(f' send {code}') + phone_number = RedisManager().add_to_redis(code, fullname=str(user.fullname), password='', email=data['email']) + + send_email([data['email']], code) + + return Response( + data= { + "id": user.id, + "fullname": user.fullname, + "phone_number": str(user.phone_number), + "email": user.email if user.email else None, + "avatar": user.avatar if user.avatar else None, + "message": "Forgot password code sent" + }, + status=status.HTTP_202_ACCEPTED, + ) + + +class UserResetPassword(CreateAPIView): + serializer_class = UserResetPasswordSerializer + permission_classes = [IsAuthenticated] + + def post(self, request, *args, **kwargs): + # Get the logged-in user + user = request.user + + # Use the serializer to validate data + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Set the new password + user.set_password(serializer.validated_data['password']) + user.save() + + # Return a success response + return Response({"message": "Your password has been changed successfully."}, status=status.HTTP_200_OK) + + + + +class UserDeleteView(APIView): + permission_classes = [IsAuthenticated] + + def delete(self, request, *args, **kwargs): + try: + user = request.user + if user.email == "admin@gmail.com": + return Response({"detail": "admin"}, status=status.HTTP_204_NO_CONTENT) + + user.soft_delete() + if t := Token.objects.filter(user=user).first(): + t.delete() + + return Response({"detail": "Your account has been deleted."}, status=status.HTTP_204_NO_CONTENT) + + except Exception: + # پیام خطای ثابت برای سایر خطاهای غیرمنتظره + return Response({"detail": "User does not exist."}, status=status.HTTP_404_NOT_FOUND) + + diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/admin.py b/apps/api/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/api/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/api/apps.py b/apps/api/apps.py new file mode 100644 index 0000000..ae75201 --- /dev/null +++ b/apps/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.api' diff --git a/apps/api/models.py b/apps/api/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/apps/api/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/apps/api/tests.py b/apps/api/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/api/urls.py b/apps/api/urls.py new file mode 100644 index 0000000..dc60af2 --- /dev/null +++ b/apps/api/urls.py @@ -0,0 +1,9 @@ + +from django.urls import path +from .views import HomeView + + + +urlpatterns = [ + path('', HomeView.as_view()) +] diff --git a/apps/api/views.py b/apps/api/views.py new file mode 100644 index 0000000..b044e94 --- /dev/null +++ b/apps/api/views.py @@ -0,0 +1,33 @@ +import random +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response +from rest_framework import serializers + +from rest_framework.authtoken.models import Token +from apps.account.models import User + +class HomeSerializer(serializers.Serializer): + token = serializers.CharField() + +# test class generate token +class HomeView(GenericAPIView): + serializer_class = HomeSerializer + + def get(self, request): + emails = ["zahra@gmail.com", "john.doe@example.com", "alice@example.com"] + phone_numbers = ["09012037621", "09012037615", "09012045432"] + fullnames = ["Alireza", "John Doe", "Alice Smith"] + # انتخاب رندوم از هر لیست + email = random.choice(emails) + phone_number = random.choice(phone_numbers) + fullname = random.choice(fullnames) + # ساخت کاربر جدید + user = User.objects.create( + email=email, + phone_number=phone_number, + fullname=fullname, + ) + # ایجاد توکن برای کاربر + token, created = Token.objects.get_or_create(user=user) + + return Response({'token': token.key}) diff --git a/apps/course/__init__.py b/apps/course/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/course/admin/__init__.py b/apps/course/admin/__init__.py new file mode 100644 index 0000000..6e8ef48 --- /dev/null +++ b/apps/course/admin/__init__.py @@ -0,0 +1,2 @@ +from .course import * +from .lesson import * \ No newline at end of file diff --git a/apps/course/admin/course.py b/apps/course/admin/course.py new file mode 100644 index 0000000..c2313ad --- /dev/null +++ b/apps/course/admin/course.py @@ -0,0 +1,87 @@ +from django.contrib import admin +from ajaxdatatable.admin import AjaxDatatable + +from apps.course.models import Course, Glossary, Attachment, CourseCategory + + + + +@admin.register(CourseCategory) +class CourseCategoryAdmin(admin.ModelAdmin): + list_display = ('name', 'slug') + search_fields = ('name',) + exclude = ('slug', ) + + + + +@admin.register(Course) +class CourseAdmin(AjaxDatatable): + list_display = ('title', 'category', 'level', 'status', 'final_price', 'is_online') + list_filter = ('status', 'level', 'is_online', 'is_free', 'category') + search_fields = ('title', 'description') + exclude = ('slug', ) + + + + + +@admin.register(Glossary) +class GlossaryAdmin(admin.ModelAdmin): + list_display = ('title', 'course', 'description') + list_filter = ('course',) + search_fields = ('title', 'description', 'course__title') + ordering = ('-id',) + + + +from django import forms +import hashlib +import os + + +class AttachmentAdminForm(forms.ModelForm): + class Meta: + model = Attachment + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if 'file' in self.data or 'file' in self.files: + file = self.files.get('file') + if file: + file.name = self._shorten_file_name(file.name) + + + def _shorten_file_name(self, file_name): + max_length = 100 + if len(file_name) > max_length: + base_name, ext = os.path.splitext(file_name) # جدا کردن نام و پسوند + allowed_length = max_length - len(ext) # طول مجاز نام بدون پسوند + + # 80٪ از نام اصلی و 20٪ هش + base_length = int(allowed_length * 0.8) # 80٪ از طول مجاز + hash_length = allowed_length - base_length # 20٪ از طول مجاز + + base_part = base_name[:base_length] # 80٪ اول نام اصلی + hash_part = hashlib.sha256(base_name.encode('utf-8')).hexdigest()[:hash_length] # 20٪ هش + + return f"{base_part}{hash_part}{ext}" # ترکیب بخش اصلی و هش با پسوند + + return file_name + + +@admin.register(Attachment) +class AttachmentAdmin(admin.ModelAdmin): + form = AttachmentAdminForm + list_display = ('title', 'course', 'file', 'file_size') + list_filter = ('course',) + search_fields = ('title', 'file', 'course__title') + + def save_model(self, request, obj, form, change): + if obj.file: + obj.file_size = obj.file.size + super().save_model(request, obj, form, change) + + diff --git a/apps/course/admin/lesson.py b/apps/course/admin/lesson.py new file mode 100644 index 0000000..0e78236 --- /dev/null +++ b/apps/course/admin/lesson.py @@ -0,0 +1,18 @@ + +from django.contrib import admin +from apps.course.models import Lesson + + + + +@admin.register(Lesson) +class LessonAdmin(admin.ModelAdmin): + list_display = ('title', 'course', 'priority', 'duration', 'content_type') + list_filter = ('course', 'content_type') + search_fields = ('title', 'course__title') + ordering = ('priority', 'title') + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.order_by('priority') + \ No newline at end of file diff --git a/apps/course/apps.py b/apps/course/apps.py new file mode 100644 index 0000000..ddc8553 --- /dev/null +++ b/apps/course/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CourseConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.course' diff --git a/apps/course/migrations/__init__.py b/apps/course/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/course/models/__init__.py b/apps/course/models/__init__.py new file mode 100644 index 0000000..6e8ef48 --- /dev/null +++ b/apps/course/models/__init__.py @@ -0,0 +1,2 @@ +from .course import * +from .lesson import * \ No newline at end of file diff --git a/apps/course/models/course.py b/apps/course/models/course.py new file mode 100644 index 0000000..b45ac89 --- /dev/null +++ b/apps/course/models/course.py @@ -0,0 +1,166 @@ +import os +from decimal import Decimal +import math +from django.db import models +from django.db.models import TextChoices +from django.utils.translation import gettext_lazy as _ +from filer.fields.image import FilerImageField +from filer.fields.file import FilerFileField + +from apps.account.models import ProfessorUser +from utils.schema import default_timing +from utils import generate_slug_for_model + + + +def course_file_upload_to(instance, filename): + return os.path.join(f"courses/{instance.slug}/videos/{filename}") + + + +def attachment_file_upload_to(instance, filename): + return os.path.join(f"courses/{instance.course.slug}/attachments/{filename}") + + + + +class CourseCategory(models.Model): + name = models.CharField(max_length=255, verbose_name='Category Name') + slug = models.SlugField(unique=True, max_length=255) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + self.slug = generate_slug_for_model(CourseCategory, self.name) + super().save(*args, **kwargs) + + @property + def course_count(self): + return self.courses.count() + + + +class Course(models.Model): + + class LevelChoices(TextChoices): + BEGINNER = 'beginner', 'Beginner' + MID = 'mid', 'Mid Level' + ADVANCED = 'advanced', 'Advanced' + + class StatusChoices(TextChoices): + INACTIVE = 'inactive', 'Inactive' # Not Active (does not show) + UPCOMING = 'upcoming', 'Upcoming' # Upcoming (visible but registration not allowed) + REGISTERING = 'registering', 'Registering' # Registering (registration is open) + ONGOING = 'ongoing', 'Ongoing' # Ongoing (course has started, registration closed) + FINISHED = 'finished', 'Finished' # Finished (course has ended) + + class VedioTypeChoices(models.TextChoices): + VIDEO_FILE = 'video_file', 'Video File' + VIDEO_LINK = 'video_link', 'Video Link' + + + title = models.CharField(max_length=255, verbose_name='Course Title') + slug = models.SlugField(allow_unicode=True, unique=True) + category = models.ForeignKey(CourseCategory, on_delete=models.CASCADE, related_name='courses', verbose_name='Category') + professor = models.ForeignKey( + ProfessorUser, + on_delete=models.CASCADE, + related_name="courses" + ) + + thumbnail = FilerImageField( + related_name='+', on_delete=models.PROTECT, null=True, blank=True, + verbose_name=_('thumbnail') + ) + video_type = models.CharField(max_length=20, choices=VedioTypeChoices.choices, verbose_name='Vedio Type') + video_file = models.FileField( + upload_to=course_file_upload_to, + null=True, + blank=True + ) + video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name='Video Link') + + is_online = models.BooleanField(default=True, verbose_name='Is Online Course') + level = models.CharField(max_length=10, choices=LevelChoices.choices, verbose_name='Course Level') + duration = models.PositiveIntegerField(verbose_name='Duration (in hours)') + lessons_count = models.PositiveIntegerField(verbose_name='Number of Lessons') + + description = models.TextField(verbose_name='Course Description') + short_description = models.CharField(max_length=500, blank=True, null=True, verbose_name="Short Description") + status = models.CharField(max_length=15, choices=StatusChoices.choices, default=StatusChoices.INACTIVE, verbose_name='Course Status') + is_free = models.BooleanField(default=True, verbose_name='Is Free') + price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name='Course Price') + discount_percentage = models.PositiveIntegerField(default=0, verbose_name='Discount Percentage') + final_price = models.DecimalField( + verbose_name=_('Course Final Price'), decimal_places=2, max_digits=10, default=0.00, blank=True, + help_text=_('This field is automatically calculated based on the discount percentage.') + ) + + timing = models.JSONField(blank=True, null=True, default=default_timing, verbose_name=_("Timing"), help_text=_("The Timing information in JSON format.")) + features = models.JSONField(verbose_name=_('Course features'), default=dict, blank=True, null=True) + + + def __str__(self): + return self.title + + + def save(self, *args, **kwargs): + self.slug = generate_slug_for_model(Course, self.title) + + if self.discount_percentage > 0: + discount_amount = (self.price * self.discount_percentage) / 100 + final_price = self.price - discount_amount + self.final_price = Decimal(math.ceil(final_price)).quantize(Decimal('0.00')) + else: + self.final_price = Decimal(math.ceil(self.price)).quantize(Decimal('0.00')) + + super().save(*args, **kwargs) + + + class Meta: + verbose_name = "Course" + verbose_name_plural = "Courses" + + + +class Glossary(models.Model): + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='glossaries', verbose_name='Course') + title = models.CharField(max_length=555, verbose_name='Glossary Title') + description = models.TextField(verbose_name='Description') + + def __str__(self): + return f"{self.course.title} - {self.title}" + + + class Meta: + ordering = ("-id",) + verbose_name = "Glossary" + verbose_name_plural = "Glossary" + + + +class Attachment(models.Model): + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='attachments', verbose_name='Course') + title = models.CharField(max_length=255, verbose_name='Attachment Title') + file = models.FileField( + upload_to=attachment_file_upload_to, + verbose_name='Attachment File' + ) + + file_size = models.PositiveIntegerField(verbose_name='File Size (in bytes)', null=True, blank=True) + + def save(self, *args, **kwargs): + # Calculate the file size before saving + if self.file and not self.file_size: + self.file_size = self.file.size + super().save(*args, **kwargs) + + + def __str__(self): + return f"{self.course.title} - {self.title}" + + class Meta: + ordering = ("-id",) + verbose_name = "Attachment" + verbose_name_plural = "Attachments" \ No newline at end of file diff --git a/apps/course/models/lesson.py b/apps/course/models/lesson.py new file mode 100644 index 0000000..e5d9084 --- /dev/null +++ b/apps/course/models/lesson.py @@ -0,0 +1,34 @@ +import os +from django.db import models + +from filer.fields.image import FilerImageField +from filer.fields.file import FilerFileField + + + +def lesson_file_upload_to(instance, filename): + return os.path.join(f"courses/{instance.course.slug}/lessons/{filename}") + + + + +class Lesson(models.Model): + class ContentTypeChoices(models.TextChoices): + LINK = 'link', 'Link' + FILE = 'file', 'File' + + course = models.ForeignKey("course.Course", on_delete=models.CASCADE, related_name='lessons', verbose_name='Course') + title = models.CharField(max_length=255, verbose_name='Lesson Title') + priority = models.IntegerField(null=True, blank=True, verbose_name='Priority') + duration = models.PositiveIntegerField(verbose_name='Duration (in minutes)') + content_type = models.CharField(max_length=10, choices=ContentTypeChoices.choices, verbose_name='Content Type') + content_file = models.FileField( + null=True, + blank=True, + upload_to=lesson_file_upload_to, + ) + video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name='Video Link') + + def __str__(self): + return f"{self.course.title} - {self.title}" + diff --git a/apps/course/serializers/__init__.py b/apps/course/serializers/__init__.py new file mode 100644 index 0000000..bc1d8a6 --- /dev/null +++ b/apps/course/serializers/__init__.py @@ -0,0 +1 @@ +from .course import * \ No newline at end of file diff --git a/apps/course/serializers/course.py b/apps/course/serializers/course.py new file mode 100644 index 0000000..bf51772 --- /dev/null +++ b/apps/course/serializers/course.py @@ -0,0 +1,107 @@ +from rest_framework import serializers + +from dj_filer.admin import get_thumbs +from apps.course.models import Course, CourseCategory, Attachment, Glossary +from apps.account.serializers import UserProfileSerializer + + + + +class CourseCategorySerializer(serializers.ModelSerializer): + course_count = serializers.SerializerMethodField() + + class Meta: + model = CourseCategory + fields = ['name', 'slug', 'course_count'] + + def get_course_count(self, obj): + # return obj.course_count + return 25 + + +class CourseListSerializer(serializers.ModelSerializer): + category = CourseCategorySerializer() + thumbnail = serializers.SerializerMethodField() + participant_count = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = [ + 'id', + 'title', + 'slug', + 'participant_count', + 'category', + 'thumbnail', + 'is_online', + 'level', + 'duration', + 'lessons_count', + 'short_description', + 'status', + 'is_free', + 'price', + 'discount_percentage', + 'final_price', + ] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + def get_participant_count(self, obj): + return 120 + + + +class CourseDetailSerializer(serializers.ModelSerializer): + category = CourseCategorySerializer() + professor = UserProfileSerializer() + thumbnail = serializers.SerializerMethodField() + participant_count = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = [ + 'id', + 'title', + 'slug', + 'category', + 'participant_count', + 'professor', + 'thumbnail', + 'video_type', + 'video_file', + 'video_link', + 'is_online', + 'level', + 'duration', + 'lessons_count', + 'short_description', + 'status', + 'is_free', + 'price', + 'discount_percentage', + 'final_price', + 'timing', + 'features', + ] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + def get_participant_count(self, obj): + return 120 + + + + +class AttachmentSerializer(serializers.ModelSerializer): + class Meta: + model = Attachment + fields = ['id', 'title', 'file', 'file_size'] + + +class GlossarySerializer(serializers.ModelSerializer): + class Meta: + model = Glossary + fields = ['id', 'title', 'description'] \ No newline at end of file diff --git a/apps/course/tests.py b/apps/course/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/course/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/course/urls.py b/apps/course/urls.py new file mode 100644 index 0000000..ab0de13 --- /dev/null +++ b/apps/course/urls.py @@ -0,0 +1,15 @@ + +from django.urls import path + +from . import views + + + +urlpatterns = [ + path('categories/', views.CourseCategoryAPIView.as_view(), name='course-categories'), + path('', views.CourseListAPIView.as_view(), name='course-list'), + path('/', views.CourseDetailAPIView.as_view(), name='course-detail'), + path('/attachments/', views.AttachmentListAPIView.as_view(), name='course-attachment-list'), + path('/glossaries/', views.GlossaryListAPIView.as_view(), name='course-glossary-list'), + +] diff --git a/apps/course/views/__init__.py b/apps/course/views/__init__.py new file mode 100644 index 0000000..bc1d8a6 --- /dev/null +++ b/apps/course/views/__init__.py @@ -0,0 +1 @@ +from .course import * \ No newline at end of file diff --git a/apps/course/views/course.py b/apps/course/views/course.py new file mode 100644 index 0000000..20a4d20 --- /dev/null +++ b/apps/course/views/course.py @@ -0,0 +1,98 @@ +from rest_framework.generics import ListAPIView, RetrieveAPIView +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from rest_framework.exceptions import NotFound + + +from apps.course.serializers import ( + CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer, + AttachmentSerializer, GlossarySerializer +) +from apps.course.models import Course, CourseCategory, Attachment, Glossary + + + +class CourseCategoryAPIView(ListAPIView): + queryset = CourseCategory.objects.all() + serializer_class = CourseCategorySerializer + + + + +class CourseListAPIView(ListAPIView): + queryset = Course.objects.all().exclude(status=Course.StatusChoices.INACTIVE) + serializer_class = CourseListSerializer + # filterset_fields = ['category__slug',] + + + @swagger_auto_schema(manual_parameters=[ + openapi.Parameter( + 'category_slug', openapi.IN_QUERY, + description="Category of the Course", + type=openapi.TYPE_STRING, + enum=[category.slug for category in CourseCategory.objects.all()] + ), + ]) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + queryset = super().get_queryset() + request = self.request + filters = request.query_params + if category := filters.get('category_slug'): + queryset = queryset.filter(category__slug=category) + + return queryset + + + + +class CourseDetailAPIView(RetrieveAPIView): + queryset = Course.objects.all() + serializer_class = CourseDetailSerializer + lookup_field = "slug" + + + + + +class AttachmentListAPIView(ListAPIView): + serializer_class = AttachmentSerializer + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + 'slug', openapi.IN_PATH, + description="Slug of the Course", + type=openapi.TYPE_STRING, + required=True + ) + ], + operation_description="Retrieve a list of attachments for a given course by its slug." + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + course_slug = self.kwargs.get('slug') + try: + course = Course.objects.get(slug=course_slug) + except Course.DoesNotExist: + raise NotFound("Course not found") + return Attachment.objects.filter(course=course) + + + + +class GlossaryListAPIView(ListAPIView): + serializer_class = GlossarySerializer + + def get_queryset(self): + course_slug = self.kwargs.get('slug') + try: + course = Course.objects.get(slug=course_slug) + except Course.DoesNotExist: + raise NotFound("Course not found") + + return Glossary.objects.filter(course=course) \ No newline at end of file diff --git a/apps/quiz/__init__.py b/apps/quiz/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/quiz/admin.py b/apps/quiz/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/quiz/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/quiz/apps.py b/apps/quiz/apps.py new file mode 100644 index 0000000..3dc8afe --- /dev/null +++ b/apps/quiz/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class QuizConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'quiz' diff --git a/apps/quiz/migrations/__init__.py b/apps/quiz/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/quiz/models/__init__.py b/apps/quiz/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/quiz/models/participant.py b/apps/quiz/models/participant.py new file mode 100644 index 0000000..8454bb5 --- /dev/null +++ b/apps/quiz/models/participant.py @@ -0,0 +1,68 @@ +from django.db import models + +from apps.account.models import User + + + + +class Participant(models.Model): + quiz = models.ForeignKey('quiz.Quiz', on_delete=models.CASCADE, related_name='participants') + user = models.ForeignKey('account.User', on_delete=models.CASCADE, verbose_name='user', related_name='uquizzes') + started_at = models.DateTimeField(verbose_name='started at') + ended_at = models.DateTimeField(verbose_name='ended at') + total_timing = models.PositiveIntegerField(help_text='Seconds take to finish the quiz') + + question_score = models.PositiveIntegerField() + timing_score = models.PositiveIntegerField() + total_score = models.PositiveIntegerField() + + class Meta: + verbose_name = "Participant" + verbose_name_plural = "Participants" + ordering = ("-id",) + + def __str__(self): + return f"Participant: {self.id}, ParticipantName: {self.user}, Quiz: {self.quiz.id}" + + def __repr__(self): + return f"Participant(id={self.id})" + + + @staticmethod + def get_user_ranks(quiz_id): + return Participant.objects.filter(quiz_id=quiz_id).annotate( + rank=Window( + expression=Rank(), + order_by=F('total_score').desc() + ) + ) + + + +class ParticipantAnswer(models.Model): + CHOICES = [ + (1, 'Option 1'), + (2, 'Option 2'), + (3, 'Option 3'), + (4, 'Option 4'), + ] + + participant = models.ForeignKey(Participant, on_delete=models.CASCADE, related_name='answers') + question = models.ForeignKey("quiz.Question", on_delete=models.CASCADE) + option_num = models.PositiveSmallIntegerField(choices=CHOICES, verbose_name='selected option') + at_time = models.DateTimeField() + answer_timing = models.PositiveSmallIntegerField(default=0, verbose_name='seconds take to answer') + + + class Meta: + verbose_name = "User Quiz Answer" + verbose_name_plural = "User Quiz Answers" + ordering = ("-id",) + + def __str__(self): + return f"Participant Answer: {self.id}" + + def __repr__(self): + return f"ParticipantAnswer(id={self.id})" + + diff --git a/apps/quiz/models/quiz.py b/apps/quiz/models/quiz.py new file mode 100644 index 0000000..ec9972c --- /dev/null +++ b/apps/quiz/models/quiz.py @@ -0,0 +1,53 @@ +from django.db import models + + + +class Quiz(models.Model): + course = models.ForeignKey("course.Course", verbose_name='course', related_name='quizzes', on_delete=models.CASCADE) + each_question_timing = models.PositiveIntegerField() + status = models.BooleanField(default=True) + + + class Meta: + verbose_name = "Quiz" + verbose_name_plural = "Quizzes" + ordering = ("-id",) + + def __str__(self): + return f"Quiz: {self.id}" + + def __repr__(self): + return f"Quiz(id={self.id})" + + + + +class Question(models.Model): + CHOICES = [ + (1, 'Option 1'), + (2, 'Option 2'), + (3, 'Option 3'), + (4, 'Option 4'), + ] + + quiz = models.ForeignKey(Quiz, verbose_name='quiz', on_delete=models.CASCADE, related_name='questions') + question = models.CharField(max_length=255) + option1 = models.CharField(max_length=255, verbose_name='option 1') + option2 = models.CharField(max_length=255, verbose_name='option 2') + option3 = models.CharField(max_length=255, verbose_name='option 3') + option4 = models.CharField(max_length=255, verbose_name='option 4') + correct_answer = models.PositiveSmallIntegerField(choices=CHOICES) + created_at = models.DateTimeField(auto_now_add=True, verbose_name='created at') + priority = models.IntegerField(null=True, blank=True) + + + class Meta: + verbose_name = "Question" + verbose_name_plural = "Questions" + ordering = ("-priority", "-id",) + + def __str__(self): + return self.question + + def __repr__(self): + return f"Question(id={self.id})" diff --git a/apps/quiz/tests.py b/apps/quiz/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/quiz/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/quiz/views.py b/apps/quiz/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/apps/quiz/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..a9ef1ea --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,8 @@ +# __init__.py +from __future__ import absolute_import, unicode_literals + +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..d436e9d --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for backend 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/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..3becb73 --- /dev/null +++ b/config/celery.py @@ -0,0 +1,22 @@ +import os + +import environ + +from celery import Celery + +env = environ.Env() +environ.Env.read_env(os.path.join(os.path.dirname(os.path.dirname(__file__)), '.env')) + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') + +app = Celery('config') +# 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') + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() + diff --git a/config/language_code_middleware.py b/config/language_code_middleware.py new file mode 100644 index 0000000..d7cd30f --- /dev/null +++ b/config/language_code_middleware.py @@ -0,0 +1,19 @@ +from django.http import HttpResponse +from apps.account.models import User + +ALLOWED_URLS = [ + "/login", "/admin", "telegram-sentry", 'bot-runner', "auth/google/", "/elalhabib/submit/", '/pay', 'paypal', + 'robots.txt', "/.well-known/", "about", "/download", 'dont-kill/' +] + + + +def language_middleware(get_response): + def middleware(request): + request.LANGUAGE_CODE = request.GET.get('language_code') or request.LANGUAGE_CODE + + response = get_response(request) + + return response + + return middleware diff --git a/config/redis_config.py b/config/redis_config.py new file mode 100644 index 0000000..def87a3 --- /dev/null +++ b/config/redis_config.py @@ -0,0 +1,15 @@ +from redis import Redis, ConnectionPool + +from config.settings import base as settings + + + +pool = ConnectionPool.from_url(url= settings.REDIS_URL,max_connections=100) + + +class RedisConfig: + + def __init__(self): + self.redis = Redis(connection_pool=pool, decode_responses=True) + + \ No newline at end of file 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..49787e0 --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,308 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 5.0.4. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.0/ref/settings/ +""" +import os +from pathlib import Path + +import environ +from django.utils.translation import gettext_lazy as _ + + +env = environ.Env( + # set casting, default value + # DEBUG=(bool, False) +) +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +environ.Env.read_env(os.path.join(BASE_DIR, '.env')) + +ALLOWED_HOSTS = env('DJANGO_ALLOWED_HOSTS').split(',') + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-7=3it+m^28^+0c1*9-*c*6g3ej63sz(97rq1^mp=!6e(mhmysh' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +X_FRAME_OPTIONS = 'SAMEORIGIN' + +LOCAL_APPS = [ + 'apps.account.apps.AccountConfig', + 'apps.api.apps.ApiConfig', + 'apps.course.apps.CourseConfig', +] + +THIRD_PARTY_APPS = [ + 'rest_framework', + 'rest_framework.authtoken', + 'drf_yasg', + 'easy_thumbnails', + 'phonenumber_field', + 'dj_language', + 'dj_filer', + 'ajaxdatatable', + 'corsheaders', + 'django_filters', + +] +INSTALLED_APPS = [ + 'limitless_dashboard.apps.DashboardConfig', + # 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + *THIRD_PARTY_APPS, + *LOCAL_APPS, + +] +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', # این خط را نگه دارید تا احراز هویت پیش‌فرض کار کند + 'apps.account.custom_user_login.CustomLoginBackend', # مسیر به کلاس سفارشی خود +] + +REDIS_URL = env('REDIS_URL') + + + +OTP_SERIVCE_KEY = "33213d78f1234e99b81f94eefda77e45" + + +PHONENUMBER_DEFAULT_REGION = "IR" +PHONENUMBER_DB_FORMAT = 'INTERNATIONAL' +PHONENUMBER_DEFAULT_FORMAT = 'INTERNATIONAL' + +AUTH_USER_MODEL = "account.User" + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', + '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', + 'config.language_code_middleware.language_middleware', + 'config.test_auth_middleware.test_auth_middleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + BASE_DIR / 'templates', + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'django.template.context_processors.i18n', + + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + + +# django google recaptcha default keys +RECAPTCHA_PUBLIC_KEY = env('captcha_public_key') +RECAPTCHA_PRIVATE_KEY = env('captcha_private_key') + +# custom settings +APPS_REORDER = { + 'auth': { + 'icon': 'icon-shield-check', + 'name': 'Authentication' + }, + 'account': { + # 'icon': 'icon-', + 'name': 'account' + + } +} +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': env('POSTGRES_DB'), + 'USER': env('POSTGRES_USER'), + 'PASSWORD': env('POSTGRES_PASSWORD'), + 'HOST': env('POSTGRES_HOST'), + 'PORT': env('POSTGRES_PORT'), + 'ATOMIC_REQUESTS': True, + }, +} + + +CORS_ALLOW_ALL_ORIGINS = True + +THUMBNAIL_ALIASES = { + '': { + 'icon': {'size': (50, 50), 'crop': True}, + 'large': {'size': (1200, 620), 'crop': False}, + 'medium': {'size': (545, 545), 'crop': False}, + 'small': {'size': (150, 150), 'crop': False}, + }, +} + +LANGUAGES_MAP = { + 'az': ['az', 'tr', 'fa', 'ar'], + 'tr': ['tr', 'az', 'fa', 'ar'], + 'ru': ['ru', 'az', 'tr', 'fa', 'ar'], + 'ar': ['ar', 'fa'], + 'ur': ['ur', 'en', 'fa', 'ar'], + 'en': ['en', 'ur', 'fa', 'ar'], + 'de': ['de', 'en', 'fr', 'es', 'ar'], + 'fa': ['fa', 'az', 'ar', 'en', 'ur'], + + 'fr': ['fr', 'en', 'ar', 'fa'], + 'es': ['es', 'en', 'ar', 'fa'], + 'id': ['id', 'en', 'ar', 'fa'], + 'sw': ['sw', 'en', 'ar', 'fa'], +} + + +LANGUAGES = [ + ('ar', _('Arabic')), + ('az', _('Azerbaijani')), + ('fr', _('French')), + ('in', _('Indonesia')), + ('fa', _('Persian')), + ('ru', _('Russia')), + ('es', _('Spanish')), + ('sw', _('Swahili')), + ('tr', _('Turkish')), + ('de', _('German')), + ('en', _('English')), + ('fa', _('Persian')), + ('ur', _('Urdu')), + ('zh', _('Mandarin')), + ('zh', _('Chinese')), + ('he', _('Hebrew')), + ('he', _('Hebrew')), + ('bn', _('Bengali')), +] + +CELERY_BROKER_URL = env("REDIS_URL") +CELERY_RESULT_BACKEND = env("REDIS_URL") +CELERY_ACCEPT_CONTENT = ['application/json'] +CELERY_TIMEZONE = 'Asia/Tehran' +CELERY_BROKER_TRANSPORT = 'redis' +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators + + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 6, + } + }, +] + + +REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 16, + # 'DEFAULT_AUTHENTICATION_CLASSES': [ + # 'apps.account.auth_back.TokenAuthentication2', + # ], + 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + # 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema' # or OpenAPISchema if using drf_yasg + + +} +# Internationalization +# https://docs.djangoproject.com/en/5.0/topics/i18n/ + +LANGUAGE_CODE = 'en' + +TIME_ZONE = 'Asia/Tehran' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = False + +STATIC_URL = '/static/' +MEDIA_URL = '/media/' + +STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] +STATIC_ROOT = os.path.join(BASE_DIR, 'static', 'static') +MEDIA_ROOT = os.path.join(BASE_DIR, 'static', 'media') + +FILER_ADMIN_ICON_SIZES = ('32', '48') + +FILER_ENABLE_LOGGING = True +FILER_DEBUG = True +ADMIN_TITLE = 'Aquilah App' +ADMIN_INDEX_TITLE = 'Aquilah Administration' + + +# Dictionary with phone number ranges and corresponding countries +# If a country is in this dictionary, it indicates that the project's OTP service supports that country +SERVICE_OTP_COUNTRU_API_KEY = { + "Iran": "https://console.melipayamak.com/api/send/simple/33213d78f1234e99b81f94eefda77e45" +} +SERVICE_OTP_COUNTRY_PHONE_RANGE = { + "98": "Iran", + "+98": "Iran" +} + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.0/howto/static-files/ + + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_SHOW_CITY_GUIDE_CITY = 'mashhad' +FILE_UPLOAD_HANDLERS = [ + 'django.core.files.uploadhandler.TemporaryFileUploadHandler', +] + + + +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.gmail.com' +EMAIL_PORT = 587 +EMAIL_USE_TLS = True +EMAIL_HOST_USER = 'aliabdolahi.171@gmail.com' +EMAIL_HOST_PASSWORD = 'rkxb nnhx iave fxxt' \ No newline at end of file diff --git a/config/settings/develop.py b/config/settings/develop.py new file mode 100644 index 0000000..e347d54 --- /dev/null +++ b/config/settings/develop.py @@ -0,0 +1,17 @@ +from .base import * + +# DJANGO_REDIS_IGNORE_EXCEPTIONS = True +DEBUG = True + +CORS_ALLOW_ALL_ORIGINS = True + +# CACHES = { +# 'default': { +# "BACKEND": "django.core.cache.backends.dummy.DummyCache", +# }, +# 'memory': { +# 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', +# 'LOCATION': 'unique-snowflake', +# 'TIMEOUT': 5000, +# }, +# } \ No newline at end of file diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..bf90107 --- /dev/null +++ b/config/settings/production.py @@ -0,0 +1,120 @@ +# import sentry_sdk + +from .base import * +from celery.schedules import crontab + +DEBUG = False + +#It is currently active +CORS_ALLOW_ALL_ORIGINS = True + +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + + + +CELERY_BROKER_URL = env("REDIS_URL") +CELERY_RESULT_BACKEND = env("REDIS_URL") +CELERY_ACCEPT_CONTENT = ['application/json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = 'Asia/Tehran' +CELERY_BROKER_TRANSPORT = 'redis' + +# زمان‌بندی Celery Beat + +CELERY_BEAT_SCHEDULE = { + 'crawler_website_bonbast_rate_usd_every_half_hour': { + 'task': 'apps.tasrif.tasks.crawler_website_bonbast_rate_usd', + 'schedule': crontab(minute=0, hour='*/1'), # اجرای هر ساعت یک‌بار + }, +} + +# CORS_ALLOWED_ORIGINS = [ +# 'https://aqila.nwhco.ir', +# 'http://aqila.nwhco.ir', +# 'https://aqila.com', +# 'https://pay.aqila.com', +# 'http://pay.aqila.com', +# 'https://qa.aqila.com', +# 'http://aqila.com', +# 'http://aqila.app', +# 'https://aqila.app', +# ] + +CACHES = { + 'default': { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": env("REDIS_URL"), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + } + }, + 'memory': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'unique-snowflake', + 'TIMEOUT': 5000, + }, +} + +# sentry_sdk.init( +# dsn="https://4d54a16a5ea997f6dd4859a4d34da230@us.sentry.io/4506682167525376", +# # Set traces_sample_rate to 1.0 to capture 100% +# # of transactions for performance monitoring. +# traces_sample_rate=1.0, +# # Set profiles_sample_rate to 1.0 to profile 100% +# # of sampled transactions. +# # We recommend adjusting this value in production. +# profiles_sample_rate=1.0, +# ) + +REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = [ + 'rest_framework.renderers.JSONRenderer', +] + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "filters": { + "require_debug_false": { + "()": "django.utils.log.RequireDebugFalse", + }, + "require_debug_true": { + "()": "django.utils.log.RequireDebugTrue", + }, + }, + "formatters": { + "django.server": { + "()": "django.utils.log.ServerFormatter", + "format": "[{server_time}] {message}", + "style": "{", + } + }, + "handlers": { + "console": { + "level": "INFO", + "class": "logging.StreamHandler", + }, + "django.server": { + "level": "INFO", + "class": "logging.StreamHandler", + "formatter": "django.server", + }, + "mail_admins": { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "django.utils.log.AdminEmailHandler", + }, + }, + "loggers": { + "django": { + "handlers": ["console", "mail_admins"], + "level": "INFO", + }, + "django.server": { + "handlers": ["django.server"], + "level": "INFO", + "propagate": False, + }, + }, +} \ No newline at end of file diff --git a/config/test_auth_middleware.py b/config/test_auth_middleware.py new file mode 100644 index 0000000..19ba241 --- /dev/null +++ b/config/test_auth_middleware.py @@ -0,0 +1,29 @@ +from django.core.exceptions import PermissionDenied +from rest_framework.authtoken.models import Token +from apps.account.models import User + + + +def test_auth_middleware(get_response): + """ + give access to swagger and api if admin is logged in + """ + + def middleware(request): + if "/admin/" not in request.path and request.META.get('HTTP_AUTHORIZATION') is None: + if request.user.is_authenticated and request.user.is_staff: + token, _ = Token.objects.get_or_create(user=request.user) + request.META['HTTP_AUTHORIZATION'] = "Token " + token.key + + + if "/swagger" in request.path or "/redoc" in request.path: + if not request.META.get('HTTP_AUTHORIZATION'): + user = User.objects.filter(is_staff=True, email="aqila@gmail.com").first() + if user: + t, _ = Token.objects.get_or_create(user=user) + request.META['HTTP_AUTHORIZATION'] = f"Token {t}" + + + return get_response(request) + + return middleware diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..3b9716e --- /dev/null +++ b/config/urls.py @@ -0,0 +1,53 @@ +""" +URL configuration for backend project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from django.conf.urls.i18n import i18n_patterns +from utils import UploadTmpMedia +from django.conf.urls import url +from django.http import JsonResponse +from django.shortcuts import render +from django.views.decorators.csrf import csrf_exempt +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from utils import absolute_url + + + +api_patterns = [ + path('test/', include('apps.api.urls')), + + path('account/', include('apps.account.urls')), + path('courses/', include('apps.course.urls')), + +] + + +urlpatterns = [ + # path('admin/', admin.site.urls), + path('api/', include(api_patterns)), + # path('test/', include('apps.api.urls')) +] +urlpatterns += i18n_patterns( + path('', include('limitless_dashboard.urls')), + +) +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..0b0ba3a --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for backend project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') + +application = get_wsgi_application() diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..eabe08d --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,85 @@ +version: '3.8' + +services: + web: + container_name: imam-javad_web + restart: unless-stopped + build: + context: . + dockerfile: Dockerfile.prod + command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers=32 --timeout 560 + volumes: + - static_volume:/usr/src/app/static + ports: + - "8019:8000" + env_file: + - .env.prod + depends_on: + - postgres + links: + - postgres + networks: + - imam-javad + + postgres: + container_name: imam-javad_db + ports: + - "5575:5432" + restart: unless-stopped + image: postgres:14.0 + volumes: + - postgres_data:/var/lib/postgresql/data/ + env_file: + - .env.prod + networks: + - imam-javad + imam-javad_redis: + container_name: imam-javad_redis + image: redis:alpine + env_file: .env.prod + volumes: + - redis_data:/data + networks: + - imam-javad + + imam-javad_celery: + container_name: imam-javad_celery + build: + context: . + dockerfile: Dockerfile.celery.prod + env_file: .env.prod + command: celery -A config worker -l info + volumes: + - .:/usr/src/app/ + - static_volume:/usr/src/app/static + + depends_on: + - imam-javad_redis + networks: + - imam-javad + + + imam-javad_celery-beat: + container_name: imam-javad_celery_beat + build: + context: . + dockerfile: Dockerfile.prod + env_file: .env.prod + command: celery -A config beat -l info + volumes: + - .:/usr/src/app/ + depends_on: + - imam-javad_redis + networks: + - imam-javad + + + +volumes: + postgres_data: + static_volume: + redis_data: + +networks: + imam-javad: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7863ba5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + + +services: + web: + build: . + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - .:/usr/src/app + - ./volumes/static_data:/usr/src/app/static/ + ports: + - "9000:8000" + env_file: + - .env.dev + depends_on: + - postgres + networks: + - aquilah + + postgres: + ports: + - "5444:5432" + image: postgres:13.7 + + volumes: + - ./volumes/postgres_data:/var/lib/postgresql/data + env_file: + - .env.dev + networks: + - aquilah + + +volumes: + postgres_data: + staticfiles: +networks: + imam-javad: diff --git a/dynamic_preferences/__init__.py b/dynamic_preferences/__init__.py new file mode 100644 index 0000000..71049d9 --- /dev/null +++ b/dynamic_preferences/__init__.py @@ -0,0 +1,2 @@ +__version__ = "1.14.0" +default_app_config = "dynamic_preferences.apps.DynamicPreferencesConfig" diff --git a/dynamic_preferences/admin.py b/dynamic_preferences/admin.py new file mode 100644 index 0000000..496ae81 --- /dev/null +++ b/dynamic_preferences/admin.py @@ -0,0 +1,114 @@ +from ajaxdatatable.admin import AjaxDatatable +from django.contrib import admin +from django import forms + +from .settings import preferences_settings +from .registries import global_preferences_registry +from .models import GlobalPreferenceModel +from .forms import GlobalSinglePreferenceForm, SinglePerInstancePreferenceForm +from django.utils.translation import gettext_lazy as _ + + +class SectionFilter(admin.AllValuesFieldListFilter): + def __init__(self, field, request, params, model, model_admin, field_path): + super(SectionFilter, self).__init__( + field, request, params, model, model_admin, field_path + ) + parent_model, reverse_path = admin.utils.reverse_field_path(model, field_path) + if model == parent_model: + queryset = model_admin.get_queryset + else: + queryset = parent_model._default_manager.all() + self.registries = [] + registry_name_set = set() + for preferenceModel in queryset.distinct(): + l = len(registry_name_set) + registry_name_set.add(preferenceModel.registry.__class__.__name__) + if len(registry_name_set) != l: + self.registries.append(preferenceModel.registry) + + def choices(self, changelist): + choices = super(SectionFilter, self).choices(changelist) + for choice in choices: + display = choice["display"] + try: + for registry in self.registries: + display = registry.section_objects[display].verbose_name + choice["display"] = display + except (KeyError): + pass + yield choice + + +class DynamicPreferenceAdmin(AjaxDatatable): + list_display = ( + "verbose_name", + "help_text", + ) + fields = ("raw_value", "default_value",) + readonly_fields = ("default_value",) + change_form_template = "dynamic_preferences/dyna_change_form.html" + + @admin.display(description=_('Verbose name')) + def verbose_name(self, obj): + return obj.verbose_name + + @admin.display(description=_('Help text')) + def help_text(self, obj): + return obj.help_text + + def has_add_permission(self, request): + # if "root@admin" in request.user.username: + # return True + return False + + def has_delete_permission(self, request, obj=None): + if "root@admin" in request.user.email: + return True + return False + + if preferences_settings.ADMIN_ENABLE_CHANGELIST_FORM: + def get_changelist_form(self, request, **kwargs): + return self.changelist_form + + def default_value(self, obj): + return obj.preference.default + + default_value.short_description = _("Default Value") + + def section_name(self, obj): + try: + return obj.registry.section_objects[obj.section].verbose_name + except KeyError: + pass + return obj.section + + section_name.short_description = _("Section Name") + + def save_model(self, request, obj, form, change): + pref = form.instance + manager = pref.registry.manager() + manager.update_db_pref(pref.section, pref.name, form.cleaned_data["raw_value"]) + + +class GlobalPreferenceAdmin(DynamicPreferenceAdmin): + form = GlobalSinglePreferenceForm + changelist_form = GlobalSinglePreferenceForm + + def get_queryset(self, *args, **kwargs): + # Instanciate default prefs + manager = global_preferences_registry.manager() + manager.all() + return super(GlobalPreferenceAdmin, self).get_queryset(*args, **kwargs) + + +admin.site.register(GlobalPreferenceModel, GlobalPreferenceAdmin) + + +class PerInstancePreferenceAdmin(DynamicPreferenceAdmin): + list_display = ("instance",) + DynamicPreferenceAdmin.list_display + fields = ("instance",) + DynamicPreferenceAdmin.fields + raw_id_fields = ("instance",) + form = SinglePerInstancePreferenceForm + changelist_form = SinglePerInstancePreferenceForm + list_select_related = True diff --git a/dynamic_preferences/api/__init__.py b/dynamic_preferences/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dynamic_preferences/api/serializers.py b/dynamic_preferences/api/serializers.py new file mode 100644 index 0000000..b3c7c56 --- /dev/null +++ b/dynamic_preferences/api/serializers.py @@ -0,0 +1,71 @@ +from rest_framework import serializers +from dynamic_preferences.models import GlobalPreferenceModel + + +class PreferenceValueField(serializers.Field): + def get_attribute(self, o): + return o + + def to_representation(self, o): + return o.preference.api_repr(o.value) + + def to_internal_value(self, data): + return data + + +class PreferenceSerializer(serializers.Serializer): + + section = serializers.CharField(read_only=True) + name = serializers.CharField(read_only=True) + identifier = serializers.SerializerMethodField() + default = serializers.SerializerMethodField() + value = PreferenceValueField() + verbose_name = serializers.SerializerMethodField() + help_text = serializers.SerializerMethodField() + additional_data = serializers.SerializerMethodField() + field = serializers.SerializerMethodField() + + class Meta: + fields = [ + "default", + "value", + "verbose_name", + "help_text", + ] + + def get_default(self, o): + return o.preference.api_repr(o.preference.get("default")) + + def get_verbose_name(self, o): + return o.preference.get("verbose_name") + + def get_identifier(self, o): + return o.preference.identifier() + + def get_help_text(self, o): + return o.preference.get("help_text") + + def get_additional_data(self, o): + return o.preference.get_api_additional_data() + + def get_field(self, o): + return o.preference.get_api_field_data() + + def validate_value(self, value): + """ + We call validation from the underlying form field + """ + field = self.instance.preference.setup_field() + value = field.to_python(value) + field.validate(value) + field.run_validators(value) + return value + + def update(self, instance, validated_data): + instance.value = validated_data["value"] + instance.save() + return instance + + +class GlobalPreferenceSerializer(PreferenceSerializer): + pass diff --git a/dynamic_preferences/api/viewsets.py b/dynamic_preferences/api/viewsets.py new file mode 100644 index 0000000..8c3b822 --- /dev/null +++ b/dynamic_preferences/api/viewsets.py @@ -0,0 +1,179 @@ +from django.db import transaction +from django.db.models import Q + +from rest_framework import mixins +from rest_framework import viewsets +from rest_framework import permissions +from rest_framework.response import Response +from rest_framework.decorators import action +from rest_framework.generics import get_object_or_404 + +from dynamic_preferences import models +from dynamic_preferences import exceptions +from dynamic_preferences.settings import preferences_settings + +from . import serializers + + +class PreferenceViewSet( + mixins.UpdateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + """ + - list preferences + - detail given preference + - batch update preferences + - update a single preference + """ + + def get_queryset(self): + """ + We just ensure preferences are actually populated before fetching + from db + """ + self.init_preferences() + queryset = super(PreferenceViewSet, self).get_queryset() + + section = self.request.query_params.get("section") + if section: + queryset = queryset.filter(section=section) + + return queryset + + def get_manager(self): + return self.queryset.model.registry.manager() + + def init_preferences(self): + manager = self.get_manager() + manager.all() + + def get_object(self): + """ + Returns the object the view is displaying. + You may want to override this if you need to provide non-standard + queryset lookups. Eg if objects are referenced using multiple + keyword arguments in the url conf. + """ + queryset = self.filter_queryset(self.get_queryset()) + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + identifier = self.kwargs[lookup_url_kwarg] + section, name = self.get_section_and_name(identifier) + filter_kwargs = {"section": section, "name": name} + obj = get_object_or_404(queryset, **filter_kwargs) + + # May raise a permission denied + self.check_object_permissions(self.request, obj) + + return obj + + def get_section_and_name(self, identifier): + try: + section, name = identifier.split(preferences_settings.SECTION_KEY_SEPARATOR) + except ValueError: + # no section given + section, name = None, identifier + + return section, name + + @action(detail=False, methods=["post"]) + @transaction.atomic + def bulk(self, request, *args, **kwargs): + """ + Update multiple preferences at once + + this is a long method because we ensure everything is valid + before actually persisting the changes + """ + manager = self.get_manager() + errors = {} + preferences = [] + payload = request.data + + # first, we check updated preferences actually exists in the registry + try: + for identifier, value in payload.items(): + try: + preferences.append(self.queryset.model.registry.get(identifier)) + except exceptions.NotFoundInRegistry: + errors[identifier] = "invalid preference" + except (TypeError, AttributeError): + return Response("invalid payload", status=400) + + if errors: + return Response(errors, status=400) + + # now, we generate an optimized Q objects to retrieve all matching + # preferences at once from database + queries = [Q(section=p.section.name, name=p.name) for p in preferences] + + query = queries[0] + for q in queries[1:]: + query |= q + preferences_qs = self.get_queryset().filter(query) + + # next, we generate a serializer for each database preference + serializer_objects = [] + for p in preferences_qs: + s = self.get_serializer_class()( + p, data={"value": payload[p.preference.identifier()]} + ) + serializer_objects.append(s) + + validation_errors = {} + + # we check if any serializer is invalid + for s in serializer_objects: + if s.is_valid(): + continue + validation_errors[s.instance.preference.identifier()] = s.errors + + if validation_errors: + return Response(validation_errors, status=400) + + for s in serializer_objects: + s.save() + + return Response( + [s.data for s in serializer_objects], + status=200, + ) + + +class GlobalPreferencePermission(permissions.DjangoModelPermissions): + perms_map = { + "GET": ["%(app_label)s.change_%(model_name)s"], + "OPTIONS": ["%(app_label)s.change_%(model_name)s"], + "HEAD": ["%(app_label)s.change_%(model_name)s"], + "POST": ["%(app_label)s.change_%(model_name)s"], + "PUT": ["%(app_label)s.change_%(model_name)s"], + "PATCH": ["%(app_label)s.change_%(model_name)s"], + "DELETE": ["%(app_label)s.change_%(model_name)s"], + } + + +class GlobalPreferencesViewSet(PreferenceViewSet): + queryset = models.GlobalPreferenceModel.objects.all() + serializer_class = serializers.GlobalPreferenceSerializer + permission_classes = [GlobalPreferencePermission] + + +class PerInstancePreferenceViewSet(PreferenceViewSet): + def get_manager(self): + return self.queryset.model.registry.manager( + instance=self.get_related_instance() + ) + + def get_queryset(self): + return ( + super(PerInstancePreferenceViewSet, self) + .get_queryset() + .filter(instance=self.get_related_instance()) + ) + + def get_related_instance(self): + """ + Override this to the instance bound to the preferences + """ + raise NotImplementedError diff --git a/dynamic_preferences/apps.py b/dynamic_preferences/apps.py new file mode 100644 index 0000000..dabbca8 --- /dev/null +++ b/dynamic_preferences/apps.py @@ -0,0 +1,25 @@ +from django.apps import AppConfig, apps +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from .registries import preference_models, global_preferences_registry +from .settings import preferences_settings + + +class DynamicPreferencesConfig(AppConfig): + name = "dynamic_preferences" + verbose_name = _("Settings") + default_auto_field = "django.db.models.AutoField" + icon = 'mi-settings' + + def ready(self): + if preferences_settings.ENABLE_GLOBAL_MODEL_AUTO_REGISTRATION: + GlobalPreferenceModel = self.get_model("GlobalPreferenceModel") + + preference_models.register( + GlobalPreferenceModel, global_preferences_registry + ) + + # This will load all dynamic_preferences_registry.py files under + # installed apps + app_names = [app.name for app in apps.app_configs.values()] + global_preferences_registry.autodiscover(app_names) diff --git a/dynamic_preferences/dynamic_preferences_registry.py b/dynamic_preferences/dynamic_preferences_registry.py new file mode 100644 index 0000000..9885de1 --- /dev/null +++ b/dynamic_preferences/dynamic_preferences_registry.py @@ -0,0 +1,15 @@ +import json + +from django import forms + +from limitless_dashboard.fields.tinyeditor import TinyWidget + +from dynamic_preferences.preferences import Section +from dynamic_preferences.registries import global_preferences_registry +from dynamic_preferences.types import BasePreferenceType, BaseSerializer, LongStringPreference, StringPreference, \ + FilePreference +from utils.json_editor_field import JsonEditorWidget + + +class EditorPreferences(LongStringPreference): + widget = TinyWidget(attrs={'class': 'editor-field'}) diff --git a/dynamic_preferences/exceptions.py b/dynamic_preferences/exceptions.py new file mode 100644 index 0000000..e08fcd5 --- /dev/null +++ b/dynamic_preferences/exceptions.py @@ -0,0 +1,32 @@ +class DynamicPreferencesException(Exception): + detail_default = "An exception occurred with django-dynamic-preferences" + + def __init__(self, detail=None): + if detail is not None: + self.detail = str(detail) + else: + self.detail = str(self.detail_default) + + def __str__(self): + return self.detail + + +class MissingDefault(DynamicPreferencesException): + detail_default = "You must provide a default value for all preferences" + + +class NotFoundInRegistry(DynamicPreferencesException, KeyError): + detail_default = "Preference with this name/section not found in registry" + + +class DoesNotExist(DynamicPreferencesException): + detail_default = "Cannot retrieve preference value, ensure the preference is correctly registered and database is synced" + + +class CachedValueNotFound(DynamicPreferencesException): + detail_default = "Cached value not found" + + +class MissingModel(DynamicPreferencesException): + detail_default = 'You must define a model choice through "model" \ + or "queryset" attribute' diff --git a/dynamic_preferences/forms.py b/dynamic_preferences/forms.py new file mode 100644 index 0000000..4810376 --- /dev/null +++ b/dynamic_preferences/forms.py @@ -0,0 +1,152 @@ +from six import string_types +from django import forms +from django.core.exceptions import ValidationError +from collections import OrderedDict + +from .registries import global_preferences_registry +from .models import GlobalPreferenceModel +from .exceptions import NotFoundInRegistry + + +class AbstractSinglePreferenceForm(forms.ModelForm): + class Meta: + fields = ("section", "name", "raw_value") + + def __init__(self, *args, **kwargs): + + self.instance = kwargs.get("instance") + initial = {} + if self.instance: + initial["raw_value"] = self.instance.value + kwargs["initial"] = initial + super(AbstractSinglePreferenceForm, self).__init__(*args, **kwargs) + + if self.instance.name: + self.fields["raw_value"] = self.instance.preference.setup_field() + + def clean(self): + cleaned_data = super(AbstractSinglePreferenceForm, self).clean() + try: + self.instance.name, self.instance.section = ( + cleaned_data["name"], + cleaned_data["section"], + ) + except KeyError: # changelist form + pass + try: + self.instance.preference + except NotFoundInRegistry: + raise ValidationError(NotFoundInRegistry.detail_default) + return self.cleaned_data + + def save(self, *args, **kwargs): + self.instance.value = self.cleaned_data["raw_value"] + return super(AbstractSinglePreferenceForm, self).save(*args, **kwargs) + + +class SinglePerInstancePreferenceForm(AbstractSinglePreferenceForm): + class Meta: + fields = ("instance",) + AbstractSinglePreferenceForm.Meta.fields + + def clean(self): + cleaned_data = super(AbstractSinglePreferenceForm, self).clean() + try: + self.instance.name, self.instance.section = ( + cleaned_data["name"], + cleaned_data["section"], + ) + except KeyError: # changelist form + pass + i = cleaned_data.get("instance") + if i: + self.instance.instance = i + try: + self.instance.preference + except NotFoundInRegistry: + raise ValidationError(NotFoundInRegistry.detail_default) + return self.cleaned_data + + +class GlobalSinglePreferenceForm(AbstractSinglePreferenceForm): + class Meta: + model = GlobalPreferenceModel + fields = AbstractSinglePreferenceForm.Meta.fields + + +def preference_form_builder(form_base_class, preferences=[], **kwargs): + """ + Return a form class for updating preferences + :param form_base_class: a Form class used as the base. Must have a ``registry` attribute + :param preferences: a list of :py:class: + :param section: a section where the form builder will load preferences + """ + registry = form_base_class.registry + preferences_obj = [] + if len(preferences) > 0: + # Preferences have been selected explicitly + for pref in preferences: + if isinstance(pref, string_types): + preferences_obj.append(registry.get(name=pref)) + elif type(pref) == tuple: + preferences_obj.append(registry.get(name=pref[0], section=pref[1])) + else: + raise NotImplementedError( + "The data you provide can't be converted to a Preference object" + ) + elif kwargs.get("section", None): + # Try to use section param + preferences_obj = registry.preferences(section=kwargs.get("section", None)) + + else: + # display all preferences in the form + preferences_obj = registry.preferences() + + fields = OrderedDict() + instances = [] + if "model" in kwargs: + # backward compat, see #212 + manager_kwargs = kwargs.get("model") + else: + manager_kwargs = {"instance": kwargs.get("instance", None)} + manager = registry.manager(**manager_kwargs) + + for preference in preferences_obj: + f = preference.field + instance = manager.get_db_pref( + section=preference.section.name, name=preference.name + ) + f.initial = instance.value + fields[preference.identifier()] = f + instances.append(instance) + + form_class = type("Custom" + form_base_class.__name__, (form_base_class,), {}) + form_class.base_fields = fields + form_class.preferences = preferences_obj + form_class.instances = instances + form_class.manager = manager + return form_class + + +def global_preference_form_builder(preferences=[], **kwargs): + """ + A shortcut :py:func:`preference_form_builder(GlobalPreferenceForm, preferences, **kwargs)` + """ + return preference_form_builder(GlobalPreferenceForm, preferences, **kwargs) + + +class PreferenceForm(forms.Form): + + registry = None + + def update_preferences(self, **kwargs): + for instance in self.instances: + self.manager.update_db_pref( + instance.preference.section.name, + instance.preference.name, + self.cleaned_data[instance.preference.identifier()], + ) + + +class GlobalPreferenceForm(PreferenceForm): + + registry = global_preferences_registry diff --git a/dynamic_preferences/locale/ar/LC_MESSAGES/django.po b/dynamic_preferences/locale/ar/LC_MESSAGES/django.po new file mode 100644 index 0000000..fcc026d --- /dev/null +++ b/dynamic_preferences/locale/ar/LC_MESSAGES/django.po @@ -0,0 +1,60 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2018-11-08 10:37+0100\n" +"PO-Revision-Date: 2018-11-09 17:15+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " +"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" +"Language: ar\n" +"X-Generator: Poedit 2.1.1\n" + +#: .\admin.py:56 +msgid "Default Value" +msgstr "القيمة الافتراضية" + +#: .\admin.py:65 +msgid "Section Name" +msgstr "إسم القسم" + +#: .\apps.py:9 +msgid "Dynamic Preferences" +msgstr "التفضيلات الديناميكية" + +#: .\models.py:25 +msgid "Name" +msgstr "الاسم" + +#: .\models.py:28 +msgid "Raw Value" +msgstr "القيمة الأولية" + +#: .\models.py:42 +msgid "Verbose Name" +msgstr "اسم مطول" + +#: .\models.py:47 +msgid "Help Text" +msgstr "نص المساعدة" + +#: .\models.py:84 +msgid "Global preference" +msgstr "التفضيل العام" + +#: .\models.py:85 +msgid "Global preferences" +msgstr "التفضيل العام" + +#: .\templates\dynamic_preferences\form.html:11 +msgid "Submit" +msgstr "إرسال" diff --git a/dynamic_preferences/locale/de/LC_MESSAGES/django.po b/dynamic_preferences/locale/de/LC_MESSAGES/django.po new file mode 100644 index 0000000..76a18f2 --- /dev/null +++ b/dynamic_preferences/locale/de/LC_MESSAGES/django.po @@ -0,0 +1,71 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-04-15 13:48+0200\n" +"PO-Revision-Date: 2018-11-09 17:14+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: 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" +"X-Generator: Poedit 2.1.1\n" + +#: .\admin.py:56 +msgid "Default Value" +msgstr "Standardwert" + +#: .\admin.py:65 .\models.py:22 +msgid "Section Name" +msgstr "Abschnitt" + +#: .\apps.py:9 +msgid "Dynamic Preferences" +msgstr "Dynamische Einstellungen" + +#: .\models.py:25 +msgid "Name" +msgstr "Name" + +#: .\models.py:28 +msgid "Raw Value" +msgstr "Wert" + +#: .\models.py:42 +msgid "Verbose Name" +msgstr "Bezeichnung" + +#: .\models.py:47 +msgid "Help Text" +msgstr "Hilfetext" + +#: .\models.py:84 +msgid "Global preference" +msgstr "Globale Einstellung" + +#: .\models.py:85 +msgid "Global preferences" +msgstr "Globale Einstellungen" + +#: .\templates\dynamic_preferences\form.html:11 +msgid "Submit" +msgstr "Absenden" + +#: .\users\apps.py:11 +msgid "Preferences - Users" +msgstr "Einstellungen - Benutzer" + +#: .\users\models.py:15 +msgid "user preference" +msgstr "Benutzer Einstellung" + +#: .\users\models.py:16 +msgid "user preferences" +msgstr "Benutzer Einstellungen" diff --git a/dynamic_preferences/locale/fa/LC_MESSAGES/django.po b/dynamic_preferences/locale/fa/LC_MESSAGES/django.po new file mode 100644 index 0000000..be7d11e --- /dev/null +++ b/dynamic_preferences/locale/fa/LC_MESSAGES/django.po @@ -0,0 +1,71 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-02-16 15:12+0330\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \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" + +#: admin.py:69 +msgid "Default Value" +msgstr "مقدار پیشفرض" + +#: admin.py:78 models.py:30 +msgid "Section Name" +msgstr "عنوان بخش" + +#: apps.py:10 +msgid "Dynamic Preferences" +msgstr "تنظیمات" + +#: models.py:34 +msgid "Name" +msgstr "نام" + +#: models.py:37 +msgid "Raw Value" +msgstr "مقدار" + +#: models.py:51 +msgid "Verbose Name" +msgstr "نام" + +#: models.py:57 +msgid "Help Text" +msgstr "متن راهنما" + +#: models.py:94 +msgid "Global preference" +msgstr "تنطیمات عمومی" + +#: models.py:95 +msgid "Global preferences" +msgstr "تنطیمات عمومی" + +#: templates/dynamic_preferences/form.html:11 +msgid "Submit" +msgstr "ثبت" + +#: users/apps.py:11 +msgid "Preferences - Users" +msgstr "" + +#: users/models.py:14 +msgid "user preference" +msgstr "" + +#: users/models.py:15 +msgid "user preferences" +msgstr "" diff --git a/dynamic_preferences/locale/fr/LC_MESSAGES/django.po b/dynamic_preferences/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000..cc9d2fc --- /dev/null +++ b/dynamic_preferences/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,59 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2018-11-08 10:37+0100\n" +"PO-Revision-Date: 2018-11-09 17:14+0100\n" +"Last-Translator: \n" +"Language-Team: \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" +"Language: fr\n" +"X-Generator: Poedit 2.1.1\n" + +#: .\admin.py:56 +msgid "Default Value" +msgstr "Valeur par défaut" + +#: .\admin.py:65 +msgid "Section Name" +msgstr "Nom de la Section" + +#: .\apps.py:9 +msgid "Dynamic Preferences" +msgstr "Préférences dynamiques" + +#: .\models.py:25 +msgid "Name" +msgstr "Nom" + +#: .\models.py:28 +msgid "Raw Value" +msgstr "Valeur RAW" + +#: .\models.py:42 +msgid "Verbose Name" +msgstr "Nom détaillé" + +#: .\models.py:47 +msgid "Help Text" +msgstr "Texte d'Aide" + +#: .\models.py:84 +msgid "Global preference" +msgstr "Préférence globale" + +#: .\models.py:85 +msgid "Global preferences" +msgstr "Préférences globales" + +#: .\templates\dynamic_preferences\form.html:11 +msgid "Submit" +msgstr "Valider" diff --git a/dynamic_preferences/locale/pl/LC_MESSAGES/django.po b/dynamic_preferences/locale/pl/LC_MESSAGES/django.po new file mode 100644 index 0000000..6d00cd1 --- /dev/null +++ b/dynamic_preferences/locale/pl/LC_MESSAGES/django.po @@ -0,0 +1,71 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-04-15 13:48+0200\n" +"PO-Revision-Date: 2020-09-23 23:59+0200\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: pl\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" +"X-Generator: Poedit 2.4.1\n" + +#: .\admin.py:56 +msgid "Default Value" +msgstr "Wartość domyślna" + +#: .\admin.py:65 .\models.py:22 +msgid "Section Name" +msgstr "Nazwa sekcji" + +#: .\apps.py:9 +msgid "Dynamic Preferences" +msgstr "Dynamiczne Preferencje" + +#: .\models.py:25 +msgid "Name" +msgstr "Nazwa" + +#: .\models.py:28 +msgid "Raw Value" +msgstr "Surowa wartość" + +#: .\models.py:42 +msgid "Verbose Name" +msgstr "Nazwa Szczegółowa" + +#: .\models.py:47 +msgid "Help Text" +msgstr "Tekst pomocy" + +#: .\models.py:84 +msgid "Global preference" +msgstr "Globalna preferencja" + +#: .\models.py:85 +msgid "Global preferences" +msgstr "Globalne Preferencje" + +#: .\templates\dynamic_preferences\form.html:11 +msgid "Submit" +msgstr "Wyślij" + +#: .\users\apps.py:11 +msgid "Preferences - Users" +msgstr "Preferencje - Użytkownicy" + +#: .\users\models.py:15 +msgid "user preference" +msgstr "preferencja użytkownika" + +#: .\users\models.py:16 +msgid "user preferences" +msgstr "preferencje użytkownika" diff --git a/dynamic_preferences/management/__init__.py b/dynamic_preferences/management/__init__.py new file mode 100644 index 0000000..bcbc5d6 --- /dev/null +++ b/dynamic_preferences/management/__init__.py @@ -0,0 +1 @@ +__author__ = "agateblue" diff --git a/dynamic_preferences/management/commands/__init__.py b/dynamic_preferences/management/commands/__init__.py new file mode 100644 index 0000000..bcbc5d6 --- /dev/null +++ b/dynamic_preferences/management/commands/__init__.py @@ -0,0 +1 @@ +__author__ = "agateblue" diff --git a/dynamic_preferences/management/commands/checkpreferences.py b/dynamic_preferences/management/commands/checkpreferences.py new file mode 100644 index 0000000..65ac6da --- /dev/null +++ b/dynamic_preferences/management/commands/checkpreferences.py @@ -0,0 +1,76 @@ +from django.core.management.base import BaseCommand +from dynamic_preferences.exceptions import NotFoundInRegistry +from dynamic_preferences.models import GlobalPreferenceModel +from dynamic_preferences.registries import ( + global_preferences_registry, + preference_models, +) +from dynamic_preferences.settings import preferences_settings + + +def delete_preferences(queryset): + """ + Delete preferences objects if they are not present in registry. + Return a list of deleted objects + """ + deleted = [] + + # Iterate through preferences. If an error is raised when accessing + # preference object, just delete it + for p in queryset: + try: + p.registry.get(section=p.section, name=p.name, fallback=False) + except NotFoundInRegistry: + p.delete() + deleted.append(p) + + return deleted + + +class Command(BaseCommand): + help = ( + "Find and delete preferences from database if they don't exist in " + "registries. Create preferences that are not present in database" + "(except when invoked with --skip_create)." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--skip_create", + action="store_true", + help="Forces to skip the creation step for missing preferences", + ) + + def handle(self, *args, **options): + skip_create = options["skip_create"] + + # Create needed preferences + # Global + if not skip_create: + self.stdout.write("Creating missing global preferences...") + manager = global_preferences_registry.manager() + manager.all() + + deleted = delete_preferences(GlobalPreferenceModel.objects.all()) + message = "Deleted {deleted} global preferences".format(deleted=len(deleted)) + self.stdout.write(message) + + for preference_model, registry in preference_models.items(): + deleted = delete_preferences(preference_model.objects.all()) + message = "Deleted {deleted} {model} preferences".format( + deleted=len(deleted), + model=preference_model.__name__, + ) + self.stdout.write(message) + if not hasattr(preference_model, "get_instance_model"): + continue + + if skip_create: + continue + + message = "Creating missing preferences for {model} model...".format( + model=preference_model.get_instance_model().__name__, + ) + self.stdout.write(message) + for instance in preference_model.get_instance_model().objects.all(): + getattr(instance, preferences_settings.MANAGER_ATTRIBUTE).all() diff --git a/dynamic_preferences/managers.py b/dynamic_preferences/managers.py new file mode 100644 index 0000000..7ab673c --- /dev/null +++ b/dynamic_preferences/managers.py @@ -0,0 +1,239 @@ +try: + from collections.abc import Mapping +except ImportError: + from collections import Mapping + +from .settings import preferences_settings +from .exceptions import CachedValueNotFound, DoesNotExist +from .signals import preference_updated + + +class PreferencesManager(Mapping): + + """Handle retrieving / caching of preferences""" + + def __init__(self, model, registry, **kwargs): + self.model = model + self.registry = registry + self.instance = kwargs.get("instance") + + @property + def queryset(self): + qs = self.model.objects.all() + if self.instance: + qs = qs.filter(instance=self.instance) + return qs + + @property + def cache(self): + from django.core.cache import caches + + return caches[preferences_settings.CACHE_NAME] + + def __getitem__(self, key): + return self.get(key) + + def __setitem__(self, key, value): + section, name = self.parse_lookup(key) + preference = self.registry.get(section=section, name=name, fallback=False) + preference.validate(value) + self.update_db_pref(section=section, name=name, value=value) + + def __repr__(self): + return repr(self.all()) + + def __iter__(self): + return self.all().__iter__() + + def __len__(self): + return len(self.all()) + + def by_name(self): + """Return a dictionary with preferences identifiers and values, but without the section name in the identifier""" + return { + key.split(preferences_settings.SECTION_KEY_SEPARATOR)[-1]: value + for key, value in self.all().items() + } + + def get_by_name(self, name): + return self.get(self.registry.get_by_name(name).identifier()) + + def get_cache_key(self, section, name): + """Return the cache key corresponding to a given preference""" + if not self.instance: + return "dynamic_preferences_{0}_{1}_{2}".format( + self.model.__name__, section, name + ) + return "dynamic_preferences_{0}_{1}_{2}_{3}".format( + self.model.__name__, self.instance.pk, section, name, self.instance.pk + ) + + def from_cache(self, section, name): + """Return a preference raw_value from cache""" + cached_value = self.cache.get( + self.get_cache_key(section, name), CachedValueNotFound + ) + + if cached_value is CachedValueNotFound: + raise CachedValueNotFound + + if cached_value == preferences_settings.CACHE_NONE_VALUE: + cached_value = None + return self.registry.get(section=section, name=name).serializer.deserialize( + cached_value + ) + + def many_from_cache(self, preferences): + """ + Return cached value for given preferences + missing preferences will be skipped + """ + keys = {p: self.get_cache_key(p.section.name, p.name) for p in preferences} + cached = self.cache.get_many(list(keys.values())) + + for k, v in cached.items(): + # we replace dummy cached values by None here, if needed + if v == preferences_settings.CACHE_NONE_VALUE: + cached[k] = None + + # we have to remap returned value since the underlying cached keys + # are not usable for an end user + return { + p.identifier(): p.serializer.deserialize(cached[k]) + for p, k in keys.items() + if k in cached + } + + def to_cache(self, pref): + """ + Update/create the cache value for the given preference model instance + """ + key = self.get_cache_key(pref.section, pref.name) + value = pref.raw_value + if value is None or value == "": + # some cache backends refuse to cache None or empty values + # resulting in more DB queries, so we cache an arbitrary value + # to ensure the cache is hot (even with empty values) + value = preferences_settings.CACHE_NONE_VALUE + self.cache.set(key, value) + + def pref_obj(self, section, name): + return self.registry.get(section=section, name=name) + + def parse_lookup(self, lookup): + try: + section, name = lookup.split(preferences_settings.SECTION_KEY_SEPARATOR) + except ValueError: + name = lookup + section = None + return section, name + + def get(self, key, no_cache=False): + """Return the value of a single preference using a dotted path key + :arg no_cache: if true, the cache is bypassed + """ + section, name = self.parse_lookup(key) + preference = self.registry.get(section=section, name=name, fallback=False) + if no_cache or not preferences_settings.ENABLE_CACHE: + return self.get_db_pref(section=section, name=name).value + + try: + return self.from_cache(section, name) + except CachedValueNotFound: + pass + + db_pref = self.get_db_pref(section=section, name=name) + self.to_cache(db_pref) + return db_pref.value + + def get_db_pref(self, section, name): + try: + pref = self.queryset.get(section=section, name=name) + except self.model.DoesNotExist: + pref_obj = self.pref_obj(section=section, name=name) + pref = self.create_db_pref( + section=section, name=name, value=pref_obj.get("default") + ) + + return pref + + def update_db_pref(self, section, name, value): + try: + db_pref = self.queryset.get(section=section, name=name) + old_value = db_pref.value + db_pref.value = value + db_pref.save() + preference_updated.send( + sender=self.__class__, + section=section, + name=name, + old_value=old_value, + new_value=value, + ) + except self.model.DoesNotExist: + return self.create_db_pref(section, name, value) + + return db_pref + + def create_db_pref(self, section, name, value): + kwargs = { + "section": section, + "name": name, + } + if self.instance: + kwargs["instance"] = self.instance + + # this is a just a shortcut to get the raw, serialized value + # so we can pass it to get_or_create + m = self.model(**kwargs) + m.value = value + raw_value = m.raw_value + + db_pref, created = self.model.objects.get_or_create(**kwargs) + if created and db_pref.raw_value != raw_value: + db_pref.raw_value = raw_value + db_pref.save() + + return db_pref + + def all(self): + """Return a dictionary containing all preferences by section + Loaded from cache or from db in case of cold cache + """ + if not preferences_settings.ENABLE_CACHE: + return self.load_from_db() + + preferences = self.registry.preferences() + + # first we hit the cache once for all existing preferences + a = self.many_from_cache(preferences) + if len(a) == len(preferences): + return a # avoid database hit if not necessary + + # then we fill those that miss, but exist in the database + # (just hit the database for all of them, filtering is complicated, and + # in most cases you'd need to grab the majority of them anyway) + a.update(self.load_from_db(cache=True)) + return a + + def load_from_db(self, cache=False): + """Return a dictionary of preferences by section directly from DB""" + a = {} + db_prefs = {p.preference.identifier(): p for p in self.queryset} + for preference in self.registry.preferences(): + try: + db_pref = db_prefs[preference.identifier()] + except KeyError: + db_pref = self.create_db_pref( + section=preference.section.name, + name=preference.name, + value=preference.get("default"), + ) + else: + # cache if create_db_pref() hasn't already done so + if cache: + self.to_cache(db_pref) + + a[preference.identifier()] = db_pref.value + + return a diff --git a/dynamic_preferences/migrations/0001_initial.py b/dynamic_preferences/migrations/0001_initial.py new file mode 100644 index 0000000..2ca4437 --- /dev/null +++ b/dynamic_preferences/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="GlobalPreferenceModel", + fields=[ + ( + "id", + models.AutoField( + primary_key=True, + serialize=False, + verbose_name="ID", + auto_created=True, + ), + ), + ( + "section", + models.CharField( + blank=True, + default=None, + null=True, + max_length=150, + db_index=True, + ), + ), + ("name", models.CharField(max_length=150, db_index=True)), + ("raw_value", models.TextField(blank=True, null=True)), + ], + options={ + "verbose_name_plural": "global preferences", + "verbose_name": "global preference", + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name="globalpreferencemodel", + unique_together=set([("section", "name")]), + ), + ] diff --git a/dynamic_preferences/migrations/0002_auto_20150712_0332.py b/dynamic_preferences/migrations/0002_auto_20150712_0332.py new file mode 100644 index 0000000..4b8730b --- /dev/null +++ b/dynamic_preferences/migrations/0002_auto_20150712_0332.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ("dynamic_preferences", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="globalpreferencemodel", + name="name", + field=models.CharField(max_length=150, db_index=True), + ), + migrations.AlterField( + model_name="globalpreferencemodel", + name="section", + field=models.CharField( + max_length=150, blank=True, db_index=True, default=None, null=True + ), + ), + ] diff --git a/dynamic_preferences/migrations/0003_auto_20151223_1407.py b/dynamic_preferences/migrations/0003_auto_20151223_1407.py new file mode 100644 index 0000000..86f74eb --- /dev/null +++ b/dynamic_preferences/migrations/0003_auto_20151223_1407.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("dynamic_preferences", "0002_auto_20150712_0332"), + ] + + operations = [ + migrations.AlterField( + model_name="globalpreferencemodel", + name="name", + field=models.CharField(max_length=150, db_index=True), + preserve_default=True, + ), + migrations.AlterField( + model_name="globalpreferencemodel", + name="section", + field=models.CharField( + max_length=150, + null=True, + default=None, + db_index=True, + blank=True, + verbose_name="Section Name", + ), + preserve_default=True, + ), + ] diff --git a/dynamic_preferences/migrations/0004_move_user_model.py b/dynamic_preferences/migrations/0004_move_user_model.py new file mode 100644 index 0000000..3889c91 --- /dev/null +++ b/dynamic_preferences/migrations/0004_move_user_model.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + """ + Migration to move the user preferences to a dedicated app, see #33 + Borrowed from http://stackoverflow.com/a/26472482/2844093 + """ + + dependencies = [ + ("dynamic_preferences", "0003_auto_20151223_1407"), + ] + + # cf https://github.com/agateblue/django-dynamic-preferences/pull/142 + operations = [] diff --git a/dynamic_preferences/migrations/0005_auto_20181120_0848.py b/dynamic_preferences/migrations/0005_auto_20181120_0848.py new file mode 100644 index 0000000..fd014f3 --- /dev/null +++ b/dynamic_preferences/migrations/0005_auto_20181120_0848.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("dynamic_preferences", "0004_move_user_model"), + ] + + operations = [ + migrations.AlterModelOptions( + name="globalpreferencemodel", + options={ + "verbose_name": "Global preference", + "verbose_name_plural": "Global preferences", + }, + ), + migrations.AlterField( + model_name="globalpreferencemodel", + name="name", + field=models.CharField(db_index=True, max_length=150, verbose_name="Name"), + ), + migrations.AlterField( + model_name="globalpreferencemodel", + name="raw_value", + field=models.TextField(blank=True, null=True, verbose_name="Raw Value"), + ), + ] diff --git a/dynamic_preferences/migrations/0006_auto_20191001_2236.py b/dynamic_preferences/migrations/0006_auto_20191001_2236.py new file mode 100644 index 0000000..798ae23 --- /dev/null +++ b/dynamic_preferences/migrations/0006_auto_20191001_2236.py @@ -0,0 +1,25 @@ +# Generated by Django 2.1.7 on 2019-10-01 14:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("dynamic_preferences", "0005_auto_20181120_0848"), + ] + + operations = [ + migrations.AlterField( + model_name="globalpreferencemodel", + name="section", + field=models.CharField( + blank=True, + db_index=True, + default=None, + max_length=150, + null=True, + verbose_name="Section Name", + ), + ), + ] diff --git a/dynamic_preferences/migrations/__init__.py b/dynamic_preferences/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dynamic_preferences/models.py b/dynamic_preferences/models.py new file mode 100644 index 0000000..f6e6482 --- /dev/null +++ b/dynamic_preferences/models.py @@ -0,0 +1,135 @@ +""" +Preference models, queryset and managers that handle the logic for persisting preferences. +""" + +from django.db import models +from django.db.models.query import QuerySet +from django.conf import settings +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from dynamic_preferences.registries import ( + preference_models, + global_preferences_registry, +) +from .utils import update + + +class BasePreferenceModel(models.Model): + + """ + A base model with common logic for all preferences models. + """ + + #: The section under which the preference is declared + section = models.CharField( + max_length=150, + db_index=True, + blank=True, + null=True, + default=None, + verbose_name=_("Section Name"), + ) + + #: a name for the preference + name = models.CharField(_("Name"), max_length=150, db_index=True) + + #: a value, serialized to a string. This field should not be accessed directly, use :py:attr:`BasePreferenceModel.value` instead + raw_value = models.TextField(_("Raw Value"), null=True, blank=True) + + class Meta: + abstract = True + app_label = "dynamic_preferences" + + @cached_property + def preference(self): + return self.registry.get(section=self.section, name=self.name, fallback=True) + + @property + def verbose_name(self): + return self.preference.get("verbose_name", self.preference.identifier) + + verbose_name.fget.short_description = _("Verbose Name") + + @property + def help_text(self): + return self.preference.get("help_text", "") + + help_text.fget.short_description = _("Help Text") + + def set_value(self, value): + """ + Save serialized self.value to self.raw_value + """ + self.raw_value = self.preference.serializer.serialize(value) + + def get_value(self): + """ + Return deserialized self.raw_value + """ + return self.preference.serializer.deserialize(self.raw_value) + + value = property(get_value, set_value) + + def save(self, **kwargs): + + if self.pk is None and not self.raw_value: + self.value = self.preference.get("default") + super(BasePreferenceModel, self).save(**kwargs) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "{0} - {1}/{2}".format(self.__class__.__name__, self.section, self.name) + + +class GlobalPreferenceModel(BasePreferenceModel): + + registry = global_preferences_registry + + class Meta: + unique_together = ("section", "name") + app_label = "dynamic_preferences" + + verbose_name = _("Global preference") + verbose_name_plural = _("Global preferences") + + +class PerInstancePreferenceModel(BasePreferenceModel): + + """For preferences that are tied to a specific model instance""" + + #: the instance which is concerned by the preference + #: use a ForeignKey pointing to the model of your choice + instance = None + + class Meta(BasePreferenceModel.Meta): + unique_together = ("instance", "section", "name") + abstract = True + + @classmethod + def get_instance_model(cls): + return cls._meta.get_field("instance").remote_field.model + + +global_preferences_registry.preference_model = GlobalPreferenceModel + +# Create default preferences for new instances + +from django.db.models.signals import post_save + + +def invalidate_cache(sender, created, instance, **kwargs): + if not isinstance(instance, BasePreferenceModel): + return + registry = preference_models.get_by_preference(instance) + linked_instance = getattr(instance, "instance", None) + kwargs = {} + if linked_instance: + kwargs["instance"] = linked_instance + + manager = registry.manager(**kwargs) + manager.to_cache(instance) + + +post_save.connect(invalidate_cache) diff --git a/dynamic_preferences/preferences.py b/dynamic_preferences/preferences.py new file mode 100644 index 0000000..35f453f --- /dev/null +++ b/dynamic_preferences/preferences.py @@ -0,0 +1,104 @@ +""" +Preferences are regular Python objects that can be declared within any django app. +Once declared and registered, they can be edited by admins (for :py:class:`SitePreference` and :py:class:`GlobalPreference`) +and regular Users (for :py:class:`UserPreference`) + +UserPreference, SitePreference and GlobalPreference are mapped to corresponding PreferenceModel, +which store the actual values. + +""" +from __future__ import unicode_literals +import re +import warnings + +from .settings import preferences_settings +from .exceptions import MissingDefault +from .serializers import UNSET + + +class InvalidNameError(ValueError): + pass + + +def check_name(name, obj): + error = None + if not re.match("^\w+$", name): + error = "Non-alphanumeric / underscore characters are forbidden in section and preferences names" + if preferences_settings.SECTION_KEY_SEPARATOR in name: + error = 'Sequence "{0}" is forbidden in section and preferences name, since it is used to access values via managers'.format( + preferences_settings.SECTION_KEY_SEPARATOR + ) + + if error: + full_message = 'Invalid name "{0}" while instanciating {1} object: {2}'.format( + name, obj, error + ) + raise InvalidNameError(full_message) + + +class Section(object): + def __init__(self, name, verbose_name=None): + self.name = name + self.verbose_name = verbose_name or name + if preferences_settings.VALIDATE_NAMES and name: + check_name(self.name, self) + + def __str__(self): + if not self.verbose_name: + return "" + return str(self.verbose_name) + + +EMPTY_SECTION = Section(None) + + +class AbstractPreference(object): + """ + A base class that handle common logic for preferences + """ + + #: The section under which the preference will be registered + section = EMPTY_SECTION + + #: The preference name + name = "" + + #: A default value for the preference + default = UNSET + + def __init__(self, registry=None): + if preferences_settings.VALIDATE_NAMES: + check_name(self.name, self) + if self.section and not hasattr(self.section, "name"): + self.section = Section(name=self.section) + warnings.warn( + "Implicit section instanciation is deprecated and " + "will be removed in future versions of django-dynamic-preferences", + DeprecationWarning, + stacklevel=2, + ) + + self.registry = registry + if self.default == UNSET and not getattr(self, "get_default", None): + raise MissingDefault + + def get(self, attr, default=None): + getter = "get_{0}".format(attr) + if hasattr(self, getter): + return getattr(self, getter)() + return getattr(self, attr, default) + + @property + def model(self): + return self.registry.preference_model + + def identifier(self): + """ + Return the name and the section of the Preference joined with a separator, with the form `sectionname` + """ + + if not self.section or not self.section.name: + return self.name + return preferences_settings.SECTION_KEY_SEPARATOR.join( + [self.section.name, self.name] + ) diff --git a/dynamic_preferences/processors.py b/dynamic_preferences/processors.py new file mode 100644 index 0000000..668365d --- /dev/null +++ b/dynamic_preferences/processors.py @@ -0,0 +1,10 @@ +from .registries import global_preferences_registry as gpr + + +def global_preferences(request): + """ + Pass the values of global preferences to template context. + You can then access value with `global_preferences.
.` + """ + manager = gpr.manager() + return {"global_preferences": manager.all()} diff --git a/dynamic_preferences/registries.py b/dynamic_preferences/registries.py new file mode 100644 index 0000000..f394b21 --- /dev/null +++ b/dynamic_preferences/registries.py @@ -0,0 +1,234 @@ +from django.core.exceptions import FieldDoesNotExist +from django.apps import apps + +# import the logging library +import warnings +import logging +import collections +import persisting_theory + +# Get an instance of a logger +logger = logging.getLogger(__name__) + + +#: The package where autodiscover will try to find preferences to register + +from .managers import PreferencesManager +from .settings import preferences_settings +from .exceptions import NotFoundInRegistry +from .types import StringPreference +from .preferences import EMPTY_SECTION, Section + + +class MissingPreference(StringPreference): + """ + Used as a fallback when the preference object is not found in registries + This can happen for example when you delete a preference in the code, + but don't remove the corresponding entries in database + """ + + pass + + +class PreferenceModelsRegistry(persisting_theory.Registry): + """Store relationships beetween preferences model and preferences registry""" + + look_into = preferences_settings.REGISTRY_MODULE + + def register(self, preference_model, preference_registry): + self[preference_model] = preference_registry + preference_registry.preference_model = preference_model + if not hasattr(preference_model, "registry"): + setattr(preference_model, "registry", preference_registry) + self.attach_manager(preference_model, preference_registry) + + def attach_manager(self, model, registry): + if not hasattr(model, "instance"): + return + + def instance_getter(self): + return registry.manager(instance=self) + + getter = property(instance_getter) + instance_class = model._meta.get_field("instance").remote_field.model + setattr(instance_class, preferences_settings.MANAGER_ATTRIBUTE, getter) + + def get_by_preference(self, preference): + return self[ + preference._meta.proxy_for_model + if preference._meta.proxy + else preference.__class__ + ] + + def get_by_instance(self, instance): + """Return a preference registry using a model instance""" + # we iterate through registered preference models in order to get the instance class + # and check if instance is an instance of this class + for model, registry in self.items(): + try: + instance_class = model._meta.get_field("instance").remote_field.model + if isinstance(instance, instance_class): + return registry + + except FieldDoesNotExist: # global preferences + pass + return None + + +preference_models = PreferenceModelsRegistry() + + +class PreferenceRegistry(persisting_theory.Registry): + + """ + Registries are special dictionaries that are used by dynamic-preferences to register and access your preferences. + dynamic-preferences has one registry per Preference type: + + - :py:const:`user_preferences` + - :py:const:`site_preferences` + - :py:const:`global_preferences` + + In order to register preferences automatically, you must call :py:func:`autodiscover` in your URLconf. + + """ + + look_into = preferences_settings.REGISTRY_MODULE + + #: a name to identify the registry + name = "preferences_registry" + preference_model = None + + #: used to reverse urls for sections in form views/templates + section_url_namespace = None + + def __init__(self, *args, **kwargs): + super(PreferenceRegistry, self).__init__(*args, **kwargs) + self.section_objects = collections.OrderedDict() + + def register(self, preference_class): + """ + Store the given preference class in the registry. + + :param preference_class: a :py:class:`prefs.Preference` subclass + """ + preference = preference_class(registry=self) + self.section_objects[preference.section.name] = preference.section + + try: + self[preference.section.name][preference.name] = preference + + except KeyError: + self[preference.section.name] = collections.OrderedDict() + self[preference.section.name][preference.name] = preference + + return preference_class + + def _fallback(self, section_name, pref_name): + """ + Create a fallback preference object, + This is used when you have model instances that do not match + any registered preferences, see #41 + """ + message = ( + "Creating a fallback preference with " + + 'section "{}" and name "{}".' + + "This means you have preferences in your database that " + + "don't match any registered preference. " + + "If you want to delete these entries, please refer to the " + + "documentation: https://django-dynamic-preferences.readthedocs.io/en/latest/lifecycle.html" + ) # NOQA + warnings.warn(message.format(section_name, pref_name)) + + class Fallback(MissingPreference): + section = Section(name=section_name) if section_name else None + name = pref_name + default = "" + help_text = "Obsolete: missing in registry" + + return Fallback() + + def get(self, name, section=None, fallback=False): + """ + Returns a previously registered preference + + :param section: The section name under which the preference is registered + :type section: str. + :param name: The name of the preference. You can use dotted notation 'section.name' if you want to avoid providing section param + :type name: str. + :param fallback: Should we return a dummy preference object instead of raising an error if no preference is found? + :type name: bool. + :return: a :py:class:`prefs.BasePreference` instance + """ + # try dotted notation + try: + _section, name = name.split(preferences_settings.SECTION_KEY_SEPARATOR) + return self[_section][name] + + except ValueError: + pass + + # use standard params + try: + return self[section][name] + + except KeyError: + if fallback: + return self._fallback(section_name=section, pref_name=name) + raise NotFoundInRegistry( + "No such preference in {0} with section={1} and name={2}".format( + self.__class__.__name__, section, name + ) + ) + + def get_by_name(self, name): + """Get a preference by name only (no section)""" + for section in self.values(): + for preference in section.values(): + if preference.name == name: + return preference + raise NotFoundInRegistry( + "No such preference in {0} with name={1}".format( + self.__class__.__name__, name + ) + ) + + def manager(self, **kwargs): + """Return a preference manager that can be used to retrieve preference values""" + return PreferencesManager(registry=self, model=self.preference_model, **kwargs) + + def sections(self): + """ + :return: a list of apps with registered preferences + :rtype: list + """ + + return self.keys() + + def preferences(self, section=None): + """ + Return a list of all registered preferences + or a list of preferences registered for a given section + + :param section: The section name under which the preference is registered + :type section: str. + :return: a list of :py:class:`prefs.BasePreference` instances + """ + + if section is None: + return [self[section][name] for section in self for name in self[section]] + else: + return [self[section][name] for name in self[section]] + + +class PerInstancePreferenceRegistry(PreferenceRegistry): + pass + + +class GlobalPreferenceRegistry(PreferenceRegistry): + section_url_namespace = "dynamic_preferences:global.section" + + def populate(self, **kwargs): + return self.models(**kwargs) + + +global_preferences_registry = GlobalPreferenceRegistry() diff --git a/dynamic_preferences/serializers.py b/dynamic_preferences/serializers.py new file mode 100644 index 0000000..9846ce6 --- /dev/null +++ b/dynamic_preferences/serializers.py @@ -0,0 +1,484 @@ +from __future__ import unicode_literals +import decimal +import os + +from datetime import date, timedelta, datetime, time + +from django.conf import settings +from django.core.validators import EMPTY_VALUES +from django.utils.dateparse import ( + parse_duration, + parse_datetime, + parse_date, + parse_time, +) +from django.utils.duration import duration_string +from django.utils.encoding import force_str +from django.utils.timezone import ( + utc, + is_aware, + make_aware, + make_naive, + get_default_timezone, +) +from six import string_types, text_type +from django.db.models.fields.files import FieldFile + + +class UnsetValue(object): + pass + + +UNSET = UnsetValue() + + +class SerializationError(Exception): + pass + + +class BaseSerializer: + """ + A serializer take a Python variable and returns a string that can be stored safely in database + """ + + exception = SerializationError + + @classmethod + def serialize(cls, value, **kwargs): + """ + Return a string from a Python var + """ + return cls.to_db(value, **kwargs) + + @classmethod + def deserialize(cls, value, **kwargs): + """ + Convert a python string to a var + """ + return cls.to_python(value, **kwargs) + + @classmethod + def to_python(cls, value, **kwargs): + raise NotImplementedError + + @classmethod + def to_db(cls, value, **kwargs): + return text_type(cls.clean_to_db_value(value)) + + @classmethod + def clean_to_db_value(cls, value): + return value + + +class InstanciatedSerializer(BaseSerializer): + """ + In some situations, such as with FileSerializer, + we need the serializer to be an instance and not a class + """ + + def serialize(self, value, **kwargs): + return self.to_db(value, **kwargs) + + def deserialize(self, value, **kwargs): + return self.to_python(value, **kwargs) + + def to_python(self, value, **kwargs): + raise NotImplementedError + + def to_db(self, value, **kwargs): + return text_type(self.clean_to_db_value(value)) + + def clean_to_db_value(self, value): + return value + + +class BooleanSerializer(BaseSerializer): + true = ( + "True", + "true", + "TRUE", + "1", + "YES", + "Yes", + "yes", + ) + + false = ( + "False", + "false", + "FALSE", + "0", + "No", + "no", + "NO", + ) + + @classmethod + def clean_to_db_value(cls, value): + if not isinstance(value, bool): + raise cls.exception("{0} is not a boolean".format(value)) + return value + + @classmethod + def to_python(cls, value, **kwargs): + + if value in cls.true: + return True + + elif value in cls.false: + return False + + else: + raise cls.exception( + "Value {0} can't be deserialized to a Boolean".format(value) + ) + + +class IntegerSerializer(BaseSerializer): + @classmethod + def clean_to_db_value(cls, value): + if not isinstance(value, int): + raise cls.exception("IntSerializer can only serialize int values") + return value + + @classmethod + def to_python(cls, value, **kwargs): + try: + return int(value) + except: + raise cls.exception("Value {0} cannot be converted to int".format(value)) + + +IntSerializer = IntegerSerializer + + +class DecimalSerializer(BaseSerializer): + @classmethod + def clean_to_db_value(cls, value): + if not isinstance(value, decimal.Decimal): + raise cls.exception( + "DecimalSerializer can only serialize Decimal instances" + ) + return value + + @classmethod + def to_python(cls, value, **kwargs): + try: + return decimal.Decimal(value) + except decimal.InvalidOperation: + raise cls.exception( + "Value {0} cannot be converted to decimal".format(value) + ) + + +class FloatSerializer(BaseSerializer): + @classmethod + def clean_to_db_value(cls, value): + if not isinstance(value, (int, float)): + raise cls.exception( + "FloatSerializer can only serialize float or int values" + ) + return float(value) + + @classmethod + def to_python(cls, value, **kwargs): + try: + return float(value) + except float.InvalidOperation: + raise cls.exception("Value {0} cannot be converted to float".format(value)) + + +from django.template import defaultfilters + + +class StringSerializer(BaseSerializer): + @classmethod + def to_db(cls, value, **kwargs): + if not isinstance(value, string_types): + raise cls.exception( + "Cannot serialize, value {0} is not a string".format(value) + ) + + if kwargs.get("escape_html", False): + return defaultfilters.force_escape(value) + else: + return value + + @classmethod + def to_python(cls, value, **kwargs): + """String deserialisation just return the value as a string""" + if not value: + return "" + try: + return str(value) + except: + pass + try: + return value.encode("utf-8") + except: + pass + raise cls.exception("Cannot deserialize value {0} tostring".format(value)) + + +class ModelSerializer(InstanciatedSerializer): + model = None + + def __init__(self, model): + self.model = model + + def to_db(self, value, **kwargs): + if not value or (value == UNSET): + return None + return str(value.pk) + + def to_python(self, value, **kwargs): + if value is None: + return + try: + pk = int(value) + return self.model.objects.get(pk=pk) + except: + raise self.exception("Value {0} cannot be converted to pk".format(value)) + + +class ModelMultipleSerializer(ModelSerializer): + separator = "," + sort = True + + def to_db(self, value, **kwargs): + if not value: + return + if hasattr(value, "pk"): + # Support single instances in this serializer to allow + # create_deletion_handler to work for model multiple choice preferences + value = [value.pk] + else: + value = list(value.values_list("pk", flat=True)) + + if self.sort: + value = sorted(value) + + return self.separator.join(map(str, value)) + + def to_python(self, value, **kwargs): + if value in EMPTY_VALUES: + return self.model.objects.none() + + try: + pks = value.split(",") + pks = [int(i) if str(i).isdigit() else str(i) for i in pks] + return self.model.objects.filter(pk__in=pks) + except: + raise self.exception("Array {0} cannot be converted to int".format(value)) + + +class PreferenceFieldFile(FieldFile): + """ + In order to have the same API that we have with models.FileField, + we must return a FieldFile object. However, there are various + things we have to override, since our files are not bound to a model + field. + """ + + def __init__(self, preference, storage, name): + super(FieldFile, self).__init__(None, name) + + # FieldFile also needs a model instance to save changes. + class FakeInstance(object): + """ + FieldFile needs a model instance to update when file is persisted + or deleted + """ + + def save(self): + return + + self.instance = FakeInstance() + + class FakeField(object): + """ + FieldFile needs a field object to generate a filename, persist + and delete files, so we are effectively mocking that. + """ + + name = "noop" + attname = "noop" + max_length = 10000 + + def generate_filename(field, instance, name): + return os.path.join(self.preference.get_upload_path(), f.name) + + self.field = FakeField() + self.storage = storage + self._committed = True + self.preference = preference + + +class FileSerializer(InstanciatedSerializer): + """ + Since this serializer requires additional data from the preference + especially the upload path, we cannot do it without binding it + to the preference + + it is therefore designed to be explicitely instanciated by the preference + object. + """ + + def __init__(self, preference): + self.preference = preference + + def to_db(self, f, **kwargs): + if not f: + return + saved_path = f.name + if not hasattr(f, "save"): + path = os.path.join(self.preference.get_upload_path(), f.name) + saved_path = self.preference.get_file_storage().save(path, f) + + return saved_path + + def to_python(self, value, **kwargs): + if not value: + return + storage = self.preference.get_file_storage() + + return PreferenceFieldFile( + preference=self.preference, storage=storage, name=value + ) + + +class DurationSerializer(BaseSerializer): + @classmethod + def to_db(cls, value, **kwargs): + if not isinstance(value, timedelta): + raise cls.exception( + "Cannot serialize, value {0} is not a timedelta".format(value) + ) + + return duration_string(value) + + @classmethod + def to_python(cls, value, **kwargs): + parsed = parse_duration(force_str(value)) + if parsed is None: + raise cls.exception( + "Value {0} cannot be converted to timedelta".format(value) + ) + return parsed + + +class DateSerializer(BaseSerializer): + @classmethod + def to_db(cls, value, **kwargs): + if not isinstance(value, date): + raise cls.exception( + "Cannot serialize, value {0} is not a date object".format(value) + ) + + return value.isoformat() + + @classmethod + def to_python(cls, value, **kwargs): + parsed = parse_date(force_str(value)) + if parsed is None: + raise cls.exception( + "Value {0} cannot be converted to a date object".format(value) + ) + + return parsed + + +class DateTimeSerializer(BaseSerializer): + @classmethod + def to_db(cls, value, **kwargs): + if not isinstance(value, datetime): + raise cls.exception( + "Cannot serialize, value {0} is not a datetime object".format(value) + ) + + value = cls.enforce_timezone(value) + + return value.isoformat() + + @classmethod + def enforce_timezone(cls, value): + """ + When `self.default_timezone` is `None`, always return naive datetimes. + When `self.default_timezone` is not `None`, always return aware datetimes. + """ + field_timezone = cls.default_timezone() + + if (field_timezone is not None) and not is_aware(value): + return make_aware(value, field_timezone) + elif (field_timezone is None) and is_aware(value): + return make_naive(value, utc) + return value + + @classmethod + def default_timezone(cls): + return get_default_timezone() if settings.USE_TZ else None + + @classmethod + def to_python(cls, value, **kwargs): + parsed = parse_datetime(force_str(value)) + if parsed is None: + raise cls.exception( + "Value {0} cannot be converted to a datetime object".format(value) + ) + return parsed + + +class TimeSerializer(BaseSerializer): + @classmethod + def to_db(cls, value, **kwargs): + if not isinstance(value, time): + raise cls.exception( + "Cannot serialize, value {0} is not a time object".format(value) + ) + + return value.isoformat() + + @classmethod + def to_python(cls, value, **kwargs): + parsed = parse_time(force_str(value)) + if parsed is None: + raise cls.exception( + "Value {0} cannot be converted to a time object".format(value) + ) + + return parsed + + +class MultipleSerializer(BaseSerializer): + separator = "," + sort = True + + @classmethod + def to_db(cls, value, **kwargs): + if not value: + return + + # This makes the use of the separator in choices safe by duplicating + # it in each value before they are joined later on + # Contract: choices keys cannot be empty + value = [str(v).replace(cls.separator, cls.separator * 2) for v in value] + if "" in value: + raise cls.exception("Choices must not be empty") + + if cls.sort: + value = sorted(value) + + return cls.separator.join(value) + + @classmethod + def to_python(cls, value, **kwargs): + if value in EMPTY_VALUES: + return [] + + ret = value.split(cls.separator) + # Duplication of separator is reverted (cf. to_db) + while "" in ret: + pos = ret.index("") + val = ret[pos - 1] + cls.separator + ret[pos + 1] + ret = ret[0 : pos - 1] + [val] + ret[pos + 2 :] + return ret diff --git a/dynamic_preferences/settings.py b/dynamic_preferences/settings.py new file mode 100644 index 0000000..20b9688 --- /dev/null +++ b/dynamic_preferences/settings.py @@ -0,0 +1,70 @@ +# Taken from django-rest-framework +# https://github.com/tomchristie/django-rest-framework +# Copyright (c) 2011-2015, Tom Christie All rights reserved. + +from django.conf import settings + +SETTINGS_ATTR = "DYNAMIC_PREFERENCES" +USER_SETTINGS = None + + +DEFAULTS = { + # 'REGISTRY_MODULE': 'prefs', + # 'BASE_PREFIX': 'base', + # 'SECTIONS_PREFIX': 'sections', + # 'PREFERENCES_PREFIX': 'preferences', + # 'PERMISSIONS_PREFIX': 'permissions', + "MANAGER_ATTRIBUTE": "preferences", + "SECTION_KEY_SEPARATOR": "__", + "REGISTRY_MODULE": "dynamic_preferences_registry", + "ADMIN_ENABLE_CHANGELIST_FORM": False, + "ENABLE_GLOBAL_MODEL_AUTO_REGISTRATION": True, + "ENABLE_USER_PREFERENCES": True, + "ENABLE_CACHE": True, + "CACHE_NAME": "default", + "VALIDATE_NAMES": True, + "FILE_PREFERENCE_UPLOAD_DIR": "dynamic_preferences", + # this will be used to cache empty values, since some cache backends + # does not support it on get_many + "CACHE_NONE_VALUE": "__dynamic_preferences_empty_value", +} + + +class PreferenceSettings(object): + """ + A settings object, that allows API settings to be accessed as properties. + For example: + + from rest_framework.settings import api_settings + print(api_settings.DEFAULT_RENDERER_CLASSES) + + Any setting with string import paths will be automatically resolved + and return the class, rather than the string literal. + """ + + def __init__(self, defaults=None): + self.defaults = defaults or DEFAULTS + + @property + def user_settings(self): + return getattr(settings, SETTINGS_ATTR, {}) + + def __getattr__(self, attr): + if attr not in self.defaults.keys(): + raise AttributeError("Invalid preference setting: '%s'" % attr) + + try: + # Check if present in user settings + val = self.user_settings[attr] + except KeyError: + # Fall back to defaults + val = self.defaults[attr] + + # Cache the result + # We sometimes need to bypass that, like in tests + if getattr(settings, "CACHE_DYNAMIC_PREFERENCES_SETTINGS", True): + setattr(self, attr, val) + return val + + +preferences_settings = PreferenceSettings(DEFAULTS) diff --git a/dynamic_preferences/signals.py b/dynamic_preferences/signals.py new file mode 100644 index 0000000..fbec558 --- /dev/null +++ b/dynamic_preferences/signals.py @@ -0,0 +1,4 @@ +from django.dispatch import Signal + +# Arguments provided to listeners: "section", "name", "old_value" and "new_value" +preference_updated = Signal() diff --git a/dynamic_preferences/templates/dynamic_preferences/base.html b/dynamic_preferences/templates/dynamic_preferences/base.html new file mode 100644 index 0000000..9e9af57 --- /dev/null +++ b/dynamic_preferences/templates/dynamic_preferences/base.html @@ -0,0 +1,15 @@ + + + + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/dynamic_preferences/templates/dynamic_preferences/dyna_change_form.html b/dynamic_preferences/templates/dynamic_preferences/dyna_change_form.html new file mode 100644 index 0000000..2068afa --- /dev/null +++ b/dynamic_preferences/templates/dynamic_preferences/dyna_change_form.html @@ -0,0 +1,13 @@ +{% extends "admin/change_form.html" %} + +{% block scripts %} + {{ super.block }} + + + +{% endblock %} diff --git a/dynamic_preferences/templates/dynamic_preferences/form.html b/dynamic_preferences/templates/dynamic_preferences/form.html new file mode 100644 index 0000000..d0b6480 --- /dev/null +++ b/dynamic_preferences/templates/dynamic_preferences/form.html @@ -0,0 +1,13 @@ +{% extends "dynamic_preferences/base.html" %} +{% load i18n %} +{% block content %} + + {# we continue to pass the sections key in case someone subclassed the template and use these #} + {% include "dynamic_preferences/sections.html" with registry=registry sections=registry.sections %} + +
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/dynamic_preferences/templates/dynamic_preferences/sections.html b/dynamic_preferences/templates/dynamic_preferences/sections.html new file mode 100644 index 0000000..8a408b9 --- /dev/null +++ b/dynamic_preferences/templates/dynamic_preferences/sections.html @@ -0,0 +1,8 @@ + diff --git a/dynamic_preferences/templates/dynamic_preferences/testcontext.html b/dynamic_preferences/templates/dynamic_preferences/testcontext.html new file mode 100644 index 0000000..e69de29 diff --git a/dynamic_preferences/types.py b/dynamic_preferences/types.py new file mode 100644 index 0000000..babb02a --- /dev/null +++ b/dynamic_preferences/types.py @@ -0,0 +1,525 @@ +""" +You'll find here the final, concrete classes of preferences you can use +in your own project. + +""" +from django import forms +from django.db.models.signals import pre_delete + +from django.core.files.storage import default_storage + +from .preferences import AbstractPreference, Section +from .exceptions import MissingModel +from dynamic_preferences.serializers import * +from dynamic_preferences.settings import preferences_settings + + +class BasePreferenceType(AbstractPreference): + """ + Used as a base for all other preference classes. You should subclass + this one if you want to implement your own preference. + """ + + field_class = None + """ + A form field that will be used to display and edit the preference + use a class, not an instance. + + :Example: + + .. code-block:: python + + from django import forms + + class MyPreferenceType(BasePreferenceType): + field_class = forms.CharField + """ + + #: A serializer class (see dynamic_preferences.serializers) + serializer = None + + field_kwargs = {} + """ + Additional kwargs to be passed to the form field. + + :Example: + + .. code-block:: python + + class MyPreference(StringPreference): + + field_kwargs = { + 'required': False, + 'initial': 'Hello there' + } + """ + + @property + def initial(self): + return self.get_initial() + + def get_initial(self): + """ + :return: + initial data for form field + from field_attribute['initial'] or default + """ + return self.field_kwargs.get("initial", self.get("default")) + + @property + def field(self): + """ + :return: + an instance of a form field for this preference, with + the correct configuration (widget, initial value, validators...) + """ + return self.setup_field() + + def setup_field(self, **kwargs): + field_class = self.get("field_class") + field_kwargs = self.get_field_kwargs() + field_kwargs.update(kwargs) + return field_class(**field_kwargs) + + def get_field_kwargs(self): + """ + Return a dict of arguments to use as parameters for the field + class instianciation. + + This will use :py:attr:`field_kwargs` as a starter, + and use sensible defaults for a few attributes: + + - :py:attr:`instance.verbose_name` for the field label + - :py:attr:`instance.help_text` for the field help text + - :py:attr:`instance.widget` for the field widget + - :py:attr:`instance.required` defined if the value is required or not + - :py:attr:`instance.initial` defined if the initial value + """ + kwargs = self.field_kwargs.copy() + kwargs.setdefault("label", self.get("verbose_name")) + kwargs.setdefault("help_text", self.get("help_text")) + kwargs.setdefault("widget", self.get("widget")) + kwargs.setdefault("required", self.get("required")) + kwargs.setdefault("initial", self.initial) + kwargs.setdefault("validators", []) + kwargs["validators"].append(self.validate) + return kwargs + + def api_repr(self, value): + """ + Used only to represent a preference value using Rest Framework + """ + return value + + def get_api_additional_data(self): + """ + Additional data to serialize for use on front-end side, for example + """ + return {} + + def get_api_field_data(self): + """ + Field data to serialize for use on front-end side, for example + will include choices available for a choice field + """ + field = self.setup_field() + d = { + "class": field.__class__.__name__, + "widget": {"class": field.widget.__class__.__name__}, + } + + try: + d["input_type"] = field.widget.input_type + except AttributeError: + # some widgets, such as Select do not have an input type + # in django < 1.11 + d["input_type"] = None + + return d + + def validate(self, value): + """ + Used to implement custom cleaning logic for use in forms + and serializers. The method will be passed as a validator to + the preference form field. + + :Example: + + .. code-block:: python + + def validate(self, value): + if value == '42': + raise ValidationError('Wrong value!') + """ + return + + +class BooleanPreference(BasePreferenceType): + """ + A preference type that stores a boolean. + """ + + field_class = forms.BooleanField + serializer = BooleanSerializer + required = False + + +class IntegerPreference(BasePreferenceType): + """ + A preference type that stores an integer. + """ + + field_class = forms.IntegerField + serializer = IntegerSerializer + + +IntPreference = IntegerPreference + + +class DecimalPreference(BasePreferenceType): + """ + A preference type that stores a :py:class:`decimal.Decimal`. + """ + + field_class = forms.DecimalField + serializer = DecimalSerializer + + +class FloatPreference(BasePreferenceType): + """ + A preference type that stores a float. + """ + + field_class = forms.FloatField + serializer = FloatSerializer + + +class StringPreference(BasePreferenceType): + """ + A preference type that stores a string. + """ + + field_class = forms.CharField + serializer = StringSerializer + + +class LongStringPreference(StringPreference): + """ + A preference type that stores a string, but with a textarea widget. + """ + + widget = forms.Textarea + + +class ChoicePreference(BasePreferenceType): + """ + A preference type that stores a string among a list of choices. + """ + + choices = () + """ + Expects the same values as for django :py:class:`forms.ChoiceField`. + + :Example: + + .. code-block:: python + + class MyChoicePreference(ChoicePreference): + choices = [ + ('c', 'Carrot'), + ('t', 'Tomato'), + ] + """ + field_class = forms.ChoiceField + serializer = StringSerializer + + def get_field_kwargs(self): + field_kwargs = super(ChoicePreference, self).get_field_kwargs() + field_kwargs["choices"] = self.get("choices") or self.field_attribute["initial"] + return field_kwargs + + def get_api_additional_data(self): + d = super(ChoicePreference, self).get_api_additional_data() + d["choices"] = self.get("choices") + return d + + def get_choice_values(self): + return [c[0] for c in self.get("choices")] + + def validate(self, value): + if value not in self.get_choice_values(): + raise forms.ValidationError("{} is not a valid choice".format(value)) + + +def create_deletion_handler(preference): + """ + Will generate a dynamic handler to purge related preference + on instance deletion + """ + + def delete_related_preferences(sender, instance, *args, **kwargs): + queryset = preference.registry.preference_model.objects.filter( + name=preference.name, section=preference.section + ) + related_preferences = queryset.filter( + raw_value=preference.serializer.serialize(instance) + ) + related_preferences.delete() + + return delete_related_preferences + + +class ModelChoicePreference(BasePreferenceType): + """ + A preference type that stores a reference to a model instance. + + :Example: + + .. code-block:: python + + from myapp.blog.models import BlogEntry + + @registry.register + class FeaturedEntry(ModelChoicePreference): + section = Section('blog') + name = 'featured_entry' + queryset = BlogEntry.objects.filter(status='published') + + blog_entry = BlogEntry.objects.get(pk=12) + manager['blog__featured_entry'] = blog_entry + + # accessing the value will return the model instance + assert manager['blog__featured_entry'].pk == 12 + + .. note:: + + You should provide either the :py:attr:`queryset` or :py:attr:`model` + attribute + """ + + field_class = forms.ModelChoiceField + serializer_class = ModelSerializer + + model = None + """ + Which model class to link the preference to. You can skip this if you + define the :py:attr:`queryset` attribute. + """ + + queryset = None + """ + A queryset to filter available model instances. + """ + signals_handlers = {} + + def __init__(self, *args, **kwargs): + super(ModelChoicePreference, self).__init__(*args, **kwargs) + + if self.model is not None: + # Set queryset following model attribute + self.queryset = self.model.objects.all() + elif self.queryset is not None: + # Set model following queryset attribute + self.model = self.queryset.model + else: + raise MissingModel + + self.serializer = self.serializer_class(self.model) + + self._setup_signals() + + def _setup_signals(self): + handler = create_deletion_handler(self) + # We need to keep a reference to the handler or it will cause + # weakref to die and our handler will not be called + self.signals_handlers["pre_delete"] = [handler] + pre_delete.connect(handler, sender=self.model) + + def get_field_kwargs(self): + kw = super(ModelChoicePreference, self).get_field_kwargs() + kw["queryset"] = self.get("queryset") + return kw + + def api_repr(self, value): + if not value: + return None + if value.__class__.__name__ == "QuerySet": + return [val.pk for val in value] + return value.pk + + +class ModelMultipleChoicePreference(ModelChoicePreference): + """ + A preference type that stores a reference list to the model instances. + + :Example: + + .. code-block:: python + + from myapp.blog.models import BlogEntry + + @registry.register + class FeaturedEntries(ModelMultipleChoicePreference): + section = Section('blog') + name = 'featured_entries' + queryset = BlogEntry.objects.all() + + blog_entries = BlogEntry.objects.filter(status='published') + manager['blog__featured_entries'] = blog_entries + + # accessing the value will return the model queryset + assert manager['blog__featured_entries'] == blog_entries + + .. note:: + + You should provide either the :py:attr:`queryset` or :py:attr:`model` + attribute + """ + + serializer_class = ModelMultipleSerializer + field_class = forms.ModelMultipleChoiceField + + def _setup_signals(self): + pass + + +class FilePreference(BasePreferenceType): + """ + A preference type that stores a a reference to a model. + + :Example: + + .. code-block:: python + + from django.core.files.uploadedfile import SimpleUploadedFile + + @registry.register + class Logo(FilePreference): + section = Section('blog') + name = 'logo' + + logo = SimpleUploadedFile( + "logo.png", b"file_content", content_type="image/png") + manager['blog__logo'] = logo + + # accessing the value will return a FieldFile object, just as + # django.db.models.FileField + assert manager['blog__logo'].read() == b'file_content' + + manager['blog__logo'].delete() + + """ + + field_class = forms.FileField + serializer_class = FileSerializer + default = None + + @property + def serializer(self): + """ + The serializer need additional data about the related preference + to upload file to correct directory + """ + return self.serializer_class(self) + + def get_field_kwargs(self): + kwargs = super(FilePreference, self).get_field_kwargs() + kwargs["required"] = self.get("required", False) + return kwargs + + def get_upload_path(self): + return os.path.join( + preferences_settings.FILE_PREFERENCE_UPLOAD_DIR, self.identifier() + ) + + def get_file_storage(self): + """ + Override this method if you want to use a custom storage + """ + return default_storage + + def api_repr(self, value): + if value: + return value.url + + +class DurationPreference(BasePreferenceType): + """ + A preference type that stores a timedelta. + """ + + field_class = forms.DurationField + serializer = DurationSerializer + + def api_repr(self, value): + return duration_string(value) + + +class DatePreference(BasePreferenceType): + """ + A preference type that stores a date. + """ + + field_class = forms.DateField + serializer = DateSerializer + + def api_repr(self, value): + return value.isoformat() + + +class DateTimePreference(BasePreferenceType): + """ + A preference type that stores a datetime. + """ + + field_class = forms.DateTimeField + serializer = DateTimeSerializer + + def api_repr(self, value): + return value.isoformat() + + +class TimePreference(BasePreferenceType): + """ + A preference type that stores a time. + """ + + field_class = forms.TimeField + serializer = TimeSerializer + + def api_repr(self, value): + return value.isoformat() + + +class MultipleChoicePreference(ChoicePreference): + """ + A preference type that stores multiple strings among a list of choices. + + :Example: + + .. code-block:: python + + @registry.register + class FeaturedEntries(MultipleChoicePreference): + section = Section('blog') + name = 'featured_entries' + choices = [ + ('c', 'Carrot'), + ('t', 'Tomato'), + ] + + .. note:: + + Internally, the selected choices are stored as a string, separated by a + separator. The separator defaults to ','. The way this is implemented still + is sae also on keys that cotain the separator, but if in doubt, you can still + set the :py:attr:`separator` to any other character. + """ + + widget = forms.CheckboxSelectMultiple + field_class = forms.MultipleChoiceField + serializer = MultipleSerializer + + def validate(self, value): + for v in value: + super().validate(v) diff --git a/dynamic_preferences/urls.py b/dynamic_preferences/urls.py new file mode 100644 index 0000000..e98f93b --- /dev/null +++ b/dynamic_preferences/urls.py @@ -0,0 +1,33 @@ +try: + from django.urls import include, re_path +except ImportError: + from django.conf.urls import include, url as re_path + +from django.contrib.admin.views.decorators import staff_member_required +from . import views +from .registries import global_preferences_registry +from .forms import GlobalPreferenceForm + +app_name = "dynamic_preferences" + +urlpatterns = [ + re_path( + r"^global/$", + staff_member_required( + views.PreferenceFormView.as_view( + registry=global_preferences_registry, form_class=GlobalPreferenceForm + ) + ), + name="global", + ), + re_path( + r"^global/(?P
[\w\ ]+)$", + staff_member_required( + views.PreferenceFormView.as_view( + registry=global_preferences_registry, form_class=GlobalPreferenceForm + ) + ), + name="global.section", + ), + re_path(r"^user/", include("dynamic_preferences.users.urls")), +] diff --git a/dynamic_preferences/users/__init__.py b/dynamic_preferences/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dynamic_preferences/users/admin.py b/dynamic_preferences/users/admin.py new file mode 100644 index 0000000..21be4bf --- /dev/null +++ b/dynamic_preferences/users/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin as django_admin +from django import forms + +from ..settings import preferences_settings +from .. import admin +from .models import UserPreferenceModel +from .forms import UserSinglePreferenceForm + + +class UserPreferenceAdmin(admin.PerInstancePreferenceAdmin): + search_fields = ["instance__username"] + admin.DynamicPreferenceAdmin.search_fields + form = UserSinglePreferenceForm + changelist_form = UserSinglePreferenceForm + + def get_queryset(self, request, *args, **kwargs): + # Instanciate default prefs + getattr(request.user, preferences_settings.MANAGER_ATTRIBUTE).all() + return super(UserPreferenceAdmin, self).get_queryset(request, *args, **kwargs) + + +django_admin.site.register(UserPreferenceModel, UserPreferenceAdmin) diff --git a/dynamic_preferences/users/apps.py b/dynamic_preferences/users/apps.py new file mode 100644 index 0000000..7ab9059 --- /dev/null +++ b/dynamic_preferences/users/apps.py @@ -0,0 +1,18 @@ +from django.apps import AppConfig, apps +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +from ..registries import preference_models +from .registries import user_preferences_registry + + +class UserPreferencesConfig(AppConfig): + name = "dynamic_preferences.users" + verbose_name = _("Preferences - Users") + label = "dynamic_preferences_users" + default_auto_field = "django.db.models.AutoField" + + def ready(self): + UserPreferenceModel = self.get_model("UserPreferenceModel") + + preference_models.register(UserPreferenceModel, user_preferences_registry) diff --git a/dynamic_preferences/users/forms.py b/dynamic_preferences/users/forms.py new file mode 100644 index 0000000..73019b2 --- /dev/null +++ b/dynamic_preferences/users/forms.py @@ -0,0 +1,33 @@ +from six import string_types +from django import forms +from django.core.exceptions import ValidationError +from collections import OrderedDict + +from .registries import user_preferences_registry +from ..forms import ( + SinglePerInstancePreferenceForm, + preference_form_builder, + PreferenceForm, +) +from ..exceptions import NotFoundInRegistry +from .models import UserPreferenceModel + + +class UserSinglePreferenceForm(SinglePerInstancePreferenceForm): + class Meta: + model = UserPreferenceModel + fields = SinglePerInstancePreferenceForm.Meta.fields + + +def user_preference_form_builder(instance, preferences=[], **kwargs): + """ + A shortcut :py:func:`preference_form_builder(UserPreferenceForm, preferences, **kwargs)` + :param user: a :py:class:`django.contrib.auth.models.User` instance + """ + return preference_form_builder( + UserPreferenceForm, preferences, instance=instance, **kwargs + ) + + +class UserPreferenceForm(PreferenceForm): + registry = user_preferences_registry diff --git a/dynamic_preferences/users/migrations/0001_initial.py b/dynamic_preferences/users/migrations/0001_initial.py new file mode 100644 index 0000000..aa9fe01 --- /dev/null +++ b/dynamic_preferences/users/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# Generated by Django 2.0.6 on 2018-06-15 16:20 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="UserPreferenceModel", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "section", + models.CharField( + blank=True, + db_index=True, + default=None, + max_length=150, + null=True, + ), + ), + ("name", models.CharField(db_index=True, max_length=150)), + ("raw_value", models.TextField(blank=True, null=True)), + ( + "instance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "user preference", + "verbose_name_plural": "user preferences", + "abstract": False, + }, + ), + migrations.AlterUniqueTogether( + name="userpreferencemodel", + unique_together={("instance", "section", "name")}, + ), + ] diff --git a/dynamic_preferences/users/migrations/0002_auto_20200821_0837.py b/dynamic_preferences/users/migrations/0002_auto_20200821_0837.py new file mode 100644 index 0000000..fdb772b --- /dev/null +++ b/dynamic_preferences/users/migrations/0002_auto_20200821_0837.py @@ -0,0 +1,35 @@ +# Generated by Django 3.1 on 2020-08-21 08:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("dynamic_preferences_users", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="userpreferencemodel", + name="name", + field=models.CharField(db_index=True, max_length=150, verbose_name="Name"), + ), + migrations.AlterField( + model_name="userpreferencemodel", + name="raw_value", + field=models.TextField(blank=True, null=True, verbose_name="Raw Value"), + ), + migrations.AlterField( + model_name="userpreferencemodel", + name="section", + field=models.CharField( + blank=True, + db_index=True, + default=None, + max_length=150, + null=True, + verbose_name="Section Name", + ), + ), + ] diff --git a/dynamic_preferences/users/migrations/__init__.py b/dynamic_preferences/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dynamic_preferences/users/models.py b/dynamic_preferences/users/models.py new file mode 100644 index 0000000..46b35dd --- /dev/null +++ b/dynamic_preferences/users/models.py @@ -0,0 +1,15 @@ +from django.db import models +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +from dynamic_preferences.models import PerInstancePreferenceModel + + +class UserPreferenceModel(PerInstancePreferenceModel): + + instance = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + + class Meta(PerInstancePreferenceModel.Meta): + app_label = "dynamic_preferences_users" + verbose_name = _("user preference") + verbose_name_plural = _("user preferences") diff --git a/dynamic_preferences/users/registries.py b/dynamic_preferences/users/registries.py new file mode 100644 index 0000000..f3cd154 --- /dev/null +++ b/dynamic_preferences/users/registries.py @@ -0,0 +1,8 @@ +from ..registries import PerInstancePreferenceRegistry + + +class UserPreferenceRegistry(PerInstancePreferenceRegistry): + section_url_namespace = "dynamic_preferences:user.section" + + +user_preferences_registry = UserPreferenceRegistry() diff --git a/dynamic_preferences/users/serializers.py b/dynamic_preferences/users/serializers.py new file mode 100644 index 0000000..1470d33 --- /dev/null +++ b/dynamic_preferences/users/serializers.py @@ -0,0 +1,5 @@ +from dynamic_preferences.api.serializers import PreferenceSerializer + + +class UserPreferenceSerializer(PreferenceSerializer): + pass diff --git a/dynamic_preferences/users/urls.py b/dynamic_preferences/users/urls.py new file mode 100644 index 0000000..d75a283 --- /dev/null +++ b/dynamic_preferences/users/urls.py @@ -0,0 +1,16 @@ +try: + from django.urls import include, re_path +except ImportError: + from django.conf.urls import include, url as re_path + +from django.contrib.auth.decorators import login_required +from . import views + +urlpatterns = [ + re_path(r"^$", login_required(views.UserPreferenceFormView.as_view()), name="user"), + re_path( + r"^(?P
[\w\ ]+)$", + login_required(views.UserPreferenceFormView.as_view()), + name="user.section", + ), +] diff --git a/dynamic_preferences/users/views.py b/dynamic_preferences/users/views.py new file mode 100644 index 0000000..366cd11 --- /dev/null +++ b/dynamic_preferences/users/views.py @@ -0,0 +1,18 @@ +from ..views import PreferenceFormView +from .forms import user_preference_form_builder +from .registries import user_preferences_registry + + +class UserPreferenceFormView(PreferenceFormView): + """ + Will pass `request.user` to form_builder + """ + + registry = user_preferences_registry + + def get_form_class(self, *args, **kwargs): + section = self.kwargs.get("section", None) + form_class = user_preference_form_builder( + instance=self.request.user, section=section + ) + return form_class diff --git a/dynamic_preferences/users/viewsets.py b/dynamic_preferences/users/viewsets.py new file mode 100644 index 0000000..4f95af6 --- /dev/null +++ b/dynamic_preferences/users/viewsets.py @@ -0,0 +1,15 @@ +from rest_framework import permissions + +from dynamic_preferences.api import viewsets + +from . import serializers +from . import models + + +class UserPreferencesViewSet(viewsets.PerInstancePreferenceViewSet): + queryset = models.UserPreferenceModel.objects.all() + serializer_class = serializers.UserPreferenceSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_related_instance(self): + return self.request.user diff --git a/dynamic_preferences/utils.py b/dynamic_preferences/utils.py new file mode 100644 index 0000000..dc181f3 --- /dev/null +++ b/dynamic_preferences/utils.py @@ -0,0 +1,18 @@ +try: + from collections.abc import Mapping +except ImportError: + from collections import Mapping + + +def update(d, u): + """ + Custom recursive update of dictionary + from http://stackoverflow.com/questions/3232943/update-value-of-a-nested-dictionary-of-varying-depth + """ + for k, v in u.iteritems(): + if isinstance(v, Mapping): + r = update(d.get(k, {}), v) + d[k] = r + else: + d[k] = u[k] + return d diff --git a/dynamic_preferences/views.py b/dynamic_preferences/views.py new file mode 100644 index 0000000..04d1acf --- /dev/null +++ b/dynamic_preferences/views.py @@ -0,0 +1,59 @@ +from django.views.generic import TemplateView, FormView +from django.http import Http404 +from .forms import preference_form_builder + + +"""Todo : remove these views and use only context processors""" + + +class RegularTemplateView(TemplateView): + """Used for testing context""" + + template_name = "dynamic_preferences/testcontext.html" + + +class PreferenceFormView(FormView): + """ + Display a form for updating preferences of the given + section provided via URL arg. + If no section is provided, will display a form for all + fields of a given registry. + """ + + #: the registry for preference lookups + registry = None + + #: will be used by :py:func:`forms.preference_form_builder` + # to create the form + form_class = None + template_name = "dynamic_preferences/form.html" + + def dispatch(self, request, *args, **kwargs): + self.section_name = kwargs.get("section", None) + if self.section_name: + try: + self.section = self.registry.section_objects[self.section_name] + except KeyError: + raise Http404 + else: + self.section = None + return super(PreferenceFormView, self).dispatch(request, *args, **kwargs) + + def get_form_class(self, *args, **kwargs): + form_class = preference_form_builder(self.form_class, section=self.section_name) + return form_class + + def get_context_data(self, *args, **kwargs): + context = super(PreferenceFormView, self).get_context_data(*args, **kwargs) + context["registry"] = self.registry + context["section"] = self.section + + return context + + def get_success_url(self): + return self.request.path + + def form_valid(self, form): + + form.update_preferences() + return super(PreferenceFormView, self).form_valid(form) diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..6317c6c --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh +sleep 20 + +python manage.py migrate + +exec "$@" diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..6361598 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.base') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + 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?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..58dd0e9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,85 @@ +asgiref==3.8.1 +certifi==2024.2.2 +charset-normalizer==3.3.2 +diff-match-patch==20230430 +django-ajax-datatable==4.5.0 +django-autoslug==1.9.9 +django-cors-headers==4.3.1 +django-debug-toolbar==4.3.0 +django-environ==0.11.2 +django-filter==2.4.0 +django-import-export==4.0.3 +django-multiselectfield==0.1.12 +django-phonenumber-field==5.2.0 +django-recaptcha==2.0.6 +django==3.2.4 +djangorestframework==3.15.1 +drf-yasg==1.21.7 +gunicorn==22.0.0 +idna==3.7 +inflection==0.5.1 +packaging==24.0 +phonenumbers==8.13.37 +pillow==10.3.0 +psycopg2-binary==2.9.9 +pytz==2024.1 +geopy==2.3.0 +pyyaml==6.0.1 +requests==2.32.1 +sqlparse==0.5.0 +tablib==3.5.0 +typing-extensions==4.11.0 +uritemplate==4.1.1 +urllib3==2.2.1 +redis==4.3.4 +django-redis==5.4.0 +celery==5.2.1 +sentry-sdk==1.6.0 +outcome==1.3.0.post0 +prompt-toolkit==3.0.45 +py-moneyed==3.0 +pycparser==2.22 +pysocks==1.7.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +selenium==4.21.0 +setuptools==70.0.0 +six==1.16.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +soupsieve==2.5 +trio-websocket==0.11.1 +trio==0.25.1 +django-mptt==0.12.0 +tzdata==2024.1 +vine==5.1.0 +wcwidth==0.2.13 +webdriver-manager==4.0.1 +wsproto==1.2.0 +django-money==3.5.2 +exceptiongroup==1.2.1 +h11==0.14.0 +kombu==5.3.7 +amqp==5.2.0 +async-timeout==4.0.3 +attrs==23.2.0 +babel==2.15.0 +beautifulsoup4==4.12.3 +python-slugify==8.0.1 +billiard==3.6.4.0 +cffi==1.16.0 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +click==8.1.7 +colorama==0.4.6 +django-dynamic-preferences==1.16.0 +unidecode + +https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-limitless-dashboard.git/archive/master.zip +https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/ajax-datatable.git/archive/master.zip +https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-seo.git/archive/master.zip +https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-filer.git/archive/master.zip +https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-language.git/archive/master.zip +https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-category.git/archive/master.zip +https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/django-modules/FastFileManager.git/archive/master.zip diff --git a/runner.sh b/runner.sh new file mode 100644 index 0000000..73206b5 --- /dev/null +++ b/runner.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Check if the '--dev' argument is provided +if [ "$1" == "--dev" ]; then + echo "Run development docker" + DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 docker compose -f docker-compose.yml up -d --build +else + echo "Run Production docker" + + git pull origin master + DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 docker compose -f docker-compose.prod.yml up -d --build +fi \ No newline at end of file diff --git a/templates/admin/includes/fieldset.html b/templates/admin/includes/fieldset.html new file mode 100644 index 0000000..b649951 --- /dev/null +++ b/templates/admin/includes/fieldset.html @@ -0,0 +1,95 @@ +
+
+ {% if inline_admin_formset.opts.verbose_name_plural %} + {#
#} + {#
#} + {# {{ inline_admin_formset.opts.verbose_name_plural|capfirst }}#} + {#
#} + {#
#} + {% else %} + {% if fieldset.name %} + + {% endif %} + {% endif %} +
+ + +
+
+
diff --git a/templates/admin/includes/object_delete_summary.html b/templates/admin/includes/object_delete_summary.html new file mode 100644 index 0000000..9ad97db --- /dev/null +++ b/templates/admin/includes/object_delete_summary.html @@ -0,0 +1,7 @@ +{% load i18n %} +

{% translate "Summary" %}

+
    + {% for model_name, object_count in model_count %} +
  • {{ model_name|capfirst }}: {{ object_count }}
  • + {% endfor %} +
diff --git a/templates/admin/index.html b/templates/admin/index.html new file mode 100644 index 0000000..9ff5288 --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,213 @@ +{% extends 'admin/base_site.html' %} +{% load static %} +{% block content %} + {{ block.super }} +
+
+
+
+
+
Monthly User Chart
+
+ +
+
+
+
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + {% if request.user.is_superuser %} + + + + + + {% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/docs.html b/templates/docs.html new file mode 100644 index 0000000..c5f9142 --- /dev/null +++ b/templates/docs.html @@ -0,0 +1,69 @@ +{% extends 'admin/base_site.html' %} + +{% block title %} + {{ title }} | {{ site_title|default:_('Django site adminssss') }} +{% endblock %} + +{% block contentwrap %} +
+
+
+
+
+ آموزش ساختار کلی ادمین +
+
+ +
+
+
+
+
+
+ آموزش بخش تقویم +
+
+ +
+
+
+ +
+
+
+ آموزش بخش مفاتیح +
+
+ +
+
+
+
+
+
+ آموزش بخش احکام +
+
+ +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/fields/json_editor_field.html b/templates/fields/json_editor_field.html new file mode 100644 index 0000000..cd18395 --- /dev/null +++ b/templates/fields/json_editor_field.html @@ -0,0 +1,38 @@ +{% load i18n %} + +
+ + + + diff --git a/templates/fields/jsonlanguage_field.html b/templates/fields/jsonlanguage_field.html new file mode 100644 index 0000000..cd18395 --- /dev/null +++ b/templates/fields/jsonlanguage_field.html @@ -0,0 +1,38 @@ +{% load i18n %} + +
+ + + + diff --git a/templates/name_finder.html b/templates/name_finder.html new file mode 100644 index 0000000..02fd663 --- /dev/null +++ b/templates/name_finder.html @@ -0,0 +1,93 @@ + + + + + + Search Names + + + + + + + + +
+
+
+

Search Names

+ + +
+
loading...
+
+
+ By Meaning: +
+
+ + By Name: +
+
+
+
+
+
+ + + + + + + diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..c719710 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,234 @@ +import os +import secrets +import shutil +import mimetypes +import re +from urllib.parse import urlparse + +from django.conf import settings +from django.core.files import File +from django.http import HttpRequest +from django.core.mail import send_mail + +from rest_framework import serializers, status +from rest_framework.generics import GenericAPIView +from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.response import Response +from unidecode import unidecode +from django.utils.text import slugify +import random +import string + + + + + +def send_email(recipient, code): + send_mail( + 'Test Email', + f'This is a test email {code} from Django using Gmail SMTP.', + 'aliabdolahi.171@gmail.com', + recipient, + fail_silently=False, + ) + return True + + +def is_valid_email(email): + # تعریف الگوی regex برای یک ایمیل معتبر + email_regex = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$' + + # بررسی اینکه آیا ایمیل با regex مطابقت دارد یا خیر + if re.match(email_regex, email): + return True + return False + + +def generate_slug_for_model(model, value: str, recycled_count: int = 0): + from slugify import slugify + try: + + base_slug = slugify(unidecode(value)) + slug = base_slug + if recycled_count > 0: + slug = f"{base_slug}-{recycled_count}" + + if model.objects.filter(slug=slug).exists(): + return generate_slug_for_model(model, value, recycled_count + 1) + + return slug[:50] + except Exception as exp: + letters = string.ascii_lowercase + result_str = ''.join(random.choice(letters) for i in range(8)) + return result_str + + +def generate_slugen_for_model(model, value_en, value_pk): + try: + unique_slug = value_en + if not value_pk or not value_en: + base_slug = slugify(unidecode(value_en)) + unique_slug = base_slug + num = 1 + while model.objects.filter(slug=unique_slug).exists(): + unique_slug = f"{base_slug}-{num}" + num += 1 + + return unique_slug + except Exception as exp: + letters = string.ascii_lowercase + result_str = ''.join(random.choice(letters) for i in range(8)) + return result_str + + +def exclude_host_from_url(url): + # Parse the URL + parsed_url = urlparse(url) + + # Extract the path and query parameters + path_with_query = parsed_url.path + parsed_url.query + + return path_with_query + + +def generate_slug_for_model(model, value: str, recycled_count: int = 0): + from slugify import slugify + + slug = slugify(value) + if model.objects.filter(slug=slug).exists(): + recycled_count += 1 + if value.endswith(f'-{recycled_count - 1}'): + value = value.replace(f'-{recycled_count - 1}', f'-{recycled_count}') + else: + value = f"{value}-{recycled_count}" + return generate_slug_for_model(model, value, recycled_count) + + return slug[:50] + +def absolute_url(req, url): + """ + can either be a file instance or a URL string + """ + try: + return req.build_absolute_uri(url.url if hasattr(url, 'url') else url) + except Exception: + return None + +def sizeof_fmt(num, suffix="B"): + for unit in ["", "K", "M", "G"]: + if abs(num) < 1024.0: + return f"{num:3.1f} {unit}{suffix}" + num /= 1024.0 + return f"{num:.1f} Yi{suffix}" + + +def file_location(path): + from django.conf import settings + import os + + if path.startswith('http'): + path = exclude_host_from_url(path) + + if path.startswith("/static"): + path = path[7:] + + if path.startswith('/'): + path = path[1:] + + return os.path.join(settings.STATIC_ROOT, path) + + +def guess_file_type(filename): + try: + mimetype = mimetypes.guess_type(filename)[0].split('/')[0] + return mimetype + + except Exception: + return False + +class FileFieldSerializer(serializers.CharField): + """ + a field to handle uploaded file + """ + + def get_rpath(self, p): + # extract relative path of doc + return p[p.find('/static/') + 7:] + + def to_representation(self, value): + request = self.context.get('request', None) + if value: + if isinstance(value, str): + # If value is a string, assume it's a file path + return value + elif hasattr(value, 'url'): + # If value is a file object with a URL + return absolute_url(request, value.url) if request else value.url + return None + + def to_internal_value(self, data): + if not data: + return None + + if "/tmp/" not in data: + # value not changed and here we simply return old file path + return self.get_rpath(data) + + if data.startswith('http'): + data = self.get_rpath(data) + + fpath = file_location(data) + if not os.path.exists(fpath): + raise serializers.ValidationError(f"File: '{fpath}' Does not exist") + + return File(open(fpath, 'rb'), os.path.basename(data)) + + +class UploadTmpSerializer(serializers.Serializer): + file = serializers.FileField() + url = serializers.URLField(read_only=True) + name = serializers.CharField(read_only=True) + size = serializers.CharField(read_only=True) + mime_type = serializers.CharField(read_only=True) + + def to_representation(self, instance): + data = super(UploadTmpSerializer, self).to_representation(instance) + data['file'] = instance['file'] + return data + + def store_file(self, file): + from django.conf import settings + static_path = settings.STATIC_ROOT + + os.makedirs(f'{static_path}/tmp', exist_ok=True) + fpath = f"/tmp/{secrets.token_urlsafe(4)}-{file.name}" + shutil.move(file.temporary_file_path(), static_path + fpath) + os.chmod(static_path + fpath, 0o644) + + return { + 'file': fpath, + 'url': absolute_url(self.context['request'], f"/static{fpath}"), + 'name': file.name, + 'size': sizeof_fmt(file.size), + 'mime_type': guess_file_type(fpath) + } + + def validate(self, attrs): + file_details = self.store_file(attrs['file']) + return file_details + + +class UploadTmpMedia(GenericAPIView): + """ + Files will remove every 1 hour + """ + parser_classes = (FormParser, MultiPartParser) + serializer_class = UploadTmpSerializer + + def post(self, request: HttpRequest, *args, **kwargs): + serializer = UploadTmpSerializer(data=request.FILES, context={'request': request}) + is_valid = serializer.is_valid(raise_exception=True) + if not is_valid: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + return Response(serializer.data) diff --git a/utils/calculate_distance.py b/utils/calculate_distance.py new file mode 100644 index 0000000..ab0df4e --- /dev/null +++ b/utils/calculate_distance.py @@ -0,0 +1,50 @@ +from math import radians + +from django.db.models import F, Value +from django.db.models.functions import Radians, Sin, ATan2, Sqrt, Cos + + +def calculate_distance(qs, client_lat: float, client_lon: float): + """ + Based on stackoverflow question: https://stackoverflow.com/a/19412565/10261581 + Distance Unit is in Kilometres + + R = 6373.0 + + lat1 = radians(52.2296756) + lon1 = radians(21.0122287) + lat2 = radians(52.406374) + lon2 = radians(16.9251681) + + dlon = lon2 - lon1 + dlat = lat2 - lat1 + + a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2 + c = 2 * atan2(sqrt(a), sqrt(1 - a)) + + distance = R * c + + print("Result: ", distance) + print("Should be: ", 278.546, "km") + """ + earth_radius = 6373.0 + + client_lat, client_lon = radians(float(client_lat)), radians(float(client_lon)) + if not client_lat: + return qs.annotate( + distance=Value(0), + ) + + return qs.annotate( + rlat=Radians('latitude'), + rlon=Radians('longitude'), + + # a=sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2 + # c = 2 * atan2(sqrt(a), sqrt(1 - a)) + lat_diff=F('rlat') - client_lat, + lon_diff=F('rlon') - client_lon, + a=Sin(F('lat_diff') / 2.0) ** 2.0 + Cos(client_lat) * Cos(F('rlat')) * Sin( + F('lon_diff') / 2.0) ** 2.0, + c=2.0 * ATan2(Sqrt(F('a')), Sqrt(1.0 - F('a'))), + distance=F('c') * earth_radius + ) diff --git a/utils/convert_currency.py b/utils/convert_currency.py new file mode 100644 index 0000000..54092c3 --- /dev/null +++ b/utils/convert_currency.py @@ -0,0 +1,70 @@ + +from djmoney.money import Money +from decimal import Decimal +from apps.tasrif.models import CurrencyRate + +def convert_currency_pure(from_rate, to_rate, amount): + + converted_amount = (amount / from_rate.rate) * to_rate.rate + + if to_rate.code == "IRR": + converted_amount = round(converted_amount) + + one_unit_conversion = to_rate.rate / from_rate.rate + + return converted_amount, one_unit_conversion + +def convert_currency(amount, from_currency_code, to_currency_code): + try: + from_currency_rate = CurrencyRate.objects.get(code=from_currency_code) + to_currency_rate = CurrencyRate.objects.get(code=to_currency_code) + + # مطمئن شدن از نوع داده Decimal برای نرخ‌ها + from_rate = Decimal(to_currency_rate.rate) + to_rate = Decimal(from_currency_rate.rate) + + # محاسبه نرخ تبدیل + conversion_rate = from_rate / to_rate + + # تبدیل مبلغ + converted_amount = Decimal(amount) * conversion_rate + + # ایجاد نمونه Money + converted_money = Money(converted_amount, to_currency_code) + print(f'>>>>>>>>> {converted_amount} /// {converted_money}') + return converted_money + except CurrencyRate.DoesNotExist: + return None # یا می‌توانید خطا را مدیریت کنید + + +def convert_currency2(from_currency_code, to_currency_code, amount): + try: + # Fetch the currencies from the database + from_currency = CurrencyRate.objects.get(code=from_currency_code) + to_currency = CurrencyRate.objects.get(code=to_currency_code) + + # Convert the amount to USD first, then to the target currency + amount_in_usd = amount / from_currency.rate + converted_amount = amount_in_usd * to_currency.rate + + # Ensure IRR values are integer if converting to IRR + if to_currency_code == 'IRR': + converted_amount = round(converted_amount) + + return converted_amount + + except CurrencyRate.DoesNotExist: + raise ValueError("One or both of the specified currencies are not available in the database.") + + +def formater_convert_currency(to_currency, converted_amount, rate_per_unit): + if to_currency == 'IRR': + # Format without decimals for IRR + formatted_converted_amount = f"{converted_amount:,.0f}" # No decimal places + formatted_rate_per_unit = f"{rate_per_unit:,.0f}" + else: + # Format with two decimal places for other currencies + formatted_converted_amount = f"{converted_amount:,.2f}" # Two decimal places + formatted_rate_per_unit = f"{rate_per_unit:,.2f}" + + return formatted_converted_amount, formatted_rate_per_unit \ No newline at end of file diff --git a/utils/exceptions.py b/utils/exceptions.py new file mode 100644 index 0000000..89f5a89 --- /dev/null +++ b/utils/exceptions.py @@ -0,0 +1,31 @@ + + +from rest_framework.exceptions import APIException +from rest_framework import status + + + + +class ExpiredCodeException(APIException): + status_code = status.HTTP_410_GONE + default_detail = "The verification code has expired." + default_code = "expired_code" + +class UserNotFoundException(APIException): + status_code = status.HTTP_404_NOT_FOUND + default_detail = 'user notfound' +class NotFoundException(APIException): + status_code = status.HTTP_404_NOT_FOUND + default_detail = "The requested resource was not found." + default_code = "not_found" + + +class InvaliedCodeVrify(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "code notfound" + + +class ServiceUnavailableException(APIException): + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + default_detail = 'Service temporarily unavailable' + default_code = 'service_unavailable' \ No newline at end of file diff --git a/utils/json_editor_field.py b/utils/json_editor_field.py new file mode 100644 index 0000000..950a89c --- /dev/null +++ b/utils/json_editor_field.py @@ -0,0 +1,30 @@ +import json + +from django import forms +from django.db import models + + +class JsonEditorWidget(forms.Textarea): + template_name = 'fields/json_editor_field.html' + + +class JsonEditorField(models.JSONField): + schema = {} + + def __init__(self, *args, schema: dict, **kwargs): + self.schema = schema + super().__init__(*args, **kwargs) + + def formfield(self, **kwargs): + schema = self.schema() if callable(self.schema) else self.schema + + kwargs.update({ + 'widget': JsonEditorWidget(attrs={'schema': json.dumps(schema)}), + }) + return super(JsonEditorField, self).formfield(**kwargs) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + kwargs['schema'] = self.schema + + return name, path, args, kwargs diff --git a/utils/keyval_field.py b/utils/keyval_field.py new file mode 100644 index 0000000..06f84af --- /dev/null +++ b/utils/keyval_field.py @@ -0,0 +1,229 @@ +import json + +from django.db import models + +from utils.json_editor_field import JsonEditorWidget +from django.utils.translation import gettext_lazy as _ +from dj_language.models import Language + +def get_simcard_detail_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Translation')), + 'properties': { + 'detail': {'type': 'string', 'title': str(_('Detail'))}, + 'language_code': { + 'type': "string", + 'enum': list(Language.objects.filter(status=True).values_list('code', flat=True)), + 'default': "en", + 'title': str(_('Language Code')) + } + } + } + } + + +def get_simcard_title_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Translation')), + 'properties': { + 'title': {'type': 'string', 'title': str(_('Title'))}, + 'language_code': { + 'type': "string", + 'enum': list(Language.objects.filter(status=True).values_list('code', flat=True)), + 'default': "en", + 'title': str(_('Language Code')) + } + } + } + } + +def get_tour_feature_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Tour Features')), + 'properties': { + 'title': {'type': 'string', 'title': str(_('Title'))}, + } + } + } + + + + +def get_product_title_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Translation')), + 'properties': { + 'title': {'type': 'string', 'title': str(_('Title'))}, + 'language_code': { + 'type': "string", + 'enum': list(Language.objects.filter(status=True).values_list('code', flat=True)), + 'default': "en", + 'title': str(_('Language Code')) + } + } + } + } + +def get_product_detail_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Translation')), + 'properties': { + 'detail': {'type': 'string', "format": "textarea",'title': str(_('Detail'))}, + 'language_code': { + 'type': "string", + 'enum': list(Language.objects.filter(status=True).values_list('code', flat=True)), + 'default': "en", + 'title': str(_('Language Code')) + } + } + } + } + + + +def get_name_translation_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Translation')), + 'properties': { + 'name': {'type': 'string', 'title': str(_('Name'))}, + 'language_code': { + 'type': "string", + 'enum': list(Language.objects.filter(status=True).values_list('code', flat=True)), + 'default': "en", + 'title': str(_('Language Code')) + } + } + } + } + + +def get_translation_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Translation')), + 'properties': { + 'title': {'type': 'string', 'title': str(_('Title'))}, + 'language_code': { + 'type': "string", + 'enum': list(Language.objects.filter(status=True).values_list('code', flat=True)), + 'default': "en", + 'title': str(_('Language Code')) + } + } + } + } + + +def get_travel_guide_schema(): + from dj_language.models import Language + from django.utils.translation import gettext_lazy as _ + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + "required_by_default": 1, + 'items': { + 'type': 'object', + "required": ["title", "description"], + 'title': str(_('Description')), + 'properties': { + 'title': { + 'type': 'string', + "format": "textarea", + 'title': str(_('Title')) + }, + 'description': { + 'type': "string", + "format": "textarea", + 'title': str(_('Description')) + } + } + } + } + + +class JsonKeyValueField(models.JSONField): + description = "custom json key value field" + + def __init__(self, key_index='key', value_index='value', schema=None, *args, **kwargs): + self.key_index = key_index + self.value_index = value_index + self.schema = schema or { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str('Title'), + 'properties': { + self.key_index: {'type': 'string', 'title': self.key_index.title()}, + self.value_index: {'type': 'string', 'title': self.value_index.title()}, + } + } + } + kwargs.setdefault('default', dict) + super().__init__(*args, **kwargs) + + def save_form_data(self, instance, data): + _data = {} + for i in data: + key, value = i[self.key_index], i[self.value_index] + _data[key] = value + + return super().save_form_data(instance, _data) + + def value_from_object(self, obj): + _data = [] + field = getattr(obj, self.attname, {}) + for key, val in field.items(): + _data.append({ + self.key_index: key, + self.value_index: val + }) + return _data + + def formfield(self, **kwargs): + schema = self.schema() if callable(self.schema) else self.schema + if type(schema) is dict or type(schema) is list: + schema = json.dumps(schema) + + return super().formfield(**{ + 'widget': JsonEditorWidget(attrs={'schema': schema}), + 'encoder': self.encoder, + 'decoder': self.decoder, + **kwargs, + }) diff --git a/utils/pageless.py b/utils/pageless.py new file mode 100644 index 0000000..12edbee --- /dev/null +++ b/utils/pageless.py @@ -0,0 +1,19 @@ +from rest_framework.response import Response + + +class PageLessMixin: + pagination_class = None + filter_backends = [] + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response({ + 'results': serializer.data + }) \ No newline at end of file diff --git a/utils/redis.py b/utils/redis.py new file mode 100644 index 0000000..d46a6c3 --- /dev/null +++ b/utils/redis.py @@ -0,0 +1,70 @@ +import random +from datetime import datetime, timedelta + +from redis.exceptions import RedisError + +from config.redis_config import RedisConfig +from utils.exceptions import ServiceUnavailableException, NotFoundException + +class RedisManager(RedisConfig): + + def __serialize(self, code, fullname, password): + return f'{code},{fullname},{password}' + + + def add_to_redis(self, code, **kwargs) -> bool: + try: + password = kwargs['password'] if kwargs['password'] else None + key = self.__serialize( + code=code, fullname=kwargs['fullname'], password=password + ) + self.redis.set(kwargs["email"], str(key), ex=timedelta(minutes=20)) + return kwargs["email"] + except RedisError as exp: + raise ServiceUnavailableException() + + def __deserialize( + self, + value: str, + key: list = ['code', 'fullname', 'password'] + ): + values = value.split(',') + # Check if lengths of keys and values are not equal + + if len(key) != len(values): + raise ValueError("The number of keys does not match the number of values.") + + result = {} + for k, v in zip(key, values): + if not k or not v: # Check if either key or value is empty + result[k] = None # or '' if you prefer empty string + else: + result[k] = v + + return result + + def get_by_redis(self, key: str): + try: + print(key) + data = self.redis.get(key) + print(f'get => {data}') + return self.__deserialize(data.decode()) + except RedisError as exp: + raise ServiceUnavailableException() + except (TypeError, ValueError, AttributeError): + raise NotFoundException() + + def check_exists_redis(self, email: str) -> bool: + """ + check exists key in redis + """ + try: + exists = self.redis.exists(email) + return exists + except RedisError as exp: + raise CustomException("Service temporarily unavailable") + + @staticmethod + def generate_otp_code() -> int: + random_code = random.randint(10000, 99999) + return random_code \ No newline at end of file diff --git a/utils/schema.py b/utils/schema.py new file mode 100644 index 0000000..a9112b4 --- /dev/null +++ b/utils/schema.py @@ -0,0 +1,14 @@ + + + + +def default_timing(): + return { + "saturday": "", + "sunday": "", + "monday": "", + "tuesday": "", + "wednesday": "", + "thursday": "", + "friday": "" + } diff --git a/utils/thumbail.py b/utils/thumbail.py new file mode 100644 index 0000000..0f1b63d --- /dev/null +++ b/utils/thumbail.py @@ -0,0 +1,16 @@ +from easy_thumbnails.files import get_thumbnailer + +from config.settings.base import THUMBNAIL_ALIASES + + +def get_thumbnail(file, size='medium', request=None): + # try: + options = THUMBNAIL_ALIASES[''].get(size) + url = get_thumbnailer(file).get_thumbnail(options).url + if request: + return request.build_absolute_uri(url) + + return url + + # except: + # return file.url diff --git a/utils/validators.py b/utils/validators.py new file mode 100644 index 0000000..548093d --- /dev/null +++ b/utils/validators.py @@ -0,0 +1,28 @@ + + +from django.core.exceptions import ValidationError + +from phonenumber_field.phonenumber import to_python +from phonenumbers.phonenumberutil import is_possible_number + + + + +def validate_possible_number(phone, country=None): + phone_number = to_python(phone, country) + if ( + phone_number + and not is_possible_number(phone_number) + or not phone_number.is_valid() + ): + raise ValidationError( + "The phone number entered is not valid.", code="invalid" + ) + return phone_number + +def validate_type_code(value): + if not value.isdigit(): + raise serializers.ValidationError('کد باید شامل اعداد باشد.') + if len(value) != 5: + raise serializers.ValidationError('کد باید ۵ رقمی باشد.') + return value \ No newline at end of file