Browse Source

init project first commit

master
alireza 2 years ago
commit
50a5e016c8
  1. 19
      .env.dev
  2. 26
      .env.prod
  3. 419
      .gitignore
  4. 32
      Dockerfile
  5. 58
      Dockerfile.prod
  6. 35
      Jenkinsfile
  7. 0
      README.md
  8. 0
      apps/account/__init__.py
  9. 4
      apps/account/admin/__init__.py
  10. 56
      apps/account/admin/professor.py
  11. 56
      apps/account/admin/student.py
  12. 94
      apps/account/admin/user.py
  13. 7
      apps/account/apps.py
  14. 25
      apps/account/custom_user_login.py
  15. 0
      apps/account/doc.py
  16. 0
      apps/account/management/__init__.py
  17. 0
      apps/account/management/commands/__init__,py
  18. 52
      apps/account/management/commands/create_groups.py
  19. 83
      apps/account/manager.py
  20. 116
      apps/account/migrations/0001_initial.py
  21. 18
      apps/account/migrations/0002_alter_user_birthdate.py
  22. 22
      apps/account/migrations/0003_auto_20241120_1741.py
  23. 0
      apps/account/migrations/__init__.py
  24. 4
      apps/account/models/__init__.py
  25. 95
      apps/account/models/groups.py
  26. 84
      apps/account/models/user.py
  27. 12
      apps/account/permissions.py
  28. 2
      apps/account/serializers/__init__.py
  29. 152
      apps/account/serializers/user.py
  30. 61
      apps/account/tasks.py
  31. 3
      apps/account/tests.py
  32. 35
      apps/account/urls.py
  33. 1
      apps/account/views/__init__.py
  34. 238
      apps/account/views/user.py
  35. 0
      apps/api/__init__.py
  36. 3
      apps/api/admin.py
  37. 6
      apps/api/apps.py
  38. 3
      apps/api/models.py
  39. 3
      apps/api/tests.py
  40. 9
      apps/api/urls.py
  41. 33
      apps/api/views.py
  42. 0
      apps/course/__init__.py
  43. 2
      apps/course/admin/__init__.py
  44. 87
      apps/course/admin/course.py
  45. 18
      apps/course/admin/lesson.py
  46. 6
      apps/course/apps.py
  47. 0
      apps/course/migrations/__init__.py
  48. 2
      apps/course/models/__init__.py
  49. 166
      apps/course/models/course.py
  50. 34
      apps/course/models/lesson.py
  51. 1
      apps/course/serializers/__init__.py
  52. 107
      apps/course/serializers/course.py
  53. 3
      apps/course/tests.py
  54. 15
      apps/course/urls.py
  55. 1
      apps/course/views/__init__.py
  56. 98
      apps/course/views/course.py
  57. 0
      apps/quiz/__init__.py
  58. 3
      apps/quiz/admin.py
  59. 6
      apps/quiz/apps.py
  60. 0
      apps/quiz/migrations/__init__.py
  61. 0
      apps/quiz/models/__init__.py
  62. 68
      apps/quiz/models/participant.py
  63. 53
      apps/quiz/models/quiz.py
  64. 3
      apps/quiz/tests.py
  65. 3
      apps/quiz/views.py
  66. 8
      config/__init__.py
  67. 16
      config/asgi.py
  68. 22
      config/celery.py
  69. 19
      config/language_code_middleware.py
  70. 15
      config/redis_config.py
  71. 0
      config/settings/__init__.py
  72. 308
      config/settings/base.py
  73. 17
      config/settings/develop.py
  74. 120
      config/settings/production.py
  75. 29
      config/test_auth_middleware.py
  76. 53
      config/urls.py
  77. 16
      config/wsgi.py
  78. 85
      docker-compose.prod.yml
  79. 37
      docker-compose.yml
  80. 2
      dynamic_preferences/__init__.py
  81. 114
      dynamic_preferences/admin.py
  82. 0
      dynamic_preferences/api/__init__.py
  83. 71
      dynamic_preferences/api/serializers.py
  84. 179
      dynamic_preferences/api/viewsets.py
  85. 25
      dynamic_preferences/apps.py
  86. 15
      dynamic_preferences/dynamic_preferences_registry.py
  87. 32
      dynamic_preferences/exceptions.py
  88. 152
      dynamic_preferences/forms.py
  89. 60
      dynamic_preferences/locale/ar/LC_MESSAGES/django.po
  90. 71
      dynamic_preferences/locale/de/LC_MESSAGES/django.po
  91. 71
      dynamic_preferences/locale/fa/LC_MESSAGES/django.po
  92. 59
      dynamic_preferences/locale/fr/LC_MESSAGES/django.po
  93. 71
      dynamic_preferences/locale/pl/LC_MESSAGES/django.po
  94. 1
      dynamic_preferences/management/__init__.py
  95. 1
      dynamic_preferences/management/commands/__init__.py
  96. 76
      dynamic_preferences/management/commands/checkpreferences.py
  97. 239
      dynamic_preferences/managers.py
  98. 50
      dynamic_preferences/migrations/0001_initial.py
  99. 27
      dynamic_preferences/migrations/0002_auto_20150712_0332.py
  100. 33
      dynamic_preferences/migrations/0003_auto_20151223_1407.py

19
.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"

26
.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=""

419
.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.*

32
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"]

58
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"]

35
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
"""
}
}
}
}
}
}
}

0
README.md

0
apps/account/__init__.py

4
apps/account/admin/__init__.py

@ -0,0 +1,4 @@
from .user import *
from .professor import *
from .student import *

56
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)

56
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)

94
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)

7
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'

25
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

0
apps/account/doc.py

0
apps/account/management/__init__.py

0
apps/account/management/commands/__init__,py

52
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."))

83
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")

116
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',),
),
]

18
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'),
),
]

22
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'),
),
]

0
apps/account/migrations/__init__.py

4
apps/account/models/__init__.py

@ -0,0 +1,4 @@
from .user import *
from .groups import *

95
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"

84
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"

12
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

2
apps/account/serializers/__init__.py

@ -0,0 +1,2 @@
from .user import *

152
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

61
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)

3
apps/account/tests.py

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

35
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'),
]

1
apps/account/views/__init__.py

@ -0,0 +1 @@
from .user import *

238
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)

0
apps/api/__init__.py

3
apps/api/admin.py

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
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'

3
apps/api/models.py

@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

3
apps/api/tests.py

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

9
apps/api/urls.py

@ -0,0 +1,9 @@
from django.urls import path
from .views import HomeView
urlpatterns = [
path('', HomeView.as_view())
]

33
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})

0
apps/course/__init__.py

2
apps/course/admin/__init__.py

@ -0,0 +1,2 @@
from .course import *
from .lesson import *

87
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)

18
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')

6
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'

0
apps/course/migrations/__init__.py

2
apps/course/models/__init__.py

@ -0,0 +1,2 @@
from .course import *
from .lesson import *

166
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"

34
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}"

1
apps/course/serializers/__init__.py

@ -0,0 +1 @@
from .course import *

107
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']

3
apps/course/tests.py

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

15
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('<slug:slug>/', views.CourseDetailAPIView.as_view(), name='course-detail'),
path('<slug:slug>/attachments/', views.AttachmentListAPIView.as_view(), name='course-attachment-list'),
path('<slug:slug>/glossaries/', views.GlossaryListAPIView.as_view(), name='course-glossary-list'),
]

1
apps/course/views/__init__.py

@ -0,0 +1 @@
from .course import *

98
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)

0
apps/quiz/__init__.py

3
apps/quiz/admin.py

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

6
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'

0
apps/quiz/migrations/__init__.py

0
apps/quiz/models/__init__.py

68
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})"

53
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})"

3
apps/quiz/tests.py

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

3
apps/quiz/views.py

@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.

8
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',)

16
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()

22
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()

19
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

15
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)

0
config/settings/__init__.py

308
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'

17
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,
# },
# }

120
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,
},
},
}

29
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

53
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)

16
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()

85
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

37
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:

2
dynamic_preferences/__init__.py

@ -0,0 +1,2 @@
__version__ = "1.14.0"
default_app_config = "dynamic_preferences.apps.DynamicPreferencesConfig"

114
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

0
dynamic_preferences/api/__init__.py

71
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

179
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

25
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)

15
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'})

32
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'

152
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

60
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 <EMAIL@ADDRESS>, 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 "إرسال"

71
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 <EMAIL@ADDRESS>, 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"

71
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 <EMAIL@ADDRESS>, 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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 ""

59
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 <EMAIL@ADDRESS>, 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"

71
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 <EMAIL@ADDRESS>, 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"

1
dynamic_preferences/management/__init__.py

@ -0,0 +1 @@
__author__ = "agateblue"

1
dynamic_preferences/management/commands/__init__.py

@ -0,0 +1 @@
__author__ = "agateblue"

76
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()

239
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

50
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")]),
),
]

27
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
),
),
]

33
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,
),
]

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

Loading…
Cancel
Save