Browse Source

redesign panel admin

master
alireza 1 year ago
commit
e013c4fd24
  1. 419
      .gitignore
  2. 32
      Dockerfile
  3. 58
      Dockerfile.prod
  4. 35
      Jenkinsfile
  5. 0
      README.md
  6. 125
      all_packages.txt
  7. 0
      apps/account/__init__.py
  8. 66
      apps/account/admin/__init__.py
  9. 12
      apps/account/admin/notification.py
  10. 125
      apps/account/admin/professor.py
  11. 76
      apps/account/admin/student.py
  12. 398
      apps/account/admin/user.py
  13. 7
      apps/account/apps.py
  14. 28
      apps/account/custom_user_login.py
  15. 835
      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. 77
      apps/account/manager.py
  20. 151
      apps/account/migrations/0001_initial.py
  21. 20
      apps/account/migrations/0002_alter_user_phone_number.py
  22. 0
      apps/account/migrations/__init__.py
  23. 3
      apps/account/models/__init__.py
  24. 95
      apps/account/models/groups.py
  25. 25
      apps/account/models/notification.py
  26. 125
      apps/account/models/user.py
  27. 12
      apps/account/permissions.py
  28. 2
      apps/account/serializers/__init__.py
  29. 26
      apps/account/serializers/notification.py
  30. 143
      apps/account/serializers/user.py
  31. 61
      apps/account/tasks.py
  32. 40
      apps/account/templates/account/group_help_text.html
  33. 800
      apps/account/templates/account/json_editor_field.html
  34. 33
      apps/account/templates/account/user_list_section.html
  35. 38
      apps/account/urls.py
  36. 4
      apps/account/views/__init__.py
  37. 105
      apps/account/views/notification.py
  38. 372
      apps/account/views/user.py
  39. 0
      apps/api/__init__.py
  40. 75
      apps/api/admin.py
  41. 6
      apps/api/apps.py
  42. 3
      apps/api/models.py
  43. 3
      apps/api/tests.py
  44. 10
      apps/api/urls.py
  45. 42
      apps/api/views.py
  46. 0
      apps/certificate/__init__.py
  47. 44
      apps/certificate/admin.py
  48. 6
      apps/certificate/apps.py
  49. 31
      apps/certificate/migrations/0001_initial.py
  50. 18
      apps/certificate/migrations/0002_alter_certificate_certificate_file.py
  51. 0
      apps/certificate/migrations/__init__.py
  52. 28
      apps/certificate/models.py
  53. 50
      apps/certificate/serializers.py
  54. 3
      apps/certificate/tests.py
  55. 11
      apps/certificate/urls.py
  56. 24
      apps/certificate/views.py
  57. 0
      apps/chat/__init__.py
  58. 59
      apps/chat/admin.py
  59. 6
      apps/chat/apps.py
  60. 62
      apps/chat/migrations/0001_initial.py
  61. 0
      apps/chat/migrations/__init__.py
  62. 121
      apps/chat/models.py
  63. 3
      apps/chat/tests.py
  64. 3
      apps/chat/views.py
  65. 0
      apps/course/__init__.py
  66. 3
      apps/course/admin/__init__.py
  67. 480
      apps/course/admin/course.py
  68. 111
      apps/course/admin/lesson.py
  69. 33
      apps/course/admin/participant.py
  70. 9
      apps/course/apps.py
  71. 42
      apps/course/data/category.json
  72. 430
      apps/course/doc.py
  73. 135
      apps/course/migrations/0001_initial.py
  74. 18
      apps/course/migrations/0002_alter_course_thumbnail.py
  75. 34
      apps/course/migrations/0003_alter_course_is_online_alter_course_timing_and_more.py
  76. 0
      apps/course/migrations/__init__.py
  77. 3
      apps/course/models/__init__.py
  78. 174
      apps/course/models/course.py
  79. 106
      apps/course/models/lesson.py
  80. 24
      apps/course/models/participant.py
  81. 3
      apps/course/serializers/__init__.py
  82. 247
      apps/course/serializers/course.py
  83. 57
      apps/course/serializers/lesson.py
  84. 17
      apps/course/serializers/participant.py
  85. 19
      apps/course/signals.py
  86. 29
      apps/course/templates/course/add_student_form.html
  87. 3
      apps/course/tests.py
  88. 25
      apps/course/urls.py
  89. 3
      apps/course/views/__init__.py
  90. 220
      apps/course/views/course.py
  91. 144
      apps/course/views/lesson.py
  92. 61
      apps/course/views/participant.py
  93. 0
      apps/hadis/__init__.py
  94. 3
      apps/hadis/admin/__init__.py
  95. 222
      apps/hadis/admin/category.py
  96. 161
      apps/hadis/admin/hadis.py
  97. 0
      apps/hadis/admin/transmitter.py
  98. 6
      apps/hadis/apps.py
  99. 452
      apps/hadis/doc.py
  100. 141
      apps/hadis/migrations/0001_initial.py

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

125
all_packages.txt

@ -0,0 +1,125 @@
ajaxdatatable @ https://git.habibapp.com/NewHorizon/ajax-datatable.git/archive/master.zip#sha256=ad9c98bbaeae07fb23c7b4a1c97b67d9a891b9ba5742c10f84b2c6edac18e098
amqp==5.2.0
asgiref==3.8.1
async-timeout==4.0.3
attrs==23.2.0
Babel==2.15.0
beautifulsoup4==4.12.3
billiard==3.6.4.0
cachetools==5.5.2
celery==5.2.1
certifi==2024.2.2
cffi==1.16.0
chardet==5.2.0
charset-normalizer==3.3.2
click==8.1.7
click-didyoumean==0.3.1
click-plugins==1.1.1
click-repl==0.3.0
colorama==0.4.6
conditional==2.0
cssselect2==0.8.0
Deprecated==1.2.18
diff-match-patch==20230430
dj_category @ https://git.habibapp.com/NewHorizon/django-category.git/archive/master.zip#sha256=9c20b673cdc03ac3fce2d259a2b3d744940cc723496bf9de63a153c18cc9317a
dj_filer @ https://git.habibapp.com/NewHorizon/django-filer.git/archive/master.zip#sha256=7972b3dc6c8187493fdb1e4c37d467704e3894e5472f0fde2b6accad0615435f
dj_language @ https://git.habibapp.com/NewHorizon/django-language.git/archive/master.zip#sha256=da2e1d6a6bb620281a71e042ccf4c361a48f44363e392f8f8b2d157223f41ffa
Django==5.1.8
django-ajax-datatable==4.5.0
django-allauth==65.3.0
django-autoslug==1.9.9
django-clone==5.3.3
django-cors-headers==4.3.1
django-countries==7.2.1
django-crispy-forms==1.11.0
django-debug-toolbar==4.3.0
django-dynamic-preferences==1.16.0
django-environ==0.11.2
django-filer==3.3.1
django-filter==2.4.0
django-import-export==4.0.3
django-js-asset==1.2.2
django-money==3.5.2
django-mptt==0.16.0
django-multiselectfield==0.1.12
django-parler==2.2
django-paypal==1.1.2
django-phonenumber-field==5.2.0
django-polymorphic==3.0.0
django-recaptcha==4.1.0
django-redis==5.4.0
django-reset-migrations==0.4.0
django-rosetta==0.9.6
django-unfold==0.54.0
djangorestframework==3.16.0
drf-yasg==1.21.10
easy-thumbnails==2.10
exceptiongroup==1.2.1
geographiclib==2.0
geopy==2.3.0
guardian==0.2.3
gunicorn==22.0.0
h11==0.14.0
idna==3.7
inflection==0.5.1
Jinja2==3.1.6
kombu==5.3.7
limitless_dashboard @ https://git.habibapp.com/NewHorizon/django-limitless-dashboard.git/archive/master.zip#sha256=0c9748ef1938f4d49e9fc4b6613a729f2724340510c4955cebff48556ae84ca0
lxml==5.3.1
markdown-it-py==3.0.0
MarkupSafe==3.0.2
mdurl==0.1.2
nwh_seo @ https://git.habibapp.com/NewHorizon/django-seo.git/archive/master.zip#sha256=cd3fbb70df2e3eaa3e26bbbf0191f7d220c3a4bef0c24f6986fe10d615fb53b0
oauthlib==3.1.0
outcome==1.3.0.post0
packaging==24.0
paypal==1.2.5
persisting-theory==1.0
phonenumbers==8.13.37
pillow==11.0.0
polib==1.2.0
prompt_toolkit==3.0.45
psycopg2-binary==2.9.9
py-moneyed==3.0
pycparser==2.22
Pygments==2.15.0
PyJWT==2.0.1
PySocks==1.7.1
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
python-slugify==8.0.1
pytz==2025.2
PyYAML==6.0.2
redis==4.3.4
reportlab==4.2.5
requests==2.32.1
requests-oauthlib==1.3.0
rich==13.7.0
ruamel.yaml==0.18.6
ruamel.yaml.clib==0.2.12
selenium==4.21.0
sentry-sdk==1.6.0
six==1.16.0
sniffio==1.3.1
sortedcontainers==2.4.0
soupsieve==2.5
sqlparse==0.5.0
svglib==1.5.1
tablib==3.5.0
text-unidecode==1.3
tinycss2==1.4.0
trio==0.25.1
trio-websocket==0.11.1
typing_extensions==4.13.0
tzdata==2024.1
unicode-slugify==0.1.3
Unidecode==1.1.2
uritemplate==4.1.1
urllib3==2.2.1
vine==5.1.0
wcwidth==0.2.13
webdriver-manager==4.0.1
webencodings==0.5.1
whitenoise==6.9.0
wrapt==1.16.0
wsproto==1.2.0

0
apps/account/__init__.py

66
apps/account/admin/__init__.py

@ -0,0 +1,66 @@
from unfold.components import BaseComponent, register_component
from django.template.loader import render_to_string
from .user import *
from .professor import *
from .student import *
@register_component
class AllUserComponent(BaseComponent):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["children"] = render_to_string(
"admin/helpers/kpi_progress.html",
{
"total": User.objects.filter(is_active=True).count(),
},
)
return context
@register_component
class GuestUserComponent(BaseComponent):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["children"] = render_to_string(
"admin/helpers/kpi_progress.html",
{
"total": User.objects.filter(email__isnull=True).count(),
},
)
return context
@register_component
class ProfessorUserComponent(BaseComponent):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
professor_count = User.objects.filter(groups__name="Professor Group").count()
context["children"] = render_to_string(
"admin/helpers/kpi_progress.html",
{
"total": professor_count
},
)
return context
@register_component
class StudentUserComponent(BaseComponent):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
student_count = User.objects.filter(
models.Q(groups__name="Student Group") |
models.Q(user_type=User.UserType.STUDENT)
).distinct().count()
context["children"] = render_to_string(
"admin/helpers/kpi_progress.html",
{
"total": student_count,
},
)
return context

12
apps/account/admin/notification.py

@ -0,0 +1,12 @@
from ajaxdatatable.admin import AjaxDatatable
from apps.account.models import User, Notification
@admin.register(Notification)
class NotificationAdmin(AjaxDatatable):
list_display = ('title', 'user', 'is_read', 'created_at')
list_filter = ('is_read', 'created_at')
search_fields = ('title', 'message', 'user__fullname')
list_editable = ('is_read',)
ordering = ('-created_at',)
autocomplete_fields = ['user',]

125
apps/account/admin/professor.py

@ -0,0 +1,125 @@
# This file is no longer used. All admin classes are now in user.pyfrom django.contrib import admin
from django.contrib.auth.forms import UserChangeForm, UsernameField, UserCreationForm
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.urls import path, reverse
from django.shortcuts import render, redirect
from django.contrib import messages
from django.contrib.auth.models import Group
from phonenumber_field.formfields import PhoneNumberField
from apps.account.models import ProfessorUser
class ProfessorUserCreationForm(UserCreationForm):
phone_number = PhoneNumberField(
help_text="Enter the phone number in international format. Example: +989012023212",
required=False
)
class Meta:
model = ProfessorUser
fields = ('fullname', 'email', 'phone_number')
@admin.register(ProfessorUser)
class ProfessorUserAdmin(UserAdmin, AjaxDatatable):
add_form = ProfessorUserCreationForm
list_display = (
'email', 'fullname', 'last_login', 'date_joined',
)
ordering = 'last_login',
readonly_fields = ('date_joined',)
exclude = ('password', 'user_permissions')
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ('fullname', 'email', 'phone_number',),
}),
('other', {
'classes': ('wide',),
'fields': ('avatar', 'info', 'skill'),
}),
('Password', {
'classes': ('wide',),
'fields': ('password1', 'password2'),
}),
)
search_fields = (
'email', 'fullname',
)
fieldsets = (
(_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar', 'info', 'skill')}),
(_('Permissions'), {
'fields': ('is_active', 'groups', 'password'),
}),
(_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}),
)
def save_model(self, request, obj, form, change):
if not change: # Creating a new professor
# Check if a user with this email already exists
email = form.cleaned_data.get('email')
existing_user = User.objects.filter(email=email).first()
if existing_user:
# If user exists and is already a professor, show error
if existing_user.user_type == User.UserType.PROFESSOR:
messages.error(request, f"A professor with the email {email} already exists.")
return
# If user exists but is not a professor, convert them to professor
existing_user.user_type = User.UserType.PROFESSOR
# Update user fields from form data
existing_user.fullname = form.cleaned_data.get('fullname')
existing_user.phone_number = form.cleaned_data.get('phone_number')
existing_user.avatar = form.cleaned_data.get('avatar')
existing_user.info = form.cleaned_data.get('info')
existing_user.skill = form.cleaned_data.get('skill')
# Set password if provided
if 'password1' in form.cleaned_data and form.cleaned_data['password1']:
existing_user.set_password(form.cleaned_data['password1'])
# Save the user
existing_user.save()
# Add to professor group
professor_group, _ = Group.objects.get_or_create(name="Professor Group")
existing_user.groups.add(professor_group)
# Show success message
messages.success(request, f"The user with email {email} has been converted to a professor.")
# Set obj to None to prevent further processing
obj = None
return
else:
# New user, set password
obj.set_password(form.cleaned_data['password1'])
if obj: # Only proceed if obj is not None
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
def get_readonly_fields(self, request, obj=None):
"""
Restrict the ability to modify groups to superusers only.
"""
readonly = list(self.readonly_fields)
if not request.user.is_superuser:
readonly.append('groups')
return readonly

76
apps/account/admin/student.py

@ -0,0 +1,76 @@
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, User
@admin.register(StudentUser)
class StudentUserAdmin(UserAdmin, AjaxDatatable):
list_display = (
'device_id', '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': ('fullname', 'email', 'phone_number',),
}),
('other', {
'classes': ('wide',),
'fields': ('avatar', 'info'),
}),
('Password', {
'classes': ('wide',),
'fields': ('password1', 'password2'),
}),
)
search_fields = (
'email', 'fullname', 'username',
)
fieldsets = (
(_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}),
(_('Permissions'), {
'fields': ('is_active', 'groups',),
}),
(_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}),
)
@admin.display(description='Phone Number')
def _phone_number(self, obj):
return obj.phone_number
def get_queryset(self, request):
# محدود کردن نمایش فقط دانش‌آموزان
qs = super().get_queryset(request)
return qs.filter(user_type=User.UserType.STUDENT)
def save_model(self, request, obj, form, change):
if not change:
obj.set_password(form.cleaned_data['password1'])
obj.user_type = User.UserType.STUDENT
super().save_model(request, obj, form, change)
def has_add_permission(self, request):
if '_popup' in request.GET and request.GET['_popup'] == '1': # بررسی وجود _popup در پارامترهای GET
return True
return False
def has_delete_permission(self, request, obj=None):
return False

398
apps/account/admin/user.py

@ -0,0 +1,398 @@
from django.contrib import admin
from django.contrib.auth.forms import UserChangeForm, UsernameField
from django.contrib.auth.admin import UserAdmin as BaseUserAdmin
from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin
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, Notification
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, StudentUser, ProfessorUser
from phonenumber_field.formfields import PhoneNumberField
from utils.admin import project_admin_site
from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm
from unfold.admin import ModelAdmin, StackedInline, TabularInline
from django.contrib.auth.models import Group
from django.db import models
from unfold.contrib.forms.widgets import WysiwygWidget
from unfold.decorators import action, display
from unfold.sections import TableSection
from unfold.contrib.filters.admin import (
AutocompleteSelectMultipleFilter,
ChoicesDropdownFilter,
MultipleRelatedDropdownFilter,
RangeDateFilter,
RangeDateTimeFilter,
RangeNumericFilter,
SingleNumericFilter,
TextFilter,
)
class UserAdmin(BaseUserAdmin, ModelAdmin):
form = UserChangeForm
add_form = UserCreationForm
change_password_form = AdminPasswordChangeForm
compressed_fields = False
list_before_template = "account/user_list_section.html"
list_display = (
'fullname', 'email', 'is_active', 'display_date_joined',
)
ordering = ("-id",)
search_fields = (
'email', 'fullname', 'username',
)
list_filter = [
"is_active",
"is_staff",
("last_login", RangeDateTimeFilter),
("date_joined", RangeDateTimeFilter),
]
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': ( ('fullname', 'email'), 'phone_number', 'birthdate', 'gender','avatar', 'skill', 'info'),
}),
(_('Location'), {
'fields': ('city', 'country'),
'classes': ('collapse',),
}),
(_('Password'), {
'fields': ('password1', 'password2'),
'classes': ('collapse',),
}),
(_('Permissions'), {
'fields': ('is_active', 'is_staff', 'is_superuser', 'groups'),
'classes': ('collapse',),
}),
)
fieldsets = (
(None, {"fields": ("email", "fullname")}),
(
_("Basic Information"),
{
"fields": ("gender", "avatar", "phone_number", "birthdate", 'info', 'skill', "password"),
"classes": ["tab"],
},
),
(
_('Country & City'), {
'fields': ('city', 'country'),
"classes": ["tab"],
}
),
(
_('Device Information'), {
'fields': ('device_id', 'device_os', 'fcm', 'language', ),
"classes": ["tab"],
}
),
(
_('Permissions'), {
'fields': ('user_type', 'is_active', 'is_staff', 'groups'),
"classes": ["tab"],
}
),
(
_('Important dates'), {
'fields': ('last_login', 'date_joined', 'deleted_at'),
"classes": ["tab"],
}
),
)
formfield_overrides = {
models.TextField: {
"widget": WysiwygWidget,
}
}
radio_fields = {
"gender": admin.HORIZONTAL,
}
readonly_fields = ["last_login", "date_joined", 'user_type', ]
@display(description=_("Date Joined"))
def display_date_joined(self, instance: User):
return instance.date_joined.strftime("%Y-%m-%d %H:%M") if instance.date_joined else "-"
@display(description=_("Last Login"))
def display_last_login(self, instance: User):
return instance.last_login.strftime("%Y-%m-%d %H:%M") if instance.last_login else "-"
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.filter(email__isnull=False)
class GuestUserAdmin(UserAdmin):
list_display = (
'device_id', 'device_os', 'is_active', 'display_date_joined',
)
def has_add_permission(self, request):
if '_popup' in request.GET and request.GET['_popup'] == '1':
return True
return False
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.filter(email__isnull=True)
@display(description=_("Date Joined"))
def display_date_joined(self, instance: User):
return instance.date_joined.strftime("%Y-%m-%d %H:%M") if instance.date_joined else "-"
class StudentUserAdmin(UserAdmin):
list_display = (
'display_header', 'email', 'gender', 'display_age', 'courses_count'
)
add_fieldsets = (
(None, {
'classes': ('wide',),
'fields': (('fullname', 'email'), 'phone_number', 'avatar', 'birthdate', 'gender'),
}),
(_('Location'), {
'fields': (('city', 'country'),),
'classes': ('collapse',),
}),
(_('password'), {
'fields': ('password1', 'password2',),
'classes': ('collapse',),
}),
)
@display(description=_("Student"), header=True)
def display_header(self, instance: StudentUser):
from django.templatetags.static import static
# Get avatar image path - use user's avatar if available, otherwise use default
avatar_path = instance.avatar.url if instance.avatar else static("images/reading(1).png")
return [
instance.fullname,
None,
None,
{
"path": avatar_path,
"height": 30,
"width": 36,
"borderless": True,
# "squared": True,
},
]
@display(description=_("Age"))
def display_age(self, instance: StudentUser):
from django.utils.html import format_html
from datetime import date
if not instance.birthdate:
return "-"
today = date.today()
birthdate = instance.birthdate
age = today.year - birthdate.year - ((today.month, today.day) < (birthdate.month, birthdate.day))
formatted_date = birthdate.strftime("%Y-%m-%d")
return format_html(
'<span title="{}">{}</span>',
f"Born on {formatted_date}",
age
)
@display(description=_("Courses"), dropdown=True)
def courses_count(self, instance: StudentUser):
from django.utils.html import format_html
total = instance.participated_courses.count()
items = []
for participant in instance.participated_courses.all():
course = participant.course
title = format_html(
"""
<div class="flex flex-row gap-2 items-center">
<span class="truncate">{}</span>
<a href="/admin/course/course/{}/change/" class="leading-none ml-auto">
<span class="material-symbols-outlined leading-none text-base-500">visibility</span>
</a>
</div>
""",
course.title,
course.id
)
items.append(
{
"title": title,
}
)
# Display custom string if no records found
if total == 0:
return "-"
return {
"title": f"{total} {_('courses')}",
"items": items,
"striped": True,
}
def get_queryset(self, request):
"""
Optimize queries by prefetching related courses
"""
return (
super().get_queryset(request)
.prefetch_related(
"participated_courses",
"participated_courses__course",
)
)
# Register with default admin site for filer app compatibility
admin.site.register(User, UserAdmin)
# Register with custom project admin site
project_admin_site.register(User, UserAdmin)
project_admin_site.register(ClientUser, GuestUserAdmin)
project_admin_site.register(StudentUser, StudentUserAdmin)
@admin.register(Group, site=project_admin_site)
class GroupAdmin(BaseGroupAdmin, ModelAdmin):
list_display = ('name', 'permissions_count')
search_fields = ('name',)
ordering = ('name',)
filter_horizontal = ('permissions',)
fieldsets = (
(None, {'fields': ('name',)}),
(_('Permissions'), {'fields': ('permissions',), 'classes': ['tab']}),
)
@display(description=_("Permissions"))
def permissions_count(self, obj):
count = obj.permissions.count()
return f"{count} {_('permissions')}" if count > 0 else "-"
class CourseTableSection(TableSection):
verbose_name = _("Course Categories")
related_name = "courses"
height = 380
fields = [
"title",
"status",
"edit_link"
]
def edit_link(self, instance):
from django.utils.html import format_html
return format_html(
'<a href="/admin/course/course/{}/change/" class="leading-none">'
'<span class="material-symbols-outlined leading-none text-base-500">visibility</span>'
'</a>',
instance.id
)
edit_link.short_description = _("Edit")
class ProfessorUserAdmin(UserAdmin):
list_display = (
'display_header', 'email', 'courses_count'
)
list_sections = [CourseTableSection]
save_as = True
@display(description=_("Professor"), header=True)
def display_header(self, instance: StudentUser):
from django.templatetags.static import static
# Get avatar image path - use user's avatar if available, otherwise use default
avatar_path = instance.avatar.url if instance.avatar else static("images/reading(1).png")
return [
instance.fullname,
None,
None,
{
"path": avatar_path,
"height": 30,
"width": 50,
"borderless": True,
"squared": True,
},
]
@display(description=_("Courses"), dropdown=True)
def courses_count(self, instance: ProfessorUser):
from django.utils.html import format_html
total = instance.courses.count()
items = []
for course in instance.courses.all():
title = format_html(
"""
<div class="flex flex-row gap-2 items-center">
<span class="truncate">{}</span>
<a href="/admin/course/course/{}/change/" class="leading-none ml-auto">
<span class="material-symbols-outlined leading-none text-base-500">visibility</span>
</a>
</div>
""",
course.title,
course.id
)
items.append(
{
"title": title,
}
)
# Display custom string if no records found
if total == 0:
return "-"
return {
"title": f"{total} {_('courses')}",
"items": items,
"striped": True,
}
def get_queryset(self, request):
"""
Optimize queries by prefetching related courses
"""
return (
super().get_queryset(request)
.prefetch_related("courses")
)
# Register the ProfessorUserAdmin with the project admin site
project_admin_site.register(ProfessorUser, ProfessorUserAdmin)
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'

28
apps/account/custom_user_login.py

@ -0,0 +1,28 @@
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:
if isinstance(username, int):
return User.objects.filter(id=int(username)).first()
return User.objects.filter(Q(email=username) | Q(phone_number=str(username))).first()
except User.DoesNotExist:
return None

835
apps/account/doc.py

@ -0,0 +1,835 @@
def doc_reset():
return """
# 🐈 Scenario
🛠 تنظیم مجدد رمز عبور
کاربر پس از تأیید کد بازیابی رمز عبور، میتواند رمز عبور جدید خود را تنظیم کند. برای این کار، کاربر باید رمز عبور جدید و تأیید آن را وارد کند.
بعد از ریکاور و وریفای
به این صفحه برای ریست میآید
که باید با همان توکنی که در وریفای دریافت کرده است را درخواست کند
(نکته بعد از ریست پسورد توکن ذخیره شده حذف شود و کاربر باید با رمز عبور جدیدی که ست کرده است مجددا لاگین را انجام دهد)
---
## 🚀 درخواست API
### URL:
```
POST /api/reset-password/
```
### Header:
| کلید | مقدار |
|---------------|---------------------------------|
| Content-Type | application/json |
| Authorization | Bearer <توکن احراز هویت> |
### Body:
```json
{
"password": "newstrongpassword",
"password_confirmation": "newstrongpassword"
}
```
---
## 📊 پاسخ‌ها
| کد وضعیت | توضیحات |
|---------------|-----------------------------------------------------------|
| `200` | موفقیتآمیز - رمز عبور با موفقیت تغییر یافت. |
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. |
| `401` | عدم احراز هویت - کاربر وارد نشده است یا توکن نامعتبر است. |
| `500` | مشکل موقتی در سرور. |
---
## 📄 نمونه پاسخ موفقیت‌آمیز
```json
{
"message": "Your password has been changed successfully."
}
```
---
## 📄 نمونه پاسخ خطا
### رمز عبور و تأیید رمز عبور برابر نیستند:
```json
{
"status": "error",
"code": "validation_error",
"status_code": 400,
"message": "Passwords do not match."
}
```
### رمز عبور کوتاه‌تر از 8 کاراکتر است:
```json
{
"status": "error",
"code": "validation_error",
"status_code": 400,
"message": "Password must be at least 8 characters long."
}
```
### عدم احراز هویت:
```json
{
"status": "error",
"code": "unauthorized",
"status_code": 401,
"message": "Authentication credentials were not provided or are invalid."
}
```
### مشکل موقتی در سرور:
```json
{
"status": "error",
"code": "service_unavailable",
"status_code": 500,
"message": "Service temporarily unavailable."
}
```
---
## 💡 نکات مهم:
1. **رمز عبور جدید:**
- باید حداقل 8 کاراکتر باشد و تأیید رمز عبور (`password_confirmation`) باید با رمز عبور اصلی یکسان باشد.
2. **امنیت:**
- کاربر باید توکن احراز هویت معتبر برای تنظیم مجدد رمز عبور ارائه دهد.
3. **توکن احراز هویت:**
- فقط کاربران احراز هویت شده میتوانند رمز عبور خود را تغییر دهند.
---
## 🔧 توضیحات فنی:
### فرآیند تنظیم مجدد رمز عبور:
1. کاربر باید ابتدا کد بازیابی رمز عبور را تأیید کند.
2. پس از تأیید موفقیتآمیز، کاربر با استفاده از توکن احراز هویت، رمز عبور جدید و تأیید آن را وارد میکند.
3. اگر دادهها معتبر باشند، رمز عبور جدید برای کاربر تنظیم میشود.
4. اگر دادهها نادرست باشند، پیام خطای مناسب به کاربر بازگردانده میشود.
### ولیدیشن‌ها:
- **رمز عبور:**
- بررسی میشود که رمز عبور حداقل 8 کاراکتر باشد.
- بررسی میشود که رمز عبور و تأیید آن یکسان باشند.
---
## 📄 نمونه درخواست:
### درخواست کامل:
```json
{
"password": "mynewpassword",
"password_confirmation": "mynewpassword"
}
```
### پاسخ موفق:
```json
{
"message": "Your password has been changed successfully."
}
```
"""
def doc_recover():
return """
# 🐈 Scenario
🛠 بازیابی رمز عبور
کاربر با وارد کردن ایمیل خود، درخواست بازیابی رمز عبور میدهد.
یک کد تأیید به ایمیل کاربر ارسال میشود تا کاربر بتواند رمز عبور خود را بازیابی کند.
سپس کاربر باید به صفحه وریفای ریدایرکت شود
و بعد از تایید وریفای با توکن داده شده
به صفحه ریست پسورد ریدایرکت میشود تا پسور جدیدی را ست کند
---
## 🚀 درخواست API
### URL:
```
POST /api/recover-password/
```
### Header:
| کلید | مقدار |
|---------------|---------------------------------|
| Content-Type | application/json |
| Authorization | Optional (برای این endpoint نیاز نیست) |
### Body:
```json
{
"email": "johndoe@example.com"
}
```
---
## 📊 پاسخ‌ها
| کد وضعیت | توضیحات |
|---------------|-----------------------------------------------------------|
| `202` | موفقیتآمیز - کد بازیابی رمز عبور به ایمیل کاربر ارسال شد. |
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. |
| `404` | کاربر یافت نشد. |
| `500` | مشکل موقتی در سرور. |
---
## 📄 نمونه پاسخ موفقیت‌آمیز
```json
{
"id": 1,
"fullname": "John Doe",
"phone_number": "1234567890",
"email": "johndoe@example.com",
"avatar": null,
"message": "Forgot password code sent"
}
```
---
## 📄 نمونه پاسخ خطا
### کاربر یافت نشد:
```json
{
"status": "error",
"code": "not_found",
"status_code": 404,
"message": "User not found."
}
```
### مشکل موقتی در سرور:
```json
{
"status": "error",
"code": "service_unavailable",
"status_code": 500,
"message": "Service temporarily unavailable."
}
```
---
## 💡 نکات مهم:
1. **کد بازیابی رمز عبور:**
- کد تأیید به ایمیل کاربر ارسال میشود و باید در مرحله بعدی برای بازیابی رمز عبور استفاده شود.
2. **امنیت:**
- کد بازیابی رمز عبور فقط برای مدت محدود اعتبار دارد و بعد از آن منقضی میشود.
---
## 🔧 توضیحات فنی:
### فرآیند بازیابی رمز عبور:
1. کاربر ایمیل خود را وارد میکند.
2. سیستم بررسی میکند که آیا کاربری با این ایمیل وجود دارد یا خیر.
3. اگر کاربر یافت شود، یک کد تأیید بازیابی رمز عبور به ایمیل کاربر ارسال میشود.
4. کاربر باید این کد را در مرحله بعدی برای تنظیم رمز عبور جدید وارد کند.
### ولیدیشن‌ها:
- **ایمیل:**
- بررسی میشود که ایمیل وارد شده معتبر باشد.
- اگر کاربری با این ایمیل یافت نشود، پیام خطای مناسب برگردانده میشود.
---
## 📄 نمونه درخواست:
### درخواست کامل:
```json
{
"email": "janedoe@example.com"
}
```
### پاسخ موفق:
```json
{
"id": 2,
"fullname": "Jane Doe",
"phone_number": "0987654321",
"email": "janedoe@example.com",
"avatar": null,
"message": "Forgot password code sent"
}
```
"""
def doc_login():
return """
# 🐈 Scenario
🛠 ورود به حساب کاربری
کاربر با وارد کردن ایمیل و رمز عبور خود به سیستم وارد میشود. اگر اعتبارنامهها معتبر باشند، توکن احراز هویت برای دسترسی به دیگر بخشهای سیستم بازگردانده میشود.
---
## 🚀 درخواست API
### URL:
```
POST /api/login/
```
### Header:
| کلید | مقدار |
|---------------|---------------------------------|
| Content-Type | application/json |
| Authorization | Optional (برای این endpoint نیاز نیست) |
### Body:
```json
{
"email": "johndoe@example.com",
"password": "strongpassword",
"fcm": "fcm_token_optional",
"device_id": "device_id_optional"
}
```
---
## 📊 پاسخ‌ها
| کد وضعیت | توضیحات |
|---------------|-----------------------------------------------------------|
| `201` | موفقیتآمیز - کاربر با موفقیت وارد شد و توکن احراز هویت بازگردانده شد. |
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. |
| `404` | کاربر یافت نشد. |
| `500` | مشکل موقتی در سرور. |
---
## 📄 نمونه پاسخ موفقیت‌آمیز
```json
{
"id": 1,
"fullname": "John Doe",
"email": "johndoe@example.com",
"token": "abc123def456",
"avatar": "https://example.com/avatar.jpg"
}
```
---
## 📄 نمونه پاسخ خطا
### ورود ناموفق (اطلاعات اشتباه):
```json
{
"status": "error",
"code": "invalid_credentials",
"status_code": 400,
"message": "Unable to log in with provided credentials."
}
```
### کاربر یافت نشد:
```json
{
"status": "error",
"code": "not_found",
"status_code": 404,
"message": "User not found."
}
```
### مشکل موقتی در سرور:
```json
{
"status": "error",
"code": "service_unavailable",
"status_code": 500,
"message": "Service temporarily unavailable."
}
```
---
## 💡 نکات مهم:
1. **رمز عبور:**
- رمز عبور باید صحیح و مطابق با آنچه کاربر هنگام ثبتنام ارائه کرده است، باشد.
2. **توکن احراز هویت:**
- پس از ورود موفقیتآمیز، توکن احراز هویت به کاربر بازگردانده میشود که برای دسترسی به دیگر بخشهای سیستم نیاز است.
3. **اطلاعات دستگاه:**
- `fcm` و `device_id` به عنوان اطلاعات اختیاری برای شناسایی دستگاه ارسال میشوند.
---
## 🔧 توضیحات فنی:
### فرآیند ورود به حساب کاربری:
1. کاربر ایمیل و رمز عبور خود را وارد میکند.
2. سیستم سعی میکند کاربر را با استفاده از اعتبارنامههای ارائه شده احراز هویت کند.
3. اگر کاربر یافت شود و اعتبارنامهها صحیح باشند، یک توکن احراز هویت ایجاد شده و به کاربر بازگردانده میشود.
4. اگر اعتبارنامه نادرست باشند، پیام خطا برگردانده میشود.
### ولیدیشن‌ها:
- **ایمیل و رمز عبور:**
- بررسی میشود که ایمیل و رمز عبور وارد شده معتبر باشند.
- اگر کاربر با این ایمیل و رمز عبور یافت نشود، پیام خطای مناسب برگردانده میشود.
---
## 📄 نمونه درخواست:
### درخواست کامل:
```json
{
"email": "janedoe@example.com",
"password": "mypassword",
"fcm": "fcm_token_example",
"device_id": "device_id_example"
}
```
### پاسخ موفق:
```json
{
"id": 2,
"fullname": "Jane Doe",
"email": "janedoe@example.com",
"token": "xyz987uvw654",
"avatar": null
}
```
"""
def doc_verify():
return """
# 🐈 Scenario
📅 تأیید حساب کاربری با کد تأیید
کاربر پس از ثبتنام، باید با استفاده از کد تأییدی که به ایمیل او ارسال شده است،
حساب کاربری خود را تأیید کند. در این مرحله، کاربر ایمیل و کد تأیید خود را ارسال میکند.
---
## 🚀 درخواست API
### URL:
```
POST /api/verify/
```
### Header:
| کلید | مقدار |
|---------------|---------------------------------|
| Content-Type | application/json |
| Authorization | Optional (برای این endpoint نیاز نیست) |
### Body:
```json
{
"email": "johndoe@example.com",
"code": "12345"
}
```
---
## 📊 پاسخ‌ها
| کد وضعیت | توضیحات |
|---------------|-----------------------------------------------------------|
| `201` | موفقیتآمیز - کاربر تأیید شد و توکن احراز هویت بازگردانده شد. |
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. |
| `404` | کاربر یا کد تأیید یافت نشد. |
| `410` | کد تأیید منقضی شده است. |
| `500` | مشکل موقتی در سرور. |
---
## 📄 نمونه پاسخ موفقیت‌آمیز
```json
{
"token": "abc123def456",
"user_id": 1,
"phone_number": "1234567890",
"email": "johndoe@example.com",
"fullname": "John Doe",
"avatar": null
}
```
---
## 📄 نمونه پاسخ خطا
### کد تأیید نادرست:
```json
{
"status": "error",
"code": "invalid_verification_code",
"status_code": 400,
"message": "The verification code is invalid."
}
```
### کد تأیید منقضی شده است:
```json
{
"status": "error",
"code": "expired_code",
"status_code": 410,
"message": "The verification code has expired."
}
```
### کاربر یا کد تأیید یافت نشد:
```json
{
"status": "error",
"code": "not_found",
"status_code": 404,
"message": "Verification data not found or expired."
}
```
### مشکل موقتی در سرور:
```json
{
"status": "error",
"code": "service_unavailable",
"status_code": 500,
"message": "Service temporarily unavailable."
}
```
---
## 💡 نکات مهم:
1. **کد تأیید:**
- کد تأیید باید دقیقاً با کدی که به ایمیل کاربر ارسال شده مطابقت داشته باشد.
- کد تأیید فقط برای یک مدت محدود اعتبار دارد.
2. **خطاها:**
- اگر کد تأیید نادرست باشد، پیام مناسب بازگردانده میشود.
- اگر کد تأیید منقضی شده باشد، کاربر باید درخواست کد جدید کند.
3. **توکن احراز هویت:**
- پس از تأیید موفقیتآمیز، توکن احراز هویت به کاربر بازگردانده میشود که برای دسترسی به دیگر بخشهای سیستم نیاز است.
---
### ولیدیشن‌ها:
- **کد تأیید:**
- باید حداکثر 5 کاراکتر باشد.
- اگر کد معتبر نباشد یا منقضی شده باشد، پیام خطای مناسب برگردانده میشود.
---
## 📄 نمونه درخواست:
### درخواست کامل:
```json
{
"email": "janedoe@example.com",
"code": "67890"
}
```
### پاسخ موفق:
```json
{
"token": "xyz987uvw654",
"user_id": 2,
"phone_number": "0987654321",
"email": "janedoe@example.com",
"fullname": "Jane Doe",
"avatar": null
}
```
"""
def doc_register():
return """
# 🐈 Scenario
ثبت نام کاربر
کاربر با وارد کردن اطلاعات مورد نیاز شامل نام کامل، ایمیل، رمز عبور و تأیید رمز عبور درخواست ثبتنام ارسال میکند. پس از ثبت موفق، یک کد تأیید به ایمیل ارسال میشود که برای تکمیل ثبتنام مورد نیاز است.
---
## 🚀 درخواست API
### URL:
```
POST /api/register/
```
### Header:
| کلید | مقدار |
|---------------|---------------------------------|
| Content-Type | application/json |
| Authorization | Optional (برای این endpoint نیاز نیست) |
### Body:
```json
{
"fullname": "John Doe",
"email": "johndoe@example.com",
"password": "strongpassword",
"password_confirmation": "strongpassword",
"fcm": "fcm_token_optional",
"device_id": "device_id_optional"
}
```
---
## 📊 پاسخ‌ها
| کد وضعیت | توضیحات |
|---------------|-----------------------------------------------------------|
| `202` | موفقیتآمیز - کد تأیید به ایمیل کاربر ارسال شد. |
| `400` | درخواست نادرست - مشکلات مربوط به دادههای ارسالی. |
| `409` | ایمیل قبلاً ثبت شده است. |
| `404` | کاربر یا منبع یافت نشد. |
| `410` | کد تأیید منقضی شده است. |
| `500` | مشکل موقتی در سرور. |
---
## 📄 نمونه پاسخ موفقیت‌آمیز
```json
{
"user": {
"id": 1,
"fullname": "John Doe",
"email": "johndoe@example.com"
},
"message": "The otp code was sent to the user's email"
}
```
---
## 📄 نمونه پاسخ خطا
### ایمیل تکراری:
```json
{
"status": "error",
"code": "validation_error",
"status_code": 409,
"message": "There were validation errors.",
"errors": [
{
"field": "email",
"message": "This email is already registered."
}
]
}
```
### رمز عبور و تأیید رمز عبور برابر نیستند:
```json
{
"status": "error",
"code": "validation_error",
"status_code": 400,
"message": "There were validation errors.",
"errors": [
{
"field": "password_confirmation",
"message": "Passwords do not match."
}
]
}
```
### رمز عبور کوتاه‌تر از 8 کاراکتر است:
```json
{
"status": "error",
"code": "validation_error",
"status_code": 400,
"message": "There were validation errors.",
"errors": [
{
"field": "password",
"message": "Password must be at least 8 characters long."
}
]
}
```
### درخواست نامعتبر (فیلدهای اجباری):
```json
{
"status": "error",
"code": "validation_error",
"status_code": 400,
"message": "There were validation errors.",
"errors": [
{
"field": "fullname",
"message": "This field is required."
},
{
"field": "email",
"message": "This field is required."
},
{
"field": "password",
"message": "This field is required."
},
{
"field": "password_confirmation",
"message": "This field is required."
}
]
}
```
### کاربر یافت نشد:
```json
{
"status": "error",
"code": "not_found",
"status_code": 404,
"message": "The requested resource was not found."
}
```
### کد تأیید منقضی شده است:
```json
{
"status": "error",
"code": "expired_code",
"status_code": 410,
"message": "The verification code has expired."
}
```
### مشکل موقتی در سرور:
```json
{
"status": "error",
"code": "service_unavailable",
"status_code": 500,
"message": "Service temporarily unavailable."
}
```
---
## 💡 نکات مهم:
1. **رمز عبور:**
- باید حداقل 8 کاراکتر باشد.
- رمز عبور و تأیید رمز عبور (`password_confirmation`) باید یکسان باشند.
2. **ایمیل:**
- باید یک آدرس ایمیل معتبر باشد.
- ایمیلهای تکراری مجاز نیستند.
3. **کد OTP:**
- کد تأیید به ایمیل ارسال میشود و برای وریفای کاربر استفاده میشود.
4. **فیلدهای اختیاری:**
- `fcm` و `device_id` در صورت نیاز میتوانند ارسال شوند اما اجباری نیستند.
---
### ولیدیشن‌ها:
- **ایمیل:**
- بررسی میشود که در سیستم موجود نباشد.
- اگر موجود باشد، پیام خطای زیر برگردانده میشود:
```json
{
"status": "error",
"code": "validation_error",
"status_code": 409,
"message": "There were validation errors.",
"errors": [
{
"field": "email",
"message": "This email is already registered."
}
]
}
```
- **رمز عبور:**
- بررسی میشود که حداقل 8 کاراکتر باشد:
```json
{
"status": "error",
"code": "validation_error",
"status_code": 400,
"message": "There were validation errors.",
"errors": [
{
"field": "password",
"message": "Password must be at least 8 characters long."
}
]
}
```
- بررسی میشود که با `password_confirmation` یکسان باشد:
```json
{
"status": "error",
"code": "validation_error",
"status_code": 400,
"message": "There were validation errors.",
"errors": [
{
"field": "password_confirmation",
"message": "Passwords do not match."
}
]
}
```
---
## 📄 نمونه درخواست:
### درخواست کامل:
```json
{
"fullname": "Jane Doe",
"email": "janedoe@example.com",
"password": "securepassword",
"password_confirmation": "securepassword",
"fcm": "fcm_token_example",
"device_id": "device_id_example"
}
```
### پاسخ موفق:
```json
{
"user": {
"id": 2,
"fullname": "Jane Doe",
"email": "janedoe@example.com"
},
"message": "The otp code was sent to the user's email"
}
```
"""

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

77
apps/account/manager.py

@ -0,0 +1,77 @@
from django.contrib.auth.models import BaseUserManager
from django.contrib.auth.models import Group
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, password, **extra_fields):
user = self.create_user(
email=email,
password=password,
**extra_fields
)
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, user, new_user_type):
group_name = f"{new_user_type.capitalize()} Group"
if user.user_type != new_user_type and not user.groups.filter(name=group_name).exists():
user.user_type = new_user_type
new_group, _ = Group.objects.get_or_create(name=group_name)
user.groups.add(new_group)
user.save()
return user
return None
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")

151
apps/account/migrations/0001_initial.py

@ -0,0 +1,151 @@
# Generated by Django 5.1.8 on 2025-04-03 00:05
import dj_language.field
import django.db.models.deletion
import phonenumber_field.modelfields
import utils.validators
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
('dj_language', '0002_auto_20220120_1344'),
]
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')),
('username', models.CharField(blank=True, max_length=150, null=True, unique=True)),
('email', models.EmailField(blank=True, help_text="Enter the user's email address.", max_length=254, null=True, unique=True, verbose_name='Email Address')),
('fullname', models.CharField(blank=True, help_text='Enter the full name of the user.', max_length=255, null=True, verbose_name='Full Name')),
('birthdate', models.DateField(blank=True, null=True, 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, 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')),
('date_joined', models.DateTimeField(auto_now_add=True, help_text='The date and time the user registered.', verbose_name='Date Joined')),
('city', models.CharField(blank=True, max_length=255, null=True, verbose_name='City')),
('country', models.CharField(blank=True, max_length=255, null=True, verbose_name='country')),
('device_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='device id')),
('device_os', models.CharField(choices=[('android', 'android'), ('apple', 'apple')], max_length=16, null=True)),
('fcm', models.CharField(blank=True, max_length=512, null=True)),
('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)),
('info', models.TextField(blank=True, null=True, verbose_name='Info')),
('skill', models.CharField(blank=True, max_length=512, 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': 'All Users',
'verbose_name_plural': 'All Users',
'ordering': ('-id',),
'unique_together': {('email', 'device_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',),
),
migrations.CreateModel(
name='LoginHistory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('lat', models.FloatField(blank=True, null=True, verbose_name='lat')),
('lon', models.FloatField(blank=True, null=True, verbose_name='lon')),
('country', models.CharField(blank=True, max_length=255, null=True, verbose_name='country')),
('city', models.CharField(blank=True, max_length=255, null=True, verbose_name='city')),
('ip', models.CharField(max_length=255, null=True)),
('timezone', models.CharField(blank=True, max_length=100, null=True)),
('at_time', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_history', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255, verbose_name='title')),
('message', models.TextField(max_length=512, verbose_name='message')),
('is_read', models.BooleanField(default=False, verbose_name='is read')),
('service', models.CharField(choices=[('imam-javad', 'Imam Javad'), ('doboodi', 'Doboodi')], default='imam-javad', max_length=20, verbose_name='service')),
('created_at', models.DateTimeField(auto_now_add=True, null=True, verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, null=True, verbose_name='updated at')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='user')),
],
),
]

20
apps/account/migrations/0002_alter_user_phone_number.py

@ -0,0 +1,20 @@
# Generated by Django 5.1.8 on 2025-04-04 00:09
import phonenumber_field.modelfields
import utils.validators
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('account', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='user',
name='phone_number',
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='e.g., +49 151 12345678', max_length=128, null=True, region=None, validators=[utils.validators.validate_possible_number], verbose_name='Phone Number'),
),
]

0
apps/account/migrations/__init__.py

3
apps/account/models/__init__.py

@ -0,0 +1,3 @@
from .user import *
from .groups import *
from .notification 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"

25
apps/account/models/notification.py

@ -0,0 +1,25 @@
from django.db import models
from django.utils.translation import gettext as _
class Notification(models.Model):
class ServiceChoices(models.TextChoices):
IMAM_JAVAD = 'imam-javad', 'Imam Javad'
DOBOODI = 'doboodi', 'Doboodi'
title = models.CharField(max_length=255, verbose_name=_('title'))
message = models.TextField(max_length=512, verbose_name=_('message'))
user = models.ForeignKey("account.User", on_delete=models.CASCADE, verbose_name=_('user'), related_name='notifications')
is_read = models.BooleanField(default=False, verbose_name=_('is read'))
service = models.CharField(
max_length=20,
choices=ServiceChoices.choices,
default=ServiceChoices.IMAM_JAVAD,
verbose_name=_('service')
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'), null=True)
updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'), null=True)
def __str__(self):
return self.title

125
apps/account/models/user.py

@ -0,0 +1,125 @@
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 DeviceOs(models.TextChoices):
android = 'android', 'android'
apple = 'apple', 'apple'
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'
last_name = None
first_name = None
username = models.CharField(unique=True, null=True, blank=True, max_length=150)
email = models.EmailField(unique=True, verbose_name="Email Address", help_text="Enter the user's email address.", null=True, blank=True)
fullname = models.CharField(max_length=255, verbose_name="Full Name", help_text="Enter the full name of the user.", null=True, blank=True)
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(
validators=[validate_possible_number],
null=True,
blank=True,
verbose_name=_('Phone Number'),
help_text="e.g., +49 151 12345678"
)
language = LanguageField(null=True)
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.")
date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Date Joined", help_text="The date and time the user registered.")
city = models.CharField(verbose_name=_('City'), max_length=255, null=True, blank=True)
country = models.CharField(max_length=255, verbose_name=_('country'), null=True, blank=True)
device_id = models.CharField(verbose_name=_('device id'), max_length=255, null=True, blank=True)
device_os = models.CharField(choices=DeviceOs.choices, null=True, max_length=16)
fcm = models.CharField(max_length=512, null=True, blank=True)
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)
skill = models.CharField(max_length=512, null=True, blank=True)
objects = UserManager()
EMAIL_FIELD = "email"
USERNAME_FIELD = "email"
REQUIRED_FIELDS = []
def __str__(self):
username = self.email or self.fullname or self.device_id
return f"{username}-({self.user_type})"
def soft_delete(self):
self.deleted_at = timezone.now()
self.is_active = False
self.fullname = f'{self.fullname}:deleted'
number = str(random.randint(1000000000, 9999999999))
self.phone_number = f'{self.phone_number}:deleted{number}'
self.email = f'{self.email}:deleted{number}' if self.email else None
self.save()
def save(self, *args, **kwargs):
self.username = self.email
if User.objects.filter(username=self.email).count():
self.username = f'{self.email}:{self.id}'
return super().save(*args, **kwargs)
def get_full_name(self):
return self.fullname
@property
def is_guest(self):
return self.email is None
@property
def user_type_based_on_groups(self):
if self.groups.filter(name="Student Group").exists():
return self.UserType.STUDENT
elif self.groups.filter(name="Professor Group").exists():
return self.UserType.PROFESSOR
else:
return self.UserType.CLIENT
class Meta:
ordering = ("-id",)
verbose_name = "All Users"
verbose_name_plural = "All Users"
unique_together = (
'email', 'device_id'
)
class LoginHistory(models.Model):
user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='login_history')
lat = models.FloatField(verbose_name=_('lat'), null=True, blank=True)
lon = models.FloatField(verbose_name=_('lon'), null=True, blank=True)
country = models.CharField(max_length=255, verbose_name=_('country'), null=True, blank=True)
city = models.CharField(max_length=255, verbose_name=_('city'), null=True, blank=True)
ip = models.CharField(max_length=255, null=True)
timezone = models.CharField(max_length=100, null=True, blank=True)
at_time = models.DateTimeField(auto_now_add=True)

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 *
from .notification import *

26
apps/account/serializers/notification.py

@ -0,0 +1,26 @@
from rest_framework import serializers
from apps.account.models import Notification
from apps.account.models import User
class NotificationSerializer(serializers.ModelSerializer):
user_type = serializers.ChoiceField(choices=[('user', 'User'), ('merchant', 'Merchant')], default='user')
service = serializers.ChoiceField(choices=Notification.ServiceChoices.choices, default=Notification.ServiceChoices.IMAM_JAVAD)
class Meta:
model = Notification
fields = ['id', 'title', 'message', 'is_read', 'user_type', 'service', 'created_at', 'updated_at']
class NotificationSendSerializer(serializers.Serializer):
title = serializers.CharField()
body = serializers.CharField()
data = serializers.DictField(required=False)
account_id = serializers.CharField(required=True)
user_type = serializers.CharField(required=True)
service = serializers.ChoiceField(choices=Notification.ServiceChoices.choices, default=Notification.ServiceChoices.IMAM_JAVAD)

143
apps/account/serializers/user.py

@ -0,0 +1,143 @@
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)
gender = serializers.ChoiceField(
choices=User.GenderChoices.choices,
required=False,
help_text="Select the user's gender."
)
fcm = serializers.CharField(required=False, help_text="Firebase Cloud Messaging token.")
class Meta:
model = User
fields = ['id', 'device_id', 'fcm', 'fullname', 'avatar', 'email', 'phone_number', 'password', 'info', 'skill', 'city', 'country', 'birthdate', 'gender']
read_only_fields = ['email', 'info', 'skill', 'device_id']
# 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):
for attr, value in validated_data.items():
if value is not None:
setattr(instance, attr, value)
instance.save()
return instance
class UserRegisterSerializer(serializers.ModelSerializer):
fcm = serializers.CharField(required=False)
device_id = serializers.CharField(required=True)
email = serializers.EmailField()
class Meta:
model = User
fields = ['id','fullname', 'email', 'fcm', 'device_id']
extra_kwargs = {
'fullname': {'required': True,},
'email': {'required': True,},
'device_id': {'required': True,},
}
def validate_email(self, value):
if User.objects.filter(email=value).exists():
raise serializers.ValidationError("This email is already registered.")
return value
class UserVerifySerializer(serializers.Serializer):
code = serializers.CharField(max_length=5, validators=[validate_type_code])
email = serializers.EmailField()
device_id = serializers.CharField(max_length=255, required=False)
class UserLoginSerializer(serializers.Serializer):
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)
timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True)
def validate(self, data):
# Custom validation logic can be added here if needed
# data.pop('fcm', None)
# data.pop('device_id', None)
return data
# class UserLoginSerializer(serializers.Serializer):
# 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)
# timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True)
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)
class Meta:
model = User
fields = ['password', ]
extra_kwargs = {
'password': {'required': True,},
}
class UserGuestSerializer(serializers.ModelSerializer):
lat = serializers.CharField(max_length=255, allow_null=True, allow_blank=True, required=False)
lon = serializers.CharField(max_length=255, allow_null=True, allow_blank=True, required=False)
fcm = serializers.CharField(required=False)
device_id = serializers.CharField(required=False)
device_os = serializers.ChoiceField(choices=User.DeviceOs.choices, required=False)
timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True)
class Meta:
model = User
fields = ['device_id', 'fcm', 'device_os', 'lat', 'lon', 'timezone']
def validate(self, data):
# Make sure at least device_id is provided
if not data.get('device_id'):
raise serializers.ValidationError({"device_id": "Device ID is required for guest users."})
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)

40
apps/account/templates/account/group_help_text.html

@ -0,0 +1,40 @@
{% load unfold i18n %}
<div class="border border-base-300 border-dashed mb-4 p-3 rounded dark:border-base-700">
{% trans "Driver before template" %}
</div>
<div class="grid gap-4 mb-4 md:grid-cols-2 lg:grid-cols-4 ">
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Active drivers" %}
{% endcomponent %}
{% component "unfold/components/title.html" with component_class="DriverActiveComponent" %}{% endcomponent %}
{% endcomponent %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Inactive drivers" %}
{% endcomponent %}
{% component "unfold/components/title.html" with component_class="DriverInactiveComponent" %}{% endcomponent %}
{% endcomponent %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Total points" %}
{% endcomponent %}
{% component "unfold/components/title.html" with component_class="DriverTotalPointsComponent" %}{% endcomponent %}
{% endcomponent %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Total races" %}
{% endcomponent %}
{% component "unfold/components/title.html" with component_class="DriverRacesComponent" %}{% endcomponent %}
{% endcomponent %}
</div>

800
apps/account/templates/account/json_editor_field.html

@ -0,0 +1,800 @@
{% load i18n %}
<div class="json-editor-container">
<textarea style="display: none" name="{{ widget.name }}" id="{{ widget.attrs.id }}"
{% include "django/forms/widgets/attrs.html" %}
>{% if widget.value %}{{ widget.value }}{% endif %}</textarea>
<div class="json-view-editor" id='date-view-editor-{{ widget.attrs.id }}'></div>
</div>
<script defer="defer">
document.addEventListener('DOMContentLoaded', function() {
function initJsonEditor() {
let editor_ = document.getElementById("{{ widget.attrs.id }}");
if (!editor_) {
console.error("Editor element not found");
return;
}
let startValue;
try {
startValue = editor_.value && editor_.value.trim() !== '' ? JSON.parse(editor_.value) : [];
} catch (e) {
console.error("Error parsing JSON value:", e);
startValue = [];
}
let jsonViewerDiv = document.getElementById('date-view-editor-{{ widget.attrs.id }}');
if (typeof JSONEditor === 'undefined') {
console.error("JSONEditor is not defined. Make sure the library is loaded.");
return;
}
// Custom template for add button
JSONEditor.defaults.templates.button = function(text, icon, title) {
let el = document.createElement('button');
el.type = 'button';
el.classList.add('json-editor-btn-modern');
if (icon) {
let iconEl = document.createElement('span');
iconEl.classList.add('json-editor-btn-icon');
iconEl.innerHTML = icon;
el.appendChild(iconEl);
}
if (text) {
let textEl = document.createElement('span');
textEl.classList.add('json-editor-btn-text');
textEl.textContent = text;
el.appendChild(textEl);
}
if (title) el.title = title;
return el;
};
// Custom icons
JSONEditor.defaults.iconlib = {
getIcon: function(key) {
switch(key) {
case 'add':
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/></svg>';
case 'delete':
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/><path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/></svg>';
case 'edit':
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>';
case 'moveup':
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 15a.5.5 0 0 0 .5-.5V2.707l3.146 3.147a.5.5 0 0 0 .708-.708l-4-4a.5.5 0 0 0-.708 0l-4 4a.5.5 0 1 0 .708.708L7.5 2.707V14.5a.5.5 0 0 0 .5.5z"/></svg>';
case 'movedown':
return '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 1a.5.5 0 0 1 .5.5v11.793l3.146-3.147a.5.5 0 0 1 .708.708l-4 4a.5.5 0 0 1-.708 0l-4-4a.5.5 0 0 1 .708-.708L7.5 13.293V1.5A.5.5 0 0 1 8 1z"/></svg>';
default:
return '';
}
}
};
try {
let jsonEditor = new JSONEditor(
jsonViewerDiv, {
theme: 'bootstrap4',
schema: {{ widget.attrs.schema|safe }},
disable_edit_json: true,
disable_properties: true,
disable_array_delete_all_rows: false,
disable_array_delete_last_row: true, // Disable delete last row button
disable_array_reorder: true, // Disable array reordering
grid_columns: 12,
prompt_before_delete: true,
disable_collapse: false, // Enable collapse to show button sections
show_errors: 'always',
startval: startValue,
iconlib: 'custom',
object_layout: 'normal', // Changed from grid to normal for better layout
enable_array_copy: false, // Disable copy functionality
show_opt_in: false,
compact: false,
array_controls_top: false, // Move array controls to bottom
show_button_bar: true, // Show button bar
form_name_root: 'root' // Add a root name for better structure
}
);
// Store the editor instance on the textarea
editor_.editor = jsonEditor;
// Update the textarea when the editor changes
jsonEditor.on('change', function() {
editor_.value = JSON.stringify(jsonEditor.getValue());
// Trigger a change event on the textarea
let event = new Event('change', { bubbles: true });
editor_.dispatchEvent(event);
// Apply styling to newly added elements
applyCustomStyling();
});
// Function to apply custom styling to all elements
function applyCustomStyling() {
// Add modern styling to buttons
const allButtons = jsonViewerDiv.querySelectorAll('button');
allButtons.forEach(button => {
if (!button.classList.contains('json-editor-btn-modern')) {
button.classList.add('json-editor-btn-modern');
// Add specific styling based on button type
if (button.classList.contains('json-editor-btntype-add')) {
button.classList.add('json-editor-btn-add');
} else if (button.classList.contains('json-editor-btntype-delete') ||
button.classList.contains('json-editor-btntype-deleteall') ||
button.classList.contains('json-editor-btntype-deletelast')) {
button.classList.add('json-editor-btn-delete');
}
}
});
// Style form controls
const formControls = jsonViewerDiv.querySelectorAll('input, select, textarea');
formControls.forEach(control => {
control.classList.add('modern-form-control');
});
// Style table headers
const tableHeaders = jsonViewerDiv.querySelectorAll('th');
tableHeaders.forEach(header => {
header.classList.add('modern-table-header');
});
// Style table rows
const tableRows = jsonViewerDiv.querySelectorAll('tr');
tableRows.forEach(row => {
row.classList.add('modern-table-row');
});
// Make table full width
const tables = jsonViewerDiv.querySelectorAll('table');
tables.forEach(table => {
table.classList.add('full-width-table');
});
// Make table cells take equal space
const tableCells = jsonViewerDiv.querySelectorAll('td');
tableCells.forEach(cell => {
if (!cell.classList.contains('table-controls-cell')) {
cell.classList.add('equal-width-cell');
}
});
// Add special styling to control cells
const controlCells = jsonViewerDiv.querySelectorAll('td:last-child');
controlCells.forEach(cell => {
cell.classList.add('table-controls-cell');
});
// Fix button group styling
const buttonGroups = jsonViewerDiv.querySelectorAll('.btn-group, .json-editor-btngroup');
buttonGroups.forEach(group => {
group.classList.add('modern-btn-group');
});
// Fix card styling
const cards = jsonViewerDiv.querySelectorAll('.card');
cards.forEach(card => {
card.classList.add('modern-card');
});
// Ensure button sections are visible
const buttonSections = jsonViewerDiv.querySelectorAll('.json-editor-btngroup');
buttonSections.forEach(section => {
section.style.display = 'flex';
section.style.visibility = 'visible';
});
// Style button bars
const buttonBars = jsonViewerDiv.querySelectorAll('.json-editor-btn-bar');
buttonBars.forEach(bar => {
bar.style.display = 'flex';
bar.style.flexWrap = 'wrap';
bar.style.gap = '0.5rem';
bar.style.marginTop = '1rem';
bar.style.marginBottom = '0';
bar.style.padding = '0.75rem';
bar.style.backgroundColor = 'rgba(1, 53, 59, 0.05)';
bar.style.borderRadius = '0.5rem';
bar.style.width = '100%';
bar.style.justifyContent = 'flex-end';
// Move button bar to the end of its parent container
const parent = bar.parentElement;
if (parent) {
parent.style.display = 'flex';
parent.style.flexDirection = 'column';
parent.appendChild(bar);
}
});
// Hide specific buttons (Copy, Move up, Move down, Delete Last)
const buttonsToHide = jsonViewerDiv.querySelectorAll('.json-editor-btntype-copy, .json-editor-btntype-move, .json-editor-btntype-deletelast');
buttonsToHide.forEach(button => {
button.style.display = 'none';
});
// Ensure form fields take full width
const formRows = jsonViewerDiv.querySelectorAll('.row');
formRows.forEach(row => {
row.style.width = '100%';
const cols = row.querySelectorAll('[class*="col-"]');
cols.forEach(col => {
col.style.width = '100%';
col.style.maxWidth = '100%';
col.style.flex = '0 0 100%';
});
});
}
// Apply styling immediately after initialization
setTimeout(applyCustomStyling, 100);
// Process error messages to make them HTML5-like
function processErrorMessages() {
const errorElements = jsonViewerDiv.querySelectorAll('.je-error');
errorElements.forEach(error => {
// Get the error message text
const errorText = error.textContent.trim();
// Set the data-content attribute for the tooltip
error.setAttribute('data-content', errorText);
// Find the parent form group
const formGroup = error.closest('.form-group');
if (formGroup) {
formGroup.classList.add('has-error');
// Find the input element
const input = formGroup.querySelector('input, select, textarea');
if (input) {
// Add error class to the input
input.classList.add('is-invalid');
// Add title attribute for native tooltip
input.setAttribute('title', errorText);
}
}
});
}
// Remove "Delete Last Course Features" button if it exists
function removeDeleteLastButton() {
const deleteLastButtons = jsonViewerDiv.querySelectorAll('button.json-editor-btntype-deletelast');
deleteLastButtons.forEach(button => {
const buttonText = button.textContent.trim();
if (buttonText.includes('Delete Last') && buttonText.includes('Course Features')) {
button.style.display = 'none';
}
});
}
// Add mutation observer to apply styling to dynamically added elements
const observer = new MutationObserver(function(mutations) {
applyCustomStyling();
processErrorMessages();
removeDeleteLastButton();
});
observer.observe(jsonViewerDiv, {
childList: true,
subtree: true
});
// Initial processing
setTimeout(() => {
processErrorMessages();
removeDeleteLastButton();
}, 200);
} catch (e) {
console.error("Error initializing JSONEditor:", e);
}
}
// Initialize the editor
if (document.readyState === 'complete') {
initJsonEditor();
} else {
window.addEventListener('load', initJsonEditor);
}
});
</script>
<style>
/* Modern JSON Editor Container - Unfold theme */
.json-editor-container {
margin-bottom: 1.5rem;
background-color: #fff;
border-radius: 0.5rem;
overflow: hidden;
width: 100%;
box-shadow: 0 1px 3px rgba(1, 53, 59, 0.08);
border: 1px solid rgba(1, 53, 59, 0.05);
}
/* Editor container */
.json-view-editor {
width: 100%;
border: none;
padding: 1.25rem;
}
/* Card styling */
.card {
border: none !important;
margin-bottom: 1.25rem !important;
background-color: transparent !important;
}
.card-body {
padding: 0.75rem 0 !important;
}
/* Hide unnecessary elements */
.json-view-editor .card-title {
display: none !important;
}
/* Modern JSON Editor styling */
.json-editor-modern {
border: none !important;
box-shadow: none !important;
}
.jsoneditor-menu {
display: none !important;
}
/* Modern card styling */
.modern-card {
border: none !important;
box-shadow: none !important;
background: transparent !important;
margin-bottom: 1rem !important;
padding: 0 !important;
width: 100% !important;
}
.card-body {
padding: 0 !important;
width: 100% !important;
}
/* Table styling */
.full-width-table {
width: 100% !important;
margin-bottom: 1rem !important;
border-collapse: separate !important;
border-spacing: 0 !important;
}
.table-responsive {
border: 1px solid rgba(1, 53, 59, 0.1);
border-radius: 0.5rem;
overflow: hidden;
margin-bottom: 1.25rem;
background-color: white;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.03);
}
.equal-width-cell {
width: 45% !important;
}
.table-controls-cell {
width: 10% !important;
text-align: right !important;
}
.modern-table-header {
background-color: rgba(1, 53, 59, 0.03) !important;
color: rgb(1, 53, 59) !important;
font-weight: 600 !important;
text-transform: uppercase !important;
font-size: 0.6875rem !important;
letter-spacing: 0.05em !important;
padding: 0.625rem 0.875rem !important;
border-bottom: 1px solid rgba(1, 53, 59, 0.08) !important;
}
.modern-table-row {
border-bottom: 1px solid rgba(1, 53, 59, 0.06) !important;
}
.modern-table-row:last-child {
border-bottom: none !important;
}
.modern-table-row:hover {
background-color: rgba(37, 208, 118, 0.03) !important;
}
.modern-table-row td {
padding: 0.625rem 0.875rem !important;
vertical-align: middle !important;
font-size: 0.875rem !important;
}
/* Modern buttons - Using Unfold color scheme */
.json-editor-btn-modern {
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
gap: 0.375rem !important;
background-color: rgb(37, 208, 118) !important; /* Unfold primary-500 */
color: white !important;
border: none !important;
border-radius: 0.375rem !important;
padding: 0.5rem 0.875rem !important; /* Smaller padding */
font-weight: 500 !important;
cursor: pointer !important;
transition: all 0.2s ease !important;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08) !important;
margin: 0 0.25rem !important;
font-size: 0.8125rem !important; /* Smaller font */
line-height: 1.4 !important;
text-transform: none !important;
letter-spacing: 0.01em !important;
}
.json-editor-btn-modern:hover {
background-color: rgb(29, 166, 94) !important; /* Unfold primary-600 */
transform: translateY(-1px) !important;
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.08) !important;
}
.json-editor-btn-modern:active {
transform: translateY(0) !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08) !important;
}
/* Button sections styling */
.json-editor-btngroup, .json-editor-btn-bar {
display: flex !important;
flex-wrap: wrap !important;
gap: 0.5rem !important;
margin-top: 1rem !important;
margin-bottom: 0 !important;
padding: 0.75rem !important;
background-color: rgba(1, 53, 59, 0.05) !important;
border-radius: 0.5rem !important;
visibility: visible !important;
width: 100% !important;
justify-content: flex-end !important;
}
/* Button icons */
.json-editor-btn-icon {
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
/* Add button styling */
.json-editor-btn-add {
background-color: rgb(37, 208, 118) !important; /* Unfold primary-500 */
padding: 0.625rem 1rem !important; /* Smaller padding */
font-size: 0.875rem !important; /* Smaller font */
font-weight: 600 !important;
letter-spacing: 0.01em !important;
border-radius: 0.375rem !important;
width: auto !important; /* Not full width */
margin-bottom: 0.75rem !important;
}
.json-editor-btn-add:hover {
background-color: rgb(29, 166, 94) !important; /* Unfold primary-600 */
}
/* Modern HTML5-like error styling */
.je-error-container {
display: none !important; /* Hide the default error container */
}
/* Style for invalid inputs */
.je-error + input,
.je-error + select,
.je-error + textarea,
.has-error .modern-form-control {
border-color: rgb(239, 68, 68) !important;
padding-right: 2.5rem !important;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ef4444' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
background-size: 1.25rem;
}
/* Custom tooltip for errors */
.je-error {
position: relative !important;
display: inline-block !important;
color: transparent !important;
font-size: 0 !important;
width: 0 !important;
height: 0 !important;
overflow: visible !important;
}
.je-error::after {
content: attr(data-content) !important;
position: absolute !important;
bottom: 125% !important;
right: 0 !important;
visibility: hidden !important;
width: 200px !important;
background-color: rgb(239, 68, 68) !important;
color: white !important;
text-align: center !important;
border-radius: 0.375rem !important;
padding: 0.5rem 0.75rem !important;
font-size: 0.8125rem !important;
font-weight: 500 !important;
opacity: 0 !important;
transition: opacity 0.3s !important;
z-index: 100 !important;
pointer-events: none !important;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
}
/* Show tooltip on hover over the input */
.je-error + input:hover + .je-error::after,
.je-error + select:hover + .je-error::after,
.je-error + textarea:hover + .je-error::after,
.has-error .modern-form-control:hover + .je-error::after {
visibility: visible !important;
opacity: 1 !important;
}
/* Arrow for tooltip */
.je-error::before {
content: "" !important;
position: absolute !important;
bottom: 125% !important;
right: 10px !important;
visibility: hidden !important;
border-width: 5px !important;
border-style: solid !important;
border-color: rgb(239, 68, 68) transparent transparent transparent !important;
opacity: 0 !important;
transition: opacity 0.3s !important;
}
.je-error + input:hover + .je-error::before,
.je-error + select:hover + .je-error::before,
.je-error + textarea:hover + .je-error::before,
.has-error .modern-form-control:hover + .je-error::before {
visibility: visible !important;
opacity: 1 !important;
}
/* Delete button styling */
.json-editor-btn-delete {
background-color: rgb(1, 53, 59) !important; /* Unfold secondary-500 */
}
.json-editor-btn-delete:hover {
background-color: rgb(1, 43, 48) !important; /* Unfold secondary-600 */
}
/* Hide Delete Last buttons */
.json-editor-btntype-deletelast {
display: none !important;
}
/* Move up/down buttons */
.json-editor-btntype-moveup, .json-editor-btntype-movedown {
background-color: rgba(1, 53, 59, 0.8) !important;
}
.json-editor-btntype-moveup:hover, .json-editor-btntype-movedown:hover {
background-color: rgb(1, 53, 59) !important;
}
/* Button sections styling */
.json-editor-btngroup, .json-editor-btn-bar {
display: flex !important;
flex-wrap: wrap !important;
gap: 0.375rem !important;
margin-top: 1rem !important;
padding: 0.625rem !important;
background-color: rgba(1, 53, 59, 0.03) !important; /* More subtle background */
border: 1px solid rgba(1, 53, 59, 0.08) !important; /* Subtle border */
border-radius: 0.5rem !important;
visibility: visible !important;
width: 100% !important;
justify-content: flex-end !important;
order: 999 !important; /* Ensure it appears at the bottom */
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02) !important; /* Subtle shadow */
}
/* Modern button group styling */
.modern-btn-group {
display: inline-flex !important;
gap: 0.25rem !important;
margin: 0.125rem !important;
}
/* Form controls - Unfold theme */
.modern-form-control {
width: 100% !important;
border: 1px solid rgba(1, 53, 59, 0.15) !important;
border-radius: 0.375rem !important;
padding: 0.625rem 0.875rem !important;
font-size: 0.875rem !important;
line-height: 1.5 !important;
color: rgb(1, 53, 59) !important;
background-color: #fff !important;
transition: all 0.2s ease !important;
appearance: none !important;
margin-bottom: 0.875rem !important;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02) !important;
}
.modern-form-control:focus {
border-color: rgb(37, 208, 118) !important;
box-shadow: 0 0 0 3px rgba(37, 208, 118, 0.15) !important;
outline: none !important;
}
.modern-form-control::placeholder {
color: rgba(1, 53, 59, 0.4) !important;
}
/* Form labels */
label, .je-label {
font-size: 0.875rem !important;
font-weight: 500 !important;
color: rgb(1, 53, 59) !important;
margin-bottom: 0.375rem !important;
display: block !important;
}
/* Button group styling */
.modern-btn-group {
display: flex !important;
flex-wrap: nowrap !important;
align-items: center !important;
justify-content: flex-end !important;
gap: 0.5rem !important;
}
/* Error message styling */
.invalid-feedback {
color: #ef4444 !important;
font-size: 0.875rem !important;
margin-top: 0.25rem !important;
margin-bottom: 0.5rem !important;
}
/* Labels */
label, .je-label {
display: block !important;
margin-bottom: 0.5rem !important;
font-weight: 500 !important;
color: rgb(1, 53, 59) !important;
font-size: 0.9375rem !important;
}
/* Form groups */
.form-group, .je-object__container {
margin-bottom: 1.5rem !important;
width: 100% !important;
}
/* Make the JSON editor more responsive */
@media (max-width: 767px) {
.modern-form-control {
font-size: 0.875rem !important;
padding: 0.5rem 0.625rem !important;
}
.json-editor-btn-modern {
padding: 0.375rem 0.75rem !important;
font-size: 0.875rem !important;
}
.modern-table-header {
font-size: 0.75rem !important;
padding: 0.625rem 0.75rem !important;
}
.modern-table-row td {
padding: 0.625rem 0.75rem !important;
}
}
/* Dark mode support - Using Unfold color scheme */
@media (prefers-color-scheme: dark) {
.json-editor-container {
background-color: rgb(1, 43, 48) !important; /* Unfold secondary-600 */
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.json-view-editor {
background-color: rgb(1, 43, 48) !important; /* Unfold secondary-600 */
}
.table-responsive {
border-color: rgb(1, 36, 40) !important; /* Unfold secondary-700 */
background-color: rgb(1, 43, 48) !important; /* Unfold secondary-600 */
}
.modern-table-header {
background-color: rgb(1, 30, 34) !important; /* Unfold secondary-800 */
color: white !important;
border-bottom-color: rgb(0, 26, 29) !important; /* Unfold secondary-900 */
}
.modern-table-row {
border-bottom-color: rgb(1, 30, 34) !important; /* Unfold secondary-800 */
}
.modern-table-row:hover {
background-color: rgb(1, 36, 40) !important; /* Unfold secondary-700 */
}
.modern-table-row td {
color: white !important;
}
.modern-form-control {
background-color: rgb(1, 36, 40) !important; /* Unfold secondary-700 */
border-color: rgb(1, 30, 34) !important; /* Unfold secondary-800 */
color: white !important;
}
label, .je-label {
color: white !important;
}
/* Button sections in dark mode */
.json-editor-btngroup, .json-editor-btn-bar {
background-color: rgba(1, 30, 34, 0.25) !important; /* More subtle dark background */
border: 1px solid rgba(37, 208, 118, 0.1) !important; /* Subtle primary color border */
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1) !important;
}
.modern-form-control:focus {
border-color: rgb(37, 208, 118) !important; /* Unfold primary-500 */
box-shadow: 0 0 0 3px rgba(37, 208, 118, 0.2) !important;
}
/* Error styling in dark mode */
.je-error + input,
.je-error + select,
.je-error + textarea,
.has-error .modern-form-control {
border-color: rgb(252, 165, 165) !important; /* Lighter red for dark mode */
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23fca5a5' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'%3E%3C/circle%3E%3Cline x1='12' y1='8' x2='12' y2='12'%3E%3C/line%3E%3Cline x1='12' y1='16' x2='12.01' y2='16'%3E%3C/line%3E%3C/svg%3E");
}
.je-error::after {
background-color: rgb(185, 28, 28) !important; /* Darker red background for tooltip */
color: white !important;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25) !important;
}
.je-error::before {
border-color: rgb(185, 28, 28) transparent transparent transparent !important;
}
/* Card styling in dark mode */
.modern-card {
background-color: rgb(1, 43, 48) !important; /* Unfold secondary-600 */
}
/* Error messages in dark mode */
.invalid-feedback {
color: #f87171 !important;
}
}
</style>

33
apps/account/templates/account/user_list_section.html

@ -0,0 +1,33 @@
{% load unfold i18n %}
<div class="grid gap-4 mb-4 md:grid-cols-2 lg:grid-cols-4 ">
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Total Actice Users" %}
{% endcomponent %}
{% component "unfold/components/title.html" with component_class="AllUserComponent" %}{% endcomponent %}
{% endcomponent %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Total Guest Users" %}
{% endcomponent %}
{% component "unfold/components/title.html" with component_class="GuestUserComponent" %}{% endcomponent %}
{% endcomponent %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Total Students" %}
{% endcomponent %}
{% component "unfold/components/title.html" with component_class="StudentUserComponent" %}{% endcomponent %}
{% endcomponent %}
{% component "unfold/components/card.html" %}
{% component "unfold/components/text.html" %}
{% trans "Total Professors" %}
{% endcomponent %}
{% component "unfold/components/title.html" with component_class="ProfessorUserComponent" %}{% endcomponent %}
{% endcomponent %}
</div>

38
apps/account/urls.py

@ -0,0 +1,38 @@
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('guest/', views.UserGuestView.as_view(), name='user-guest'),
# 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'),
path('notif/', views.NotificationListView.as_view(), name='user-notif'),
path('notif/read', views.NotificationReadAllView.as_view(), name='user-notif-read-all'),
# # 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'),
]

4
apps/account/views/__init__.py

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

105
apps/account/views/notification.py

@ -0,0 +1,105 @@
from rest_framework import generics, status
from rest_framework.response import Response
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.permissions import IsAuthenticated
from apps.account.serializers import NotificationSerializer, NotificationSendSerializer
from apps.account.models import Notification
# from apps.account.fcm_notification import send_notification
class NotificationListView(generics.ListAPIView):
queryset = Notification.objects.all()
serializer_class = NotificationSerializer
permission_classes = [IsAuthenticated,]
@swagger_auto_schema(
operation_description="Retrieve a list of notifications for the authenticated user or merchant account.",
tags=['Notifications'],
manual_parameters=[
openapi.Parameter(
'service',
openapi.IN_QUERY,
description="Filter notifications by service (imam-javad or doboodi)",
type=openapi.TYPE_STRING,
enum=['imam-javad', 'doboodi'],
required=False
)
]
)
def get(self, request, *args, **kwargs):
"""
This API allows you to retrieve a list of notifications based on the authenticated user's type.
If the user is a regular user, their notifications will be fetched from the `Notification` model.
If the user is a merchant, their notifications will be fetched from the `MerchantAccountNotification` model.
- **Method**: GET
- **URL**: /api/notifications/
- **Query Parameters**:
- `service`: Optional. Filter notifications by service ('imam-javad' or 'doboodi')
- **Response**: Includes details of notifications such as title, message, is read status, service, creation date, and update date.
- **Headers**: `Authorization: Bearer <token>` for authentication.
"""
return super().get(request, *args, **kwargs)
def get_queryset(self):
user = self.request.user
queryset = Notification.objects.filter(user=user)
# Filter by service if provided in query params
service = self.request.query_params.get('service', None)
if service:
queryset = queryset.filter(service=service)
return queryset.order_by('-created_at')
class NotificationReadAllView(generics.GenericAPIView):
permission_classes = [IsAuthenticated,]
queryset = Notification.objects.all()
@swagger_auto_schema(
operation_description="Mark all notifications as read for the authenticated user or merchant account.",
tags=['Notifications'],
manual_parameters=[
openapi.Parameter(
'service',
openapi.IN_QUERY,
description="Filter notifications to mark as read by service (imam-javad or doboodi)",
type=openapi.TYPE_STRING,
enum=['imam-javad', 'doboodi'],
required=False
)
],
responses={
200: "All notifications marked as read",
}
)
def get(self, request, *args, **kwargs):
user = request.user
service = request.query_params.get('service', None)
# Get base queryset for user's notifications
notifications = Notification.objects.filter(user=user)
# Apply service filtering based on query parameter
if service == 'doboodi':
# If service is doboodi, only mark doboodi notifications as read
notifications = notifications.filter(service=Notification.ServiceChoices.DOBOODI)
status_message = 'all doboodi notifications marked as read'
else:
# Default: mark all imam-javad notifications as read (exclude doboodi)
notifications = notifications.exclude(service=Notification.ServiceChoices.DOBOODI)
status_message = 'all imam-javad notifications marked as read'
# Update the filtered notifications
notifications.update(is_read=True)
return Response({'status': status_message}, status=status.HTTP_200_OK)

372
apps/account/views/user.py

@ -0,0 +1,372 @@
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 django.db.models import Q
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 rest_framework.exceptions import ValidationError
from utils.exceptions import InvaliedCodeVrify, ExpiredCodeException, ServiceUnavailableException
from apps.account.models import User
from apps.account.serializers import UserRegisterSerializer, UserProfileSerializer, UserVerifySerializer, UserLoginSerializer, UserRecoverPasswordSerializer, UserResetPasswordSerializer, UserGuestSerializer
from utils.redis import RedisManager
from utils.exceptions import AppAPIException
from utils import send_email, is_valid_email
from config.settings import base as settings
from apps.account.permissions import IsActiveUser
from apps.account.doc import *
logger = logging.getLogger(__name__)
class UserGuestView(CreateAPIView):
permission_classes = [AllowAny]
serializer_class = UserGuestSerializer
@swagger_auto_schema(
operation_description="Create a guest user account with device information",
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
"device_id": openapi.Schema(type=openapi.TYPE_STRING, default="c9f0c1f4f5cee3d7"),
"fcm": openapi.Schema(type=openapi.TYPE_STRING, default=""),
"device_os": openapi.Schema(type=openapi.TYPE_STRING, default="android"),
"lat": openapi.Schema(type=openapi.TYPE_STRING, default="56"),
"lon": openapi.Schema(type=openapi.TYPE_STRING, default="44"),
"timezone": openapi.Schema(type=openapi.TYPE_STRING, default="1.0"),
},
required=["device_id"],
),
)
def post(self, request, *args, **kwargs):
logger.info(f'GuestAuthView--> {request.data}')
return super().post(request, *args, **kwargs)
@staticmethod
def generate_login_token(user):
token, created = Token.objects.update_or_create(user=user)
return token.key
def get_client_ip(self):
request = self.request
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
user = self.perform_create(serializer)
return Response({
'token': self.generate_login_token(user),
}, status=200)
def perform_create(self, serializer):
device_id = serializer.validated_data.get('device_id')
device_os = serializer.validated_data.get('device_os')
fcm = serializer.validated_data.get('fcm')
lat = serializer.validated_data.pop('lat', None)
lon = serializer.validated_data.pop('lon', None)
user_timezone = serializer.validated_data.pop('timezone', None)
serializer_data = dict(serializer.validated_data)
obj = User.objects.select_for_update().filter(Q(device_id=device_id)).first()
if not obj:
obj, created = User.objects.select_for_update().get_or_create(
device_id=device_id,
defaults=serializer_data
)
if created:
logger.info(f'Guest-(created)->: {obj.device_id}')
obj.last_login = timezone.now()
obj.save()
login_history_obj = obj.login_history.create(
lat=lat,
lon=lon,
ip=self.get_client_ip(),
timezone=user_timezone,
)
return obj
class UserRegisterView(CreateAPIView):
permission_classes = [AllowAny]
serializer_class = UserRegisterSerializer
@swagger_auto_schema(
operation_description=doc_register(),
request_body=UserRegisterSerializer,
)
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)
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
@swagger_auto_schema(
operation_description=doc_verify(),
request_body=UserVerifySerializer,
)
def post(self, request, *args, **kwargs):
print(f'-UserVerifyView-> {request.data}')
return super().post(request, *args, **kwargs)
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.data
print(f'--UserVerifyView---1--')
try:
verify_data = RedisManager().get_by_redis(data['email'])
if not verify_data:
raise ValidationError({"code": "Verification data not found or expired."})
# raise ExpiredCodeException("Verification data not found or expired.")
except (ServiceUnavailableException) as e:
return AppAPIException({"message": str(e)}, status_code=e.status_code)
except ExpiredCodeException:
# raise ExpiredCodeException("The verification code has expired.")
raise ValidationError({"code": "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'], device_id=serializer.data['device_id'], **verify_data
)
token, _ = Token.objects.get_or_create(user=user)
return Response(data={
'token': str(token.key),
'user_id': user.id,
'phone_number': str(user.phone_number) if user.phone_number else None,
'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 ValidationError({"code": "code notfound"})
return current_code
def perform_create(self, *args, **kwargs):
email = kwargs.get('email')
device_id = kwargs.get('device_id')
user = User.objects.filter(email=email).first()
if user:
if kwargs['password']:
user.is_active = True
user.deletion_date = None
user.device_id = device_id
user.last_login = timezone.now()
user.save()
else:
user = User.objects.filter(device_id=device_id, email__isnull=True).first()
if not user:
user = User.objects.create(**kwargs)
else:
user.email = email
user.fullname = kwargs['fullname']
user.device_id = device_id
user.last_login = timezone.now()
user.is_active = True
user.save()
return user
class UserLoginView(CreateAPIView):
permission_classes = [AllowAny]
serializer_class = UserLoginSerializer
@swagger_auto_schema(
operation_description=doc_login(),
request_body=UserLoginSerializer,
)
def post(self, request, *args, **kwargs):
return super().post(request, *args, **kwargs)
def get_client_ip(self):
request = self.request
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0]
else:
ip = request.META.get('REMOTE_ADDR')
return ip
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 ValidationError({"email": "Unable to log in with provided credentials."})
user_timezone = serializer.validated_data.pop('timezone', None)
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
login_history_obj = user.login_history.create(
ip=self.get_client_ip(),
timezone=user_timezone,
)
return Response({
"id": user.id,
"fullname": user.fullname,
"email": user.email,
"token": token.key,
"user_type": user.user_type_based_on_groups,
"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(self, request, *args, **kwargs):
logger.info(f'UserProfileView--> {request.data}')
return super().get(request, *args, **kwargs)
def get_object(self):
return self.request.user
class UserUpdateView(UpdateAPIView):
permission_classes = [IsAuthenticated, IsActiveUser]
serializer_class = UserProfileSerializer
def put(self, request, *args, **kwargs):
logger.info(f'UserProfileView--> {request.data}')
return super().put(request, *args, **kwargs)
def get_object(self):
return self.request.user
class UserRecoverPassword(CreateAPIView):
serializer_class = UserRecoverPasswordSerializer
@swagger_auto_schema(
operation_description=doc_recover(),
request_body=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) if user.phone_number else None,
"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]
@swagger_auto_schema(
operation_description=doc_reset(),
request_body=UserResetPasswordSerializer,
)
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":
raise AppAPIException({"message": "Unable to log in with provided credentials."}, status_code=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

75
apps/api/admin.py

@ -0,0 +1,75 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from unfold.admin import ModelAdmin
from unfold.decorators import display
from django.utils.html import format_html
from filer.models.thumbnailoptionmodels import ThumbnailOption
# from filer.admin.thumbnailoptionmodels import ThumbnailOptionAdmin as OriginalThumbnailOptionAdmin
admin.site.unregister(ThumbnailOption)
@admin.register(ThumbnailOption)
class ThumbnailOptionAdmin(ModelAdmin):
list_display = ['name', 'dimensions_display', 'crop', 'upscale', 'preview']
list_filter = ['crop', 'upscale']
search_fields = ['name']
fieldsets = (
(None, {
'fields': ('name', 'width', 'height', 'crop', 'upscale'),
'classes': ('unfold-fieldset',),
}),
)
@display(description=_("Dimensions"))
def dimensions_display(self, obj):
return f"{obj.width} × {obj.height}"
@display(description=_("Preview"))
def preview(self, obj):
# ایجاد یک نمایش بصری از ابعاد تصویر بندانگشتی
width_percent = min(100, obj.width / 10) # محدود کردن عرض به حداکثر 100%
height_px = min(50, obj.height / 5) # محدود کردن ارتفاع به حداکثر 50px
return format_html(
'<div style="width:{}%; height:{}px; background-color:#e0e0e0; border:1px solid #ccc; display:flex; align-items:center; justify-content:center;">'
'<span style="font-size:10px; color:#666;">{} × {}</span>'
'</div>',
width_percent, height_px, obj.width, obj.height
)
# اضافه کردن فیلتر سلسله مراتبی برای نام
def changelist_view(self, request, extra_context=None):
# گرفتن حرف اول از پارامتر URL
first_letter = request.GET.get('first_letter', '')
# ایجاد لیست حروف الفبا
alphabet = [chr(i) for i in range(ord('A'), ord('Z')+1)]
# اضافه کردن به context
if extra_context is None:
extra_context = {}
extra_context['alphabet'] = alphabet
extra_context['selected_letter'] = first_letter
# اعمال فیلتر به queryset اگر حرف انتخاب شده باشد
if first_letter:
original_get_queryset = self.get_queryset
def filtered_queryset(request):
qs = original_get_queryset(request)
if first_letter == '0-9':
return qs.filter(name__regex=r'^[0-9]')
return qs.filter(name__istartswith=first_letter)
self.get_queryset = filtered_queryset
return super().changelist_view(request, extra_context=extra_context)
from utils.admin import project_admin_site
project_admin_site.register(ThumbnailOption, ThumbnailOptionAdmin)

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.

10
apps/api/urls.py

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

42
apps/api/views.py

@ -0,0 +1,42 @@
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()
from utils.countries import countries
# 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})
class CountryView(GenericAPIView):
def get(self, request):
return Response(countries, status=200)

0
apps/certificate/__init__.py

44
apps/certificate/admin.py

@ -0,0 +1,44 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from django.utils.html import format_html
from unfold.admin import ModelAdmin
from unfold.decorators import display
from apps.certificate.models import Certificate
from utils.admin import project_admin_site
@admin.register(Certificate)
class CertificateAdmin(ModelAdmin):
list_display = ['student', 'course', 'certificate_status', 'created_at']
list_filter = ['status', 'created_at']
search_fields = ['id', 'student__username', 'student__email', 'course__title']
readonly_fields = ['created_at', 'updated_at']
autocomplete_fields = ['student',]
fieldsets = (
(None, {
'fields': ('student', 'course', 'status', 'certificate_file')
}),
(_('Timestamps'), {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
}),
)
@display(description=_("Status"), ordering="status")
def certificate_status(self, obj):
status_classes = {
'pending': 'unfold-badge unfold-badge--warning',
'approved': 'unfold-badge unfold-badge--success',
'rejected': 'unfold-badge unfold-badge--danger',
'issued': 'unfold-badge unfold-badge--info',
}
status_class = status_classes.get(obj.status.lower(), 'unfold-badge')
return format_html('<span class="{}">{}</span>', status_class, obj.get_status_display())
def get_queryset(self, request):
queryset = super().get_queryset(request)
return queryset
project_admin_site.register(Certificate, CertificateAdmin)

6
apps/certificate/apps.py

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

31
apps/certificate/migrations/0001_initial.py

@ -0,0 +1,31 @@
# Generated by Django 5.1.8 on 2025-04-03 00:05
import django.db.models.deletion
import filer.fields.file
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('account', '0001_initial'),
('course', '0001_initial'),
('filer', '0017_image__transparent'),
]
operations = [
migrations.CreateModel(
name='Certificate',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('pending', 'pending'), ('approved', 'approved'), ('canceled', 'canceled')], default='pending', max_length=10)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('certificate_file', filer.fields.file.FilerFileField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='filer.file', verbose_name='certificate_file')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_certificates', to='course.course')),
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to='account.studentuser')),
],
),
]

18
apps/certificate/migrations/0002_alter_certificate_certificate_file.py

@ -0,0 +1,18 @@
# Generated by Django 5.1.8 on 2025-04-03 01:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('certificate', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='certificate',
name='certificate_file',
field=models.FileField(blank=True, null=True, upload_to='certificates/', verbose_name='certificate_file'),
),
]

0
apps/certificate/migrations/__init__.py

28
apps/certificate/models.py

@ -0,0 +1,28 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from filer.fields.file import FilerFileField
from apps.course.models import Course
from apps.account.models import StudentUser
class Certificate(models.Model):
STATUS_CHOICES = [
('pending', _('pending')),
('approved', _('approved')),
('canceled', _('canceled')),
]
student = models.ForeignKey(StudentUser, on_delete=models.CASCADE, related_name='certificates')
course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='course_certificates')
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending')
certificate_file = models.FileField(upload_to='certificates/', null=True, blank=True, verbose_name=_('certificate_file'))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"Certificate {self.student.fullname} - {self.course.title}"

50
apps/certificate/serializers.py

@ -0,0 +1,50 @@
from rest_framework import serializers
from apps.certificate.models import Certificate
from apps.course.serializers import CourseDetailSerializer
from django.conf import settings
class CertificateSerializer(serializers.ModelSerializer):
course = serializers.SerializerMethodField()
certificate_file = serializers.SerializerMethodField()
class Meta:
model = Certificate
fields = ['id', 'student', 'course', 'status', 'created_at', 'updated_at', 'certificate_file']
read_only_fields = ['id', 'student', 'status', 'created_at', 'updated_at',]
def get_course(self, obj):
return CourseDetailSerializer(obj.course, context=self.context).data
def get_certificate_file(self, obj):
if obj.certificate_file:
request = self.context.get('request')
if request is not None:
return request.build_absolute_uri(obj.certificate_file.url)
return obj.certificate_file.url
return None
class CertificateRequestSerializer(serializers.ModelSerializer):
class Meta:
model = Certificate
fields = ['id', 'course']
read_only_fields = ['id']
def create(self, validated_data):
user = self.context['request'].user
course = validated_data['course']
if Certificate.objects.filter(student=user, course=course, status__in=['pending', 'approved']).exists():
raise serializers.ValidationError({
"course": "A certificate request for this course is already pending or approved."
})
return Certificate.objects.create(student=user, course=course)

3
apps/certificate/tests.py

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

11
apps/certificate/urls.py

@ -0,0 +1,11 @@
from django.urls import path
from .views import CertificateRequestView, UserCertificatesListView
urlpatterns = [
path('request/', CertificateRequestView.as_view(), name='certificate-request'),
path('my-certificates/', UserCertificatesListView.as_view(), name='user-certificates'),
]

24
apps/certificate/views.py

@ -0,0 +1,24 @@
from rest_framework import generics, permissions
from apps.certificate.models import Certificate
from apps.certificate.serializers import CertificateRequestSerializer, CertificateSerializer
class CertificateRequestView(generics.CreateAPIView):
queryset = Certificate.objects.all()
serializer_class = CertificateRequestSerializer
permission_classes = [permissions.IsAuthenticated]
def perform_create(self, serializer):
serializer.save(student=self.request.user)
class UserCertificatesListView(generics.ListAPIView):
serializer_class = CertificateSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return Certificate.objects.filter(student=self.request.user).order_by('-created_at')

0
apps/chat/__init__.py

59
apps/chat/admin.py

@ -0,0 +1,59 @@
from django.contrib import admin
from apps.chat.models import RoomMessage, ChatMessage, MessageReadStatus
@admin.register(MessageReadStatus)
class MessageReadStatusAdmin(admin.ModelAdmin):
list_display = (
'user', 'message', 'is_read', 'read_at',
)
@admin.register(RoomMessage)
class RoomMessageAdmin(admin.ModelAdmin):
list_display = (
'name', 'room_type', 'course', 'initiator', 'recipient', 'created_at', 'unread_messages_count'
)
list_filter = ('room_type', 'created_at', 'updated_at', 'course')
search_fields = ('name', 'description', 'course__title', 'initiator__username', 'recipient__username')
ordering = ('-created_at',)
readonly_fields = ('created_at', 'updated_at')
fieldsets = (
(None, {
'fields': ('name', 'description', 'room_type')
}),
('Relations', {
'fields': ('course', 'initiator', 'recipient')
}),
('Timestamps', {
'fields': ('created_at', 'updated_at')
}),
)
@admin.register(ChatMessage)
class ChatMessageAdmin(admin.ModelAdmin):
list_display = (
'room', 'sender', 'content_type', 'content_size', 'sent_at', 'is_deleted'
)
list_filter = ('content_type', 'is_deleted', 'sent_at', 'updated_at')
search_fields = ('room__name', 'sender__username', 'content')
ordering = ('-sent_at',)
readonly_fields = ('sent_at', 'updated_at')
fieldsets = (
(None, {
'fields': ('room', 'sender', 'content', 'content_type')
}),
('Additional Info', {
'fields': ('content_size',)
}),
('Status', {
'fields': ('is_deleted', 'deleted_at')
}),
('Timestamps', {
'fields': ('sent_at', 'updated_at')
}),
)

6
apps/chat/apps.py

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

62
apps/chat/migrations/0001_initial.py

@ -0,0 +1,62 @@
# Generated by Django 5.1.8 on 2025-04-03 00:05
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('course', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='RoomMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Room Name')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('room_type', models.CharField(choices=[('group', 'Group'), ('private', 'Private')], default='group', max_length=10, verbose_name='Room Type')),
('unread_messages_count', models.IntegerField(default=0)),
('course', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='room_messages', to='course.course', verbose_name='Course')),
('initiator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='initiated_rooms', to=settings.AUTH_USER_MODEL, verbose_name='Initiator')),
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='messages_received', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')),
],
),
migrations.CreateModel(
name='ChatMessage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('content', models.TextField(verbose_name='Message Content')),
('content_type', models.CharField(choices=[('text', 'Text'), ('file', 'File'), ('audio', 'Audio'), ('image', 'Image')], default='text', max_length=10, verbose_name='Chat Type')),
('content_size', models.PositiveIntegerField(blank=True, null=True, verbose_name='Content Size (bytes)')),
('is_read', models.BooleanField(default=False, verbose_name='Is Read')),
('sent_at', models.DateTimeField(auto_now_add=True, verbose_name='Sent At')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')),
('is_deleted', models.BooleanField(default=False, verbose_name='Is deleted')),
('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages_sent', to=settings.AUTH_USER_MODEL, verbose_name='Sender')),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.roommessage', verbose_name='Room')),
],
),
migrations.CreateModel(
name='MessageReadStatus',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_read', models.BooleanField(default=False, verbose_name='Is Read')),
('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')),
('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_statuses', to='chat.chatmessage', verbose_name='Message')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_statuses', to=settings.AUTH_USER_MODEL, verbose_name='User')),
],
options={
'unique_together': {('user', 'message')},
},
),
]

0
apps/chat/migrations/__init__.py

121
apps/chat/models.py

@ -0,0 +1,121 @@
from django.db import models
from apps.account.models import User, User
from apps.course.models import Course
class RoomMessage(models.Model):
class RoomTypeChoices(models.TextChoices):
GROUP = 'group', 'Group'
PRIVATE = 'private', 'Private'
name = models.CharField(
max_length=255,
verbose_name="Room Name"
)
description = models.TextField(
verbose_name="Description",
blank=True,
null=True
)
course = models.ForeignKey(Course,on_delete=models.CASCADE, null=True, blank=True ,related_name="room_messages", verbose_name="Course")
initiator = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="initiated_rooms",
verbose_name="Initiator"
)
recipient = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="messages_received",
verbose_name="Recipient",
null=True,
blank=True
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created At")
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="Updated At"
)
room_type = models.CharField(
max_length=10,
choices=RoomTypeChoices.choices,
default=RoomTypeChoices.GROUP,
verbose_name="Room Type"
)
unread_messages_count = models.IntegerField(default=0)
def __str__(self):
if self.room_type == self.RoomTypeChoices.GROUP:
return f"Group Room: {self.course.title if self.course else 'N/A'}"
return f"Private Room with {self.recipient}"
class ChatMessage(models.Model):
class ChatTypeChoices(models.TextChoices):
TEXT = 'text', 'Text'
FILE = 'file', 'File'
AUDIO = 'audio', 'Audio'
IMAGE = 'image', 'Image'
room = models.ForeignKey(
RoomMessage,
on_delete=models.CASCADE,
related_name="messages",
verbose_name="Room",
)
sender = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="messages_sent",
verbose_name="Sender"
)
content = models.TextField(verbose_name="Message Content")
content_type = models.CharField(
max_length=10,
choices=ChatTypeChoices.choices,
default=ChatTypeChoices.TEXT,
verbose_name="Chat Type"
)
content_size = models.PositiveIntegerField(
verbose_name="Content Size (bytes)",
blank=True,
null=True
)
is_read = models.BooleanField(default=False, verbose_name="Is Read")
sent_at = models.DateTimeField(auto_now_add=True, verbose_name="Sent At")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Updated At")
deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Deleted At")
is_deleted = models.BooleanField(default=False, verbose_name="Is deleted")
def __str__(self):
return f"Message from {self.sender} in {self.room}"
class MessageReadStatus(models.Model):
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="read_statuses",
verbose_name="User"
)
message = models.ForeignKey(
ChatMessage,
on_delete=models.CASCADE,
related_name="read_statuses",
verbose_name="Message"
)
is_read = models.BooleanField(default=False, verbose_name="Is Read")
read_at = models.DateTimeField(null=True, blank=True, verbose_name="Read At")
class Meta:
unique_together = ("user", "message") # جلوگیری از ثبت تکراری
def __str__(self):
return f"User {self.user.fullname} read Message {self.message.id}: {self.is_read}"

3
apps/chat/tests.py

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

3
apps/chat/views.py

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

0
apps/course/__init__.py

3
apps/course/admin/__init__.py

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

480
apps/course/admin/course.py

@ -0,0 +1,480 @@
import os
import hashlib
from django.contrib import admin
from django.contrib import messages
from django import forms
from django.utils.translation import gettext_lazy as _
from django.db import models
from django.utils.html import format_html
from django.shortcuts import redirect
from django.urls import reverse_lazy
from unfold.admin import ModelAdmin, StackedInline, TabularInline
from unfold.decorators import action, display
from unfold.contrib.forms.widgets import WysiwygWidget
from unfold.sections import TableSection
from unfold.contrib.filters.admin import (
ChoicesDropdownFilter,
MultipleRelatedDropdownFilter,
RangeDateFilter,
RangeNumericFilter,
TextFilter,
)
from unfold.widgets import (
UnfoldAdminColorInputWidget,
UnfoldAdminRadioSelectWidget,
UnfoldAdminSelectWidget,
UnfoldAdminSplitDateTimeWidget,
UnfoldAdminTextInputWidget,
)
from unfold.contrib.forms.widgets import ArrayWidget
from django.contrib.postgres.fields import ArrayField
from utils.admin import project_admin_site
from utils.json_editor_field import JsonEditorWidget
from apps.course.models import Course, Glossary, Attachment, CourseCategory, Participant
from apps.course.models.lesson import Lesson
from apps.account.models import StudentUser
from utils.schema import get_weekly_timing_schema, get_course_feature_schema
class CourseTableSection(TableSection):
verbose_name = _("Course Categories")
related_name = "courses"
height = 380
fields = [
"title",
"status",
"edit_link"
]
def edit_link(self, instance):
from django.utils.html import format_html
return format_html(
'<a href="/admin/course/course/{}/change/" class="leading-none">'
'<span class="material-symbols-outlined leading-none text-base-500">visibility</span>'
'</a>',
instance.id
)
edit_link.short_description = _("Edit")
class CourseCategoryAdmin(ModelAdmin):
list_display = ('name', 'slug', 'course_count')
search_fields = ('name',)
# exclude = ('slug', )
list_sections = [CourseTableSection]
fieldsets = (
(None, {
'fields': ('name', 'slug')
}),
)
@display(description=_("Courses"))
def course_count(self, obj):
count = obj.courses.all().count()
return format_html(
'<span class="badge badge-primary">{}</span>',
count
)
class CourseForm(forms.ModelForm):
class Meta:
model = Course
fields = '__all__'
exclude = ('slug',)
widgets = {
'timing': JsonEditorWidget(attrs={
'schema': get_weekly_timing_schema(),
'title': _('Course Weekly Schedule'),
}),
'features': JsonEditorWidget(attrs={
'schema': get_course_feature_schema(),
'title': _('Course Features'),
}),
}
help_texts = {
'status': 'If set to inactive, the course will not be displayed.',
}
class AttachmentInline(TabularInline):
model = Attachment
extra = 0
fields = ('title', 'file', 'file_size')
tab = True
def save_model(self, request, obj, form, change):
if obj.file:
obj.file_size = obj.file.size
super().save_model(request, obj, form, change)
class GlossaryInline(StackedInline):
model = Glossary
fields = ('title', 'description')
extra = 0
tab = True
show_change_link = True
class LessonInline(StackedInline):
model = Lesson
fields = ('title', 'is_active', 'duration', 'content_type', 'content_file', 'video_link', 'priority',)
extra = 0
tab = True
show_change_link = True
ordering_field = "priority"
conditional_fields = {
'content_file': "content_type == 'video_file'",
'video_link': "content_type == 'youtube_link'",
}
class ParticipantAdmin(ModelAdmin):
list_display = ('student_name', 'course_title', 'joined_date',)
list_filter = (
('course', MultipleRelatedDropdownFilter),
)
search_fields = ('student__email', 'student__fullname', 'course__title')
readonly_fields = ('joined_date',)
autocomplete_fields = ('student', 'course')
fieldsets = (
(None, {
'fields': ('student', 'course',)
}),
(_('Enrollment Details'), {
'fields': ('joined_date', 'last_activity', 'progress')
}),
)
@display(description=_("Student"), header=True)
def student_name(self, instance: StudentUser):
from django.templatetags.static import static
# Get avatar image path - use user's avatar if available, otherwise use default
avatar_path = instance.student.avatar.url if instance.student.avatar else static("images/reading(1).png")
return [
instance.student.fullname,
None,
None,
{
"path": avatar_path,
"height": 30,
"width": 36,
"borderless": True,
# "squared": True,
},
]
@admin.display(description=_("Course"))
def course_title(self, obj):
if obj.course:
return obj.course.title
return "-"
class ParticipantInline(TabularInline):
model = Participant
fields = ('student', 'joined_date', )
readonly_fields = ('joined_date', 'student')
extra = 0
tab = True
verbose_name = _("Participant")
verbose_name_plural = _("Participants")
show_change_link = True
autocomplete_fields = ('student',)
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.order_by('-joined_date')
def has_add_permission(self, request, obj):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
from django.urls import reverse
from django import forms
from django.shortcuts import render, redirect
from django.contrib import messages
from unfold.widgets import UnfoldAdminSelectWidget
class AddStudentForm(forms.Form):
student = forms.ModelChoiceField(
queryset=StudentUser.objects.filter(is_active=True),
label=_("Select Student"),
widget=UnfoldAdminSelectWidget,
required=True
)
class CourseAdmin(ModelAdmin):
form = CourseForm
inlines = [LessonInline, AttachmentInline, GlossaryInline, ParticipantInline]
list_display = ('display_header', 'category', 'display_professor', 'status', 'display_price', 'is_online')
list_filter = [
('status', ChoicesDropdownFilter),
('level', ChoicesDropdownFilter),
'is_online',
'is_free',
('category', MultipleRelatedDropdownFilter),
('price', RangeNumericFilter),
]
save_as = True
warn_unsaved_form = True
# compressed_fields = True
search_fields = ('id','title', 'description')
exclude = ('slug', )
readonly_fields = ('final_price',)
autocomplete_fields = ('category', 'professor',)
list_filter_submit = True
change_form_show_cancel_button = True
radio_fields = {
"video_type": admin.HORIZONTAL,
"status": admin.HORIZONTAL,
"level": admin.HORIZONTAL,
}
show_facets = admin.ShowFacets.ALLOW
formfield_overrides = {
models.TextField: {
"widget": WysiwygWidget,
},
}
conditional_fields = {
'price': "is_free == false",
'discount_percentage': "is_free == false",
'final_price': "is_free == false",
'online_link': "is_online",
'video_file': "video_type == 'video_file'",
'video_link': "video_type == 'youtube_link'",
}
fieldsets = (
(None, {
'fields': ('title', 'category', 'professor', 'thumbnail')
}),
(_('Status'), {
'fields': ('status', 'is_online', 'online_link'),
}),
(_('Course Details'), {
'fields': ('description', 'short_description', 'level', 'duration', 'lessons_count',),
'classes': ['tab'],
}),
(_('Media'), {
'fields': ('video_type', 'video_file', 'video_link'),
}),
(_('Pricing'), {
'fields': ('is_free', 'price', 'discount_percentage', 'final_price'),
}),
(_('Timing & Features'), {
'fields': ('timing', 'features'),
'classes': ['tab'],
}),
)
@display(description=_("Course"), header=True)
def display_header(self, instance):
from django.templatetags.static import static
thumbnail_path = instance.thumbnail.url if instance.thumbnail else None
return [
instance.title,
instance.short_description or _("No description"),
None,
{
"path": thumbnail_path,
"height": 40,
"width": 60,
"squared": True,
"borderless": True,
},
]
@display(description=_("Professor"))
def display_professor(self, instance):
return instance.professor.fullname
@display(description=_("Price"))
def display_price(self, instance):
if instance.is_free:
return format_html('<span class="text-green-600 font-medium">{}</span>', _("Free"))
if instance.discount_percentage > 0:
return format_html(
'<span class="line-through text-gray-400 mr-2">${}</span>'
'<span class="text-green-600 font-medium">${}</span>',
instance.price,
instance.final_price
)
return format_html('<span>${}</span>', instance.final_price)
actions_row = [
"view_course_lessons",
"add_student_to_course"
]
actions_detail = ['add_student_to_course',]
@action(
description=_("View Lessons"),
icon="menu_book",
url_path="actions-row-custom-url",
permissions=[
"is_course_professor",
],
)
def view_course_lessons(self, request, object_id):
"""Navigate to the list of lessons for this course."""
course = self.get_object(request, object_id)
if not course:
messages.error(request, _("Course not found"))
return redirect(request.META.get("HTTP_REFERER") or reverse_lazy("admin:course_course_changelist"))
# Redirect to the lesson list filtered by this course
from django.urls import reverse
url = f"{reverse('admin:course_lesson_changelist')}?course__id__exact={course.id}"
return redirect(url)
def has_is_course_professor_permission(self, request, object_id=None):
try:
if request.user.is_staff:
return True
course = self.get_object(request, object_id)
# Check if the current user is the professor of this course
return course and hasattr(request.user, 'professor') and course.professor_id == request.user.id
except Exception as e:
print(e)
return False
@action(
description=_("Add Student to Course"),
icon="person_add",
permissions=[
"is_course_professor",
],
)
def add_student_to_course(self, request, object_id):
"""Add a student to this course as a participant."""
course = self.get_object(request, object_id)
if not course:
messages.error(request, _("Course not found"))
return redirect(reverse("admin:course_course_changelist"))
if request.method == 'POST':
form = AddStudentForm(request.POST)
if form.is_valid():
student = form.cleaned_data['student']
# Check if the student is already a participant
if Participant.objects.filter(student=student, course=course).exists():
messages.warning(request, _(f"Student {student.fullname} is already enrolled in this course"))
else:
# Create a new participant
Participant.objects.create(
student=student,
course=course,
)
messages.success(
request,
_(f"Student {student.fullname} has been successfully added to {course.title}")
)
return redirect(reverse("admin:course_course_changelist"))
else:
form = AddStudentForm()
return render(
request,
"course/add_student_form.html",
{
"form": form,
"object": object,
"title": _("Change detail action for {}").format(object),
**self.admin_site.each_context(request),
},
)
class GlossaryAdmin(ModelAdmin):
list_display = ('title', 'course', 'description')
list_filter = ('course',)
search_fields = ('title', 'description', 'course__title')
ordering = ('-id',)
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
class AttachmentAdmin(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)
# Register with the project admin site
project_admin_site.register(Course, CourseAdmin)
project_admin_site.register(CourseCategory, CourseCategoryAdmin)
project_admin_site.register(Glossary, GlossaryAdmin)
project_admin_site.register(Attachment, AttachmentAdmin)
project_admin_site.register(Participant, ParticipantAdmin)

111
apps/course/admin/lesson.py

@ -0,0 +1,111 @@
import os
from django.contrib import admin
from django import forms
from django.utils.translation import gettext_lazy as _
from django.db import models
from django.utils.html import format_html
from unfold.admin import ModelAdmin
from unfold.decorators import display
from unfold.contrib.forms.widgets import WysiwygWidget
from unfold.contrib.filters.admin import (
ChoicesDropdownFilter,
MultipleRelatedDropdownFilter,
)
from unfold.widgets import (
UnfoldAdminRadioSelectWidget,
)
from utils.admin import project_admin_site
from apps.course.models.lesson import Lesson, LessonCompletion
from unfold.admin import ModelAdmin, StackedInline, TabularInline
class LessonForm(forms.ModelForm):
class Meta:
model = Lesson
fields = '__all__'
widgets = {
'content_type': UnfoldAdminRadioSelectWidget(),
}
from apps.quiz.models import Quiz
class LessonAdmin(ModelAdmin):
form = LessonForm
list_display = ('title', 'course', 'display_duration', 'content_type', 'is_active', 'priority')
list_filter = (
('course', MultipleRelatedDropdownFilter),
('content_type', ChoicesDropdownFilter),
'is_active',
)
search_fields = ('title', 'course__title')
ordering = ('course', 'priority')
autocomplete_fields = ('course', )
list_filter_submit = True
radio_fields = {
"content_type": admin.HORIZONTAL,
}
conditional_fields = {
'content_file': "content_type == 'video_file'",
'video_link': "content_type == 'youtube_link'",
}
fieldsets = (
(None, {
'fields': ('course', 'title', 'priority', 'is_active', 'duration')
}),
(_('Content'), {
'fields': ('content_type', 'content_file', 'video_link'),
'classes': [],
}),
)
def get_form(self, request, obj=None, change=False, **kwargs):
form = super().get_form(request, obj, change, **kwargs)
# Enhanced styling for content_type radio buttons
form.base_fields["content_type"].widget = UnfoldAdminRadioSelectWidget(
choices=Lesson.ContentTypeChoices.choices,
radio_style=admin.HORIZONTAL,
attrs={
"class": "radio-inline flex gap-4 p-2 rounded-lg bg-gray-50 shadow-sm",
"option_class": "flex items-center p-2 rounded-md hover:bg-white hover:shadow-sm transition-all duration-200",
"label_class": "ml-2 font-medium text-gray-700 cursor-pointer",
"input_class": "form-radio h-5 w-5 text-blue-600 transition duration-150 ease-in-out cursor-pointer",
},
)
return form
@display(description=_("Duration"))
def display_duration(self, obj):
return format_html(
'<span class="badge badge-info">{} min</span>',
obj.duration
)
def get_queryset(self, request):
qs = super().get_queryset(request)
return qs.order_by('course', 'priority')
class LessonCompletionAdmin(ModelAdmin):
list_display = ('student', 'lesson', 'completed_at')
search_fields = ('student__fullname', 'student__email', 'lesson__title', 'lesson__course__title')
list_filter = ('lesson__course', 'completed_at')
ordering = ('-completed_at',)
def get_readonly_fields(self, request, obj=None):
"""
Make fields readonly if the object already exists.
"""
if obj:
return ['student', 'lesson', 'completed_at']
return []
# Register with the project admin site
project_admin_site.register(Lesson, LessonAdmin)
project_admin_site.register(LessonCompletion, LessonCompletionAdmin)

33
apps/course/admin/participant.py

@ -0,0 +1,33 @@
# from django.contrib import admin
# from apps.course.models import Participant
# from apps.account.models import StudentUser, User
# @admin.register(Participant)
# class ParticipantAdmin(admin.ModelAdmin):
# list_display = ('student', 'course', 'joined_date', 'unread_messages_count')
# search_fields = ('student__fullname', 'student__email', 'course__title')
# list_filter = ('course', 'joined_date')
# ordering = ('-joined_date',)
# autocomplete_fields = ['student',] # جستجوی پویا برای فیلد دانش‌آموز
# def get_readonly_fields(self, request, obj=None):
# """
# Make fields readonly if the object already exists.
# """
# if obj:
# return ['student', 'course', 'joined_date']
# return []
# def get_form(self, request, obj=None, **kwargs):
# form = super().get_form(request, obj, **kwargs)
# if obj is None: # Adding a new participant
# # محدود کردن انتخاب دانش‌آموزان به کاربرانی که از نوع StudentUser هستند
# # form.base_fields['student'].queryset = StudentUser.objects.filter(user_type=User.UserType.STUDENT)
# form.base_fields['student'].widget.can_add_related = True # فعال کردن دکمه اضافه کردن
# return form

9
apps/course/apps.py

@ -0,0 +1,9 @@
from django.apps import AppConfig
class CourseConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.course'
def ready(self):
import apps.course.signals

42
apps/course/data/category.json

@ -0,0 +1,42 @@
[
{
"id": 8,
"name": "Комплексный годовой курс",
"slug": "kompleksnyi-godovoi-kurs"
},
{
"id": 7,
"name": "Исламская философия",
"slug": "islamskaia-filosofiia"
},
{
"id": 6,
"name": "Арабский диалог",
"slug": "arabskii-dialog"
},
{
"id": 5,
"name": "грамматике арабского языка",
"slug": "grammatike-arabskogo-iazyka"
},
{
"id": 4,
"name": "Персидский язык",
"slug": "persidskii-iazyk"
},
{
"id": 3,
"name": "исламской философии",
"slug": "islamskoi-filosofii"
},
{
"id": 2,
"name": "Толкование корана",
"slug": "tolkovanie-korana"
},
{
"id": 1,
"name": "Таджвид Корана",
"slug": "tadzhvid-korana"
}
]

430
apps/course/doc.py

@ -0,0 +1,430 @@
def doc_course_participants():
return """
# 🐈 Scenario
🛠 لیست شرکتکنندگان دوره
---
## 🚀 درخواست API
### URL:
```
GET /api/courses/<slug>/participants/
```
## 📊 پاسخ‌ها
| کد وضعیت | توضیحات |
|---------------|-----------------------------------------------------------|
| `200` | موفقیتآمیز - لیستی از شرکتکنندگان دوره بازگردانده شد. |
| `404` | دوره یافت نشد. |
| `500` | مشکل موقتی در سرور. |
---
### پاسخ موفق:
```json
[
{
"id": 1,
"fullname": "Ali Rezaei",
"avatar": "https://example.com/avatars/ali_rezaei.jpg",
"email": "ali@example.com",
"phone_number": "+98 912 345 6789",
"info": "Experienced Python Developer",
"skill": "Python, Django, REST API"
}
]
```
"""
def doc_courses_lesson():
return """
# 🐈 Scenario
🛠 لیست درسهای دوره
این API برای دریافت لیست درسهای یک دوره خاص استفاده میشود. این لیست شامل اطلاعاتی مانند عنوان، اولویت، مدت زمان، نوع محتوا، لینک ویدئو، و وضعیت تکمیل هر درس میباشد.
(مقدار is_complated مشخص میکند آیا کاربر این درس را گذرانده است
ممکن است درس دارای کوعیز باشد که باید در زیر آ» مانند طرح نمایش داده شود
)
دارای ابجکت کوعیز که لیستی از کوعیز های مربوط به یک درس را نمایش میدهد
بایستی مانند طرح در زیر درس قرار داده شود
و دارای مقدار permission
است که مشخص میکند ایا این کاربر کوعیز را از قبل شرکت کرده است
---
```
## 📄 توضیحات مقادیر پاسخ
| کلید | نوع داده | توضیحات |
|-------------------------|------------|----------------------------------------------------------|
| `id` | Integer | شناسه یکتای درس. |
| `title` | String | عنوان درس. |
| `priority` | Integer | اولویت نمایش درس در لیست دروس. |
| `is_active` | Boolean | آیا درس فعال است یا خیر. |
| `duration` | Integer | مدت زمان درس به دقیقه. |
| `content_type` | String | نوع محتوا (لینک یا فایل). |
| `content_file` | String | فایل مرتبط با درس (در صورت وجود). |
| `video_link` | String | لینک ویدئو برای درس (در صورت آنلاین بودن). |
| `is_complated` | Boolean | آیا کاربر این درس را تکمیل کرده است یا خیر. |
| `quiz` | Object | اطلاعات مرتبط با کوییز درس (در صورت وجود). |
### پاسخ موفق:
```json
[
{
"id": 1,
"title": "Introduction to Variables",
"duration": 30,
"content_type": "link",
"content_file": null,
"video_link": "https://example.com/videos/variables_intro.mp4",
"is_complated": true,
"quizs": [
{
"id": 1,
"title": "Тестовые курсы",
"description": "урок 1-2",
"permission": true,
"each_question_timing": 30
}
]
},
{
"id": 1,
"title": "Introduction to Variables",
"duration": 30,
"content_type": "link",
"content_file": null,
"video_link": "https://example.com/videos/variables_intro.mp4",
"is_complated": true,
"quizs": null
}
]
```
"""
def doc_courses_my_courses():
return """
# 🐈 Scenario
🛠 دورههای من
این API برای دریافت لیست دورههایی است که کاربر در آنها شرکت کرده است. این شامل دورههایی است که به اتمام رسیدهاند یا هنوز در حال تکمیل هستند.
(برای دوره های تکمیل نشده
?completed=false
دوره های تکمیل شده
?completed=true
)
(برای همه دوره های کاربر بدون هیچ مقداری بفرستید)
(در صفحه هوم هم میتوانید دوره هایی که کاربر شرکت کرده است و هنوز تکمیل نشده است را نمایش دهید)
---
## 🚀 درخواست API
### پارامترهای فیلتر
| کلید | نوع داده | توضیحات |
|---------------|-----------|----------------------------------------------------------|
| `completed` | Boolean | اگر `true` باشد، فقط دورههایی که تکمیل شدهاند را بازمیگرداند. |
### درخواست کامل:
```
GET /api/my-courses/?completed=true
```
"""
def doc_course_detail():
return """
# 🐈 Scenario
🛠 جزئیات دوره
---
## 💡 نکات مهم:
1. **اطلاعات دسترسی (`access`)**:
- این مقدار نشان میدهد که آیا کاربر به این دوره دسترسی دارد یا خیر.
در واقع آیا دانش آموز این دوره است و به درس های این دوره دسترسی دارد
2. **ویدئو دوره**:
- دورهها میتوانند شامل لینک ویدئو یا فایل ویدئویی باشند که توسط `video_type` مشخص میشود.
3. **تعداد درسهای تکمیلشده**:
- `lessons_complated_count` نشان میدهد که چند درس توسط کاربر تکمیل شده است.
(برای به دست آوردن درصد درس های تکمیل شده دانش اموز تعداد کل درس های دوره را بر اساس درس های تکمیل شده دوره توسط دانش آموز محاسبه کنید)
4. **اطلاعات استاد (`professor`)**:
- اطلاعات استاد شامل نام، تصویر و مهارتها برای آشنایی بیشتر با مربی دوره فراهم شده است.
5. برای دیدن درس ها و فایل ها و گلاسوری api
های جدا در نظر گرفته شده است.
---
---
## 📄 توضیحات مقادیر پاسخ
| کلید | نوع داده | توضیحات |
|-------------------------|------------|----------------------------------------------------------|
| `id` | Integer | شناسه یکتای دوره. |
| `title` | String | عنوان دوره. |
| `slug` | String | شناسه یکتای دوره که برای URLها استفاده میشود. |
| `category` | Object | اطلاعات دستهبندی دوره شامل نام و شناسه. |
| `access` | Boolean | آیا کاربر به این دوره دسترسی دارد یا خیر. |
| `participant_count` | Integer | تعداد شرکتکنندگان در این دوره. |
| `professor` | Object | اطلاعات استاد شامل نام، تصویر، و مهارتها. |
| `thumbnail` | String | لینک تصویر کوچک دوره. به صورت ابجکت است |
| `video_type` | String | نوع ویدئو (لینک یا فایل). |
| `video_file` | String | لینک فایل ویدئویی در صورت وجود. |
| `video_link` | String | لینک ویدئو در صورت آنلاین بودن محتوا. |
| `is_online` | Boolean | آیا دوره به صورت آنلاین برگزار میشود یا خیر. |
| `level` | String | سطح دوره (beginner, mid, advanced). |
| `duration` | Integer | مدت زمان دوره به ساعت. |
| `lessons_count` | Integer | تعداد درسهای موجود در این دوره. |
| `lessons_complated_count`| Integer | تعداد درسهایی که کاربر تکمیل کرده است. که ممکن است مقدار خالی هم باشد |
| `short_description` | String | توضیح کوتاه در مورد دوره. |
| `status` | String | وضعیت دوره (upcoming, registering, ongoing, finished). |
| `is_free` | Boolean | آیا دوره رایگان است یا خیر. |
| `price` | Decimal | قیمت اصلی دوره در صورت غیر رایگان بودن. |
| `discount_percentage` | Decimal | درصد تخفیف برای دوره. |
| `final_price` | Decimal | قیمت نهایی دوره پس از اعمال تخفیف. |
| `timing` | String | زمانبندی برگزاری دوره (مثلاً ساعتها و روزهای برگزاری).'enum': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], |
| `features` | String | ویژگیهای برجسته دوره. |
---
## 📄 نمونه پاسخ موفقیت‌آمیز
```json
{
"id": 1,
"title": "Тажвид м",
"slug": "tazhvid-m",
"category": {
"name": "Таджвид Корана",
"slug": "tadzhvid-korana",
"course_count": 25
},
"access": true,
"participant_count": 120,
"professor": {
"id": 2,
"fullname": "rezaa",
"avatar": "http://localhost:8000/media/users/avatars/2024/11/test3.jpeg",
"email": "root@admin.com",
"phone_number": "+98 901 203 1023",
"info": "good",
"skill": null
},
"thumbnail": {},
"video_type": "video_link",
"video_file": null,
"video_link": "https:222",
"is_online": true,
"level": "beginner",
"duration": 55,
"lessons_count": 2,
"lessons_complated_count": 0,
"short_description": "Таджвид Корана2",
"status": "upcoming",
"is_free": true,
"price": "0.00",
"discount_percentage": 0,
"final_price": "0.00",
"timing": [
{
"day": "Monday",
"time": "02:00"
},
{
"day": "Friday",
"time": "10:00"
}
],
"features": [
{
"title": "good"
},
{
"title": "regood"
}
]
}
```
"""
def doc_course_list():
return """
# 🐈 Scenario
🛠 لیست دورهها
این API برای لیست کردن دورهها به همراه اطلاعاتی مانند تعداد شرکتکنندگان، دستهبندی، تصویر کوچک، سطح، مدت زمان و دیگر جزئیات مرتبط استفاده میشود.
## 📄 توضیحات مقادیر پاسخ
| کلید | نوع داده | توضیحات |
|---------------------|------------|----------------------------------------------------------|
| `id` | Integer | شناسه یکتای دوره. |
| `title` | String | عنوان دوره. |
| `slug` | String | شناسه یکتای دوره که برای URLها استفاده میشود. |
| `participant_count` | Integer | تعداد شرکتکنندگانی که در این دوره حضور دارند. |
| `category` | Object | اطلاعات دستهبندی دوره شامل نام و شناسه و اسلاک |
| `thumbnail` | String | لینک تصویر کوچک دوره. |
| `is_online` | Boolean | آیا دوره به صورت آنلاین برگزار میشود یا خیر. |
| `level` | String | سطح دوره (beginner, mid, advanced). |
| `duration` | Integer | مدت زمان دوره به ساعت. |
| `lessons_count` | Integer | تعداد درسهای موجود در این دوره. |
| `short_description` | String | توضیح کوتاه در مورد دوره. |
| `status` | String | وضعیت دوره (upcoming, registering, ongoing, finished). |
| `is_free` | Boolean | آیا دوره رایگان است یا خیر. |
| `price` | Decimal | قیمت اصلی دوره در صورت غیر رایگان بودن. |
| `discount_percentage`| Decimal | درصد تخفیف برای دوره. |
| `final_price` | Decimal | قیمت نهایی دوره پس از اعمال تخفیف. |
---
### پارامترهای فیلتر و جستجو
| کلید | نوع داده | توضیحات |
|---------------|-----------|----------------------------------------------------------|
| `title` | String | عنوان دوره برای جستجو در لیست دورهها. |
| `category_slug` | String | اسلاگ دستهبندی دوره برای فیلتر کردن دورهها براساس دستهبندی. |
| `status` | String | وضعیت دوره برای فیلتر کردن براساس وضعیت (upcoming, registering, ongoing, finished) |
| `is_free` | Boolean | برای فیلتر کردن دورههای رایگان یا غیررایگان. |
| `is_online` | Boolean | برای فیلتر کردن دورههای آنلاین یا آفلاین. |
---
## 📊 پاسخ‌ها
| کد وضعیت | توضیحات |
|---------------|-----------------------------------------------------------|
| `200` | موفقیتآمیز - لیستی از دورهها بازگردانده شد. |
| `500` | مشکل موقتی در سرور. |
---
## 📄 نمونه پاسخ موفقیت‌آمیز
```json
[
{
"id": 1,
"title": "Introduction to Python",
"slug": "introduction-to-python",
"participant_count": 120,
"category": {
"name": "Programming",
"slug": "programming"
},
"thumbnail": {},
"is_online": true,
"level": "beginner",
"duration": 180,
"lessons_count": 12,
"short_description": "Learn the basics of Python programming.",
"status": "upcoming",
"is_free": false,
"price": 100.0,
"discount_percentage": 20.0,
"final_price": 80.0
},
]
```
"""
def doc_course_category():
return """
# 🐈 Scenario
🛠 لیست دستهبندیهای دورهها
این API برای لیست کردن دستهبندیهای دورهها به همراه تعداد دورههای مرتبط با هر دستهبندی استفاده میشود.
---
## 🚀 درخواست API
---
## 📄 توضیحات مقادیر پاسخ
| کلید | نوع داده | توضیحات |
|---------------|-----------|----------------------------------------------------------|
| `name` | String | نام دستهبندی دوره. |
| `slug` | String | شناسه یکتای دستهبندی که برای URLها استفاده میشود. |
| `course_count`| Integer | تعداد دورههایی که در این دستهبندی قرار دارند. |
---
## 📊 پاسخ‌ها
| کد وضعیت | توضیحات |
|---------------|-----------------------------------------------------------|
| `200` | موفقیتآمیز - لیستی از دستهبندیهای دورهها بازگردانده شد. |
| `500` | مشکل موقتی در سرور. |
---
## 📄 نمونه پاسخ موفقیت‌آمیز
```json
[
{
"name": "Programming",
"slug": "programming",
"course_count": 12
},
{
"name": "Data Science",
"slug": "data-science",
"course_count": 8
}
]
```
## 📄 نمونه درخواست:
### درخواست کامل:
```
GET /api/course-categories/
```
### پاسخ موفق:
```json
[
{
"name": "Web Development",
"slug": "web-development",
"course_count": 15
},
{
"name": "Artificial Intelligence",
"slug": "ai",
"course_count": 10
}
]
```
"""

135
apps/course/migrations/0001_initial.py

@ -0,0 +1,135 @@
# Generated by Django 5.1.8 on 2025-04-03 00:05
import apps.course.models.course
import apps.course.models.lesson
import django.db.models.deletion
import filer.fields.image
import utils.schema
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('account', '0001_initial'),
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
]
operations = [
migrations.CreateModel(
name='CourseCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Category Name')),
('slug', models.SlugField(max_length=255, unique=True)),
],
),
migrations.CreateModel(
name='Course',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255, verbose_name='Course Title')),
('slug', models.SlugField(allow_unicode=True, unique=True)),
('video_type', models.CharField(choices=[('video_file', 'Video File'), ('video_link', 'Video Link')], max_length=20, verbose_name='Vedio Type')),
('video_file', models.FileField(blank=True, null=True, upload_to=apps.course.models.course.course_file_upload_to)),
('video_link', models.CharField(blank=True, max_length=500, null=True, verbose_name='Video Link')),
('is_online', models.BooleanField(default=True, verbose_name='Is Online Course')),
('online_link', models.CharField(blank=True, max_length=500, null=True, verbose_name='Online Class Link')),
('level', models.CharField(choices=[('beginner', 'Beginner'), ('mid', 'Mid Level'), ('advanced', 'Advanced')], max_length=10, 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(blank=True, max_length=500, null=True, verbose_name='Short Description')),
('status', models.CharField(choices=[('inactive', 'Inactive'), ('upcoming', 'Upcoming'), ('registering', 'Registering'), ('ongoing', 'Ongoing'), ('finished', 'Finished')], default='inactive', max_length=15, verbose_name='Course Status')),
('is_free', models.BooleanField(default=True, verbose_name='Is Free')),
('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='Course Price')),
('discount_percentage', models.PositiveIntegerField(default=0, verbose_name='Discount Percentage')),
('final_price', models.DecimalField(blank=True, decimal_places=2, default=0.0, help_text='This field is automatically calculated based on the discount percentage.', max_digits=10, verbose_name='Course Final Price')),
('timing', models.JSONField(blank=True, default=utils.schema.default_timing, help_text='The Timing information in JSON format.', null=True, verbose_name='Timing')),
('features', models.JSONField(blank=True, default=dict, null=True, verbose_name='Course features')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('professor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to='account.professoruser')),
('thumbnail', filer.fields.image.FilerImageField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.FILER_IMAGE_MODEL, verbose_name='thumbnail')),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to='course.coursecategory', verbose_name='Category')),
],
options={
'verbose_name': 'Course',
'verbose_name_plural': 'Courses',
},
),
migrations.CreateModel(
name='Attachment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255, verbose_name='Attachment Title')),
('file', models.FileField(upload_to=apps.course.models.course.attachment_file_upload_to, verbose_name='Attachment File')),
('file_size', models.PositiveIntegerField(blank=True, null=True, verbose_name='File Size (in bytes)')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='course.course', verbose_name='Course')),
],
options={
'verbose_name': 'Attachment',
'verbose_name_plural': 'Attachments',
'ordering': ('-id',),
},
),
migrations.CreateModel(
name='Glossary',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=555, verbose_name='Glossary Title')),
('description', models.TextField(verbose_name='Description')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='glossaries', to='course.course', verbose_name='Course')),
],
options={
'verbose_name': 'Glossary',
'verbose_name_plural': 'Glossary',
'ordering': ('-id',),
},
),
migrations.CreateModel(
name='Lesson',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255, verbose_name='Lesson Title')),
('priority', models.IntegerField(blank=True, null=True, verbose_name='Priority')),
('is_active', models.BooleanField(default=True, verbose_name='Is Active')),
('duration', models.PositiveIntegerField(verbose_name='Duration (in minutes)')),
('content_type', models.CharField(choices=[('youtube_link', 'Youtube Link'), ('video_file', 'Video File'), ('audio_file', 'Audio File')], max_length=50, verbose_name='Content Type')),
('content_file', models.FileField(blank=True, null=True, upload_to=apps.course.models.lesson.lesson_file_upload_to)),
('video_link', models.CharField(blank=True, max_length=500, null=True, verbose_name='Link')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='course.course', verbose_name='Course')),
],
),
migrations.CreateModel(
name='LessonCompletion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('completed_at', models.DateTimeField(auto_now_add=True)),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='completions', to='course.lesson')),
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lesson_completions', to='account.studentuser')),
],
options={
'unique_together': {('student', 'lesson')},
},
),
migrations.CreateModel(
name='Participant',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('joined_date', models.DateTimeField(auto_now_add=True)),
('unread_messages_count', models.IntegerField(default=0)),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='course.course')),
('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participated_courses', to='account.studentuser')),
],
options={
'unique_together': {('student', 'course')},
},
),
]

18
apps/course/migrations/0002_alter_course_thumbnail.py

@ -0,0 +1,18 @@
# Generated by Django 5.1.8 on 2025-04-03 01:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='course',
name='thumbnail',
field=models.ImageField(blank=True, null=True, upload_to='courses/thumbnails/', verbose_name='Thumbnail'),
),
]

34
apps/course/migrations/0003_alter_course_is_online_alter_course_timing_and_more.py

@ -0,0 +1,34 @@
# Generated by Django 5.1.8 on 2025-04-04 00:09
import utils.schema
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('course', '0002_alter_course_thumbnail'),
]
operations = [
migrations.AlterField(
model_name='course',
name='is_online',
field=models.BooleanField(default=False, verbose_name='Is Online Course'),
),
migrations.AlterField(
model_name='course',
name='timing',
field=models.JSONField(blank=True, default=utils.schema.default_timing, null=True, verbose_name='Timing'),
),
migrations.AlterField(
model_name='course',
name='video_link',
field=models.CharField(blank=True, max_length=500, null=True),
),
migrations.AlterField(
model_name='course',
name='video_type',
field=models.CharField(choices=[('youtube_link', 'Youtube Link'), ('video_file', 'Video File')], max_length=20, verbose_name='Preview Video Type (YouTube Link or File Upload)'),
),
]

0
apps/course/migrations/__init__.py

3
apps/course/models/__init__.py

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

174
apps/course/models/course.py

@ -0,0 +1,174 @@
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 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):
if not self.slug:
self.slug = generate_slug_for_model(CourseCategory, self.name)
super().save(*args, **kwargs)
@property
def course_count(self):
return self.courses.exclude(status="inactive").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):
YOUTUBE_LINK = 'youtube_link', 'Youtube Link'
VIDEO_FILE = 'video_file', 'Video File'
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 = models.ImageField(upload_to="courses/thumbnails/", null=True, blank=True, verbose_name=_('Thumbnail'))
video_type = models.CharField(
max_length=20,
choices=VedioTypeChoices.choices,
verbose_name='Preview Video Type (YouTube Link or File Upload)'
)
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)
is_online = models.BooleanField(default=False, verbose_name='Is Online Course')
online_link = models.CharField(max_length=500, null=True, blank=True, verbose_name='Online Class Link')
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"))
features = models.JSONField(verbose_name=_('Course features'), default=dict, blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
def __str__(self):
return self.title
def get_completed_lessons_count(self, student):
return self.lessons.filter(completions__student=student).count()
def is_student_participant(self, student):
return self.participants.filter(student=student).exists()
def save(self, *args, **kwargs):
if not self.slug:
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"

106
apps/course/models/lesson.py

@ -0,0 +1,106 @@
import os
from django.db import models
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 StudentUser
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):
YOUTUBE_LINK = 'youtube_link', 'Youtube Link'
VIDEO_FILE = 'video_file', 'Video 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')
is_active = models.BooleanField(default=True, verbose_name=_('Is Active'))
duration = models.PositiveIntegerField(verbose_name='Duration (in minutes)')
content_type = models.CharField(max_length=50, 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='Link')
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))
def __str__(self):
return f"{self.course.title} - {self.title}"
def is_completed_by(self, student):
return self.completions.filter(student=student).exists()
def save(self, *args, **kwargs):
print(f'---> start')
if self.priority is None:
# If priority is not set, set it to the next available priority
max_priority = self.course.lessons.aggregate(max_priority=models.Max('priority'))['max_priority']
self.priority = (max_priority or 0) + 1
else:
self._adjust_priorities()
super().save(*args, **kwargs)
def _adjust_priorities(self):
# Adjust priorities of other lessons in the course
lessons = self.course.lessons.exclude(pk=self.pk)
# Shift priorities for lessons with the same or higher priority
lessons.filter(priority__gte=self.priority).update(priority=models.F('priority') + 1)
# # If priority is set, adjust the priorities of other lessons
# lessons = self.course.lessons.exclude(pk=self.pk).order_by('priority')
# updated_priorities = []
# inserted = False
# for lesson in lessons:
# if lesson.priority >= self.priority and not inserted:
# updated_priorities.append((self.priority, self))
# inserted = True
# updated_priorities.append((lesson.priority if not inserted else lesson.priority + 1, lesson))
# if not inserted:
# updated_priorities.append((self.priority, self))
# # Update priorities in bulk
# for priority, lesson in updated_priorities:
# lesson.priority = priority
# lesson.save(update_fields=['priority'])
class LessonCompletion(models.Model):
student = models.ForeignKey(
StudentUser,
on_delete=models.CASCADE,
related_name='lesson_completions'
)
lesson = models.ForeignKey(
Lesson,
on_delete=models.CASCADE,
related_name='completions'
)
completed_at = models.DateTimeField(auto_now_add=True)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
class Meta:
unique_together = ('student', 'lesson')
def __str__(self):
return f"{self.student.fullname} - {self.lesson.title} - Completed"

24
apps/course/models/participant.py

@ -0,0 +1,24 @@
from django.db import models
from apps.account.models import StudentUser, User
from apps.course.models import Course
class Participant(models.Model):
student = models.ForeignKey(
StudentUser,
on_delete=models.CASCADE,
related_name='participated_courses'
)
course = models.ForeignKey(
Course,
on_delete=models.CASCADE,
related_name='participants'
)
joined_date = models.DateTimeField(auto_now_add=True)
unread_messages_count = models.IntegerField(default=0)
class Meta:
unique_together = ('student', 'course')

3
apps/course/serializers/__init__.py

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

247
apps/course/serializers/course.py

@ -0,0 +1,247 @@
from rest_framework import serializers
# from dj_filer.admin import get_thumbs
from utils import get_thumbs
from apps.course.models import Course, CourseCategory, Attachment, Glossary, LessonCompletion, Participant, Lesson
from apps.chat.models import RoomMessage
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
class CourseListSerializer(serializers.ModelSerializer):
category = CourseCategorySerializer()
thumbnail = serializers.SerializerMethodField()
participant_count = serializers.SerializerMethodField()
lessons_count = serializers.SerializerMethodField()
class Meta:
model = Course
fields = [
'id',
'title',
'slug',
'participant_count',
'category',
'thumbnail',
'is_online',
'online_link',
'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 obj.participants.count()
def get_lessons_count(self, obj):
lessons_count = obj.lessons.filter(is_active=True).count()
return max(lessons_count, obj.lessons_count)
class CourseDetailSerializer(serializers.ModelSerializer):
category = CourseCategorySerializer()
professor = UserProfileSerializer()
thumbnail = serializers.SerializerMethodField()
participant_count = serializers.SerializerMethodField()
access = serializers.SerializerMethodField()
lessons_complated_count = serializers.SerializerMethodField()
lessons_count = serializers.SerializerMethodField()
last_lesson_id = serializers.SerializerMethodField()
room_id = serializers.SerializerMethodField()
class Meta:
model = Course
fields = [
'id',
'title',
'slug',
'category',
'access',
'participant_count',
'professor',
'thumbnail',
'video_type',
'video_file',
'video_link',
'is_online',
'online_link',
'level',
'description',
'duration',
'lessons_count',
'lessons_complated_count',
'short_description',
'status',
'is_free',
'price',
'discount_percentage',
'final_price',
'timing',
'features',
'last_lesson_id',
'room_id',
]
def get_room_id(self, obj):
room_message = RoomMessage.objects.filter(course=obj).first()
if room_message:
return room_message.id
return None
def get_last_lesson_id(self, obj):
request = self.context.get('request')
if request and request.user.is_authenticated:
user = request.user
# آخرین درس تکمیل‌شده توسط کاربر
last_completed_lesson = LessonCompletion.objects.filter(
student=user,
lesson__course=obj
).order_by('-completed_at').first()
if last_completed_lesson:
# پیدا کردن درس بعدی بر اساس priority
next_lesson = Lesson.objects.filter(
course=obj,
priority__gt=last_completed_lesson.lesson.priority,
is_active=True
).order_by('priority').first()
if not next_lesson:
next_lesson = Lesson.objects.filter(
course=obj,
is_active=True
).order_by('priority').first()
if next_lesson:
return next_lesson.id
return None
def get_access(self, obj):
if student := self._get_authenticated_user():
if not self._is_participant(student, obj):
return False
return True
return False
def get_is_professor(self, obj):
if professor := self._get_authenticated_user():
return obj.professor == professor
return False
def get_lessons_count(self, obj):
lessons_count = obj.lessons.filter(is_active=True).count()
return max(lessons_count, obj.lessons_count)
def get_lessons_complated_count(self, obj):
if student := self._get_authenticated_user():
if not self._is_participant(student, obj):
return None
return self._get_completed_lessons_count(student, obj)
return None
def _is_participant(self, student, course):
"""Helper method to check if a student is a participant in the given course."""
return Participant.objects.filter(student=student, course=course).exists()
def _get_authenticated_user(self):
"""Helper method to retrieve the authenticated user from the context."""
request = self.context.get('request')
return request.user if request and request.user.is_authenticated else None
def _get_completed_lessons_count(self, student, course):
"""Helper method to count completed lessons for the student in the given course."""
return LessonCompletion.objects.filter(
student=student,
lesson__course=course
).count()
def get_thumbnail(self, obj):
return get_thumbs(obj.thumbnail, self.context.get('request'))
def get_participant_count(self, obj):
return obj.participants.count()
class MyCourseListSerializer(serializers.ModelSerializer):
category = CourseCategorySerializer()
thumbnail = serializers.SerializerMethodField()
lessons_complated_count = serializers.SerializerMethodField()
class Meta:
model = Course
fields = [
'id',
'title',
'slug',
'category',
'thumbnail',
'lessons_count',
'lessons_complated_count',
'short_description',
'status',
]
def get_thumbnail(self, obj):
return get_thumbs(obj.thumbnail, self.context.get('request'))
def get_lessons_complated_count(self, obj):
if student := self._get_authenticated_user():
if not self._is_participant(student, obj):
return None
return self._get_completed_lessons_count(student, obj)
return None
def _is_participant(self, student, course):
"""Helper method to check if a student is a participant in the given course."""
return Participant.objects.filter(student=student, course=course).exists()
def _get_authenticated_user(self):
"""Helper method to retrieve the authenticated user from the context."""
request = self.context.get('request')
return request.user if request and request.user.is_authenticated else None
def _get_completed_lessons_count(self, student, course):
"""Helper method to count completed lessons for the student in the given course."""
return LessonCompletion.objects.filter(
student=student,
lesson__course=course
).count()
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']

57
apps/course/serializers/lesson.py

@ -0,0 +1,57 @@
from rest_framework import serializers
from apps.course.models import Lesson, Participant, LessonCompletion
from apps.quiz.serializers import QuizListSerializer
class LessonSerializer(serializers.ModelSerializer):
is_complated = serializers.SerializerMethodField()
quizs = serializers.SerializerMethodField()
permission = serializers.SerializerMethodField()
class Meta:
model = Lesson
fields = ['id', 'title', 'priority', 'is_active', 'permission','duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quizs']
def get_permission(self, obj):
if student := self._get_authenticated_user():
if not self._is_participant(student, obj.course):
return False
return True
return False
def _get_authenticated_user(self):
"""Helper method to retrieve the authenticated user from the context."""
request = self.context.get('request')
return request.user if request and request.user.is_authenticated else None
def _is_participant(self, student, course):
"""Helper method to check if a student is a participant in the given course."""
return Participant.objects.filter(student=student, course=course).exists()
def get_is_complated(self, obj):
request = self.context.get('request')
if not request or not request.user.is_authenticated:
return False
user = request.user
is_participant = Participant.objects.filter(
student=user,
course=obj.course
).exists()
if not is_participant:
return False
return LessonCompletion.objects.filter(
student=user,
lesson=obj
).exists()
def get_quizs(self, obj):
quizzes = obj.quizzes.all() # استفاده از related_name 'quizzes' برای دسترسی به کوییزهای درس
if quizzes.exists():
return QuizListSerializer(quizzes, many=True, context=self.context).data
return None

17
apps/course/serializers/participant.py

@ -0,0 +1,17 @@
from rest_framework import serializers
from apps.course.models import Lesson, Participant, LessonCompletion
from apps.account.models import StudentUser, User
class ParticipantSerializer(serializers.ModelSerializer):
email = serializers.EmailField(required=True)
gender = serializers.ChoiceField(choices=User.GenderChoices.choices, required=True)
class Meta:
model = StudentUser
fields = ['fullname' , 'phone_number', 'gender', 'email', 'birthdate']

19
apps/course/signals.py

@ -0,0 +1,19 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from apps.course.models import Course
from apps.chat.models import RoomMessage
@receiver(post_save, sender=Course)
def create_room_message_for_course(sender, instance, created, **kwargs):
if created: # فقط برای موارد جدید اجرا شود
RoomMessage.objects.create(
name=f"{instance.title} - Group",
description=f"Group chat for course: {instance.title}",
initiator=instance.professor, # استاد به‌عنوان سازنده اتاق
course=instance,
room_type=RoomMessage.RoomTypeChoices.GROUP
)

29
apps/course/templates/course/add_student_form.html

@ -0,0 +1,29 @@
{% extends "admin/base_site.html" %}
{% load i18n unfold %}
{% block breadcrumbs %}{% endblock %}
{% block extrahead %}
{{ block.super }}
<script src="{% url 'admin:jsi18n' %}"></script>
{{ form.media }}
{% endblock %}
{% block content %}
<form action="" method="post" novalidate>
<div class="aligned border border-base-200 mb-8 rounded-md pt-3 px-3 shadow-sm dark:border-base-800">
{% csrf_token %}
{% for field in form %}
{% include "unfold/helpers/field.html" with field=field %}
{% endfor %}
</div>
<div class="flex justify-end">
{% component "unfold/components/button.html" with submit=1 %}
{% trans "Submit form" %}
{% endcomponent %}
</div>
</form>
{% endblock %}

3
apps/course/tests.py

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

25
apps/course/urls.py

@ -0,0 +1,25 @@
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('my-courses/', views.MyCourseListAPIView.as_view(), name='course-my-courses-list'),
path('lesson/completion/', views.LessonCompletionCreateAPIView.as_view(), name='lesson-completion'),
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'),
path('<slug:slug>/lessons/', views.LessonListView.as_view(), name='course-lesson-list'),
path('lesson/<int:id>/', views.LessonDetailView.as_view(), name='lesson-detail'),
path('<slug:slug>/participants/', views.CourseParticipantsView.as_view(), name='course-participant-list'),
# path('<slug:slug>/participant/join/', views.ParticipantCreateView.as_view(), name='course-participant-join'),
]

3
apps/course/views/__init__.py

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

220
apps/course/views/course.py

@ -0,0 +1,220 @@
from rest_framework.generics import ListAPIView, RetrieveAPIView
from django.db.models import Count, Q, F
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.exceptions import NotFound
from rest_framework.permissions import IsAuthenticated
from rest_framework.filters import SearchFilter
from apps.course.serializers import (
CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer,
AttachmentSerializer, GlossarySerializer, MyCourseListSerializer
)
from apps.course.models import Course, CourseCategory, Attachment, Glossary, Participant
from apps.course.doc import *
class CourseCategoryAPIView(ListAPIView):
queryset = CourseCategory.objects.all()
serializer_class = CourseCategorySerializer
@swagger_auto_schema(
operation_description=doc_course_category(),
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
class CourseListAPIView(ListAPIView):
queryset = Course.objects.all().exclude(status=Course.StatusChoices.INACTIVE)
serializer_class = CourseListSerializer
filter_backends = [SearchFilter]
search_fields = ['title']
@swagger_auto_schema(
operation_description=doc_course_list(),
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()]
),
openapi.Parameter(
'status', openapi.IN_QUERY,
type=openapi.TYPE_STRING,
description="""Status =>
Upcoming (visible but registration not allowed)---Предстоящие
Registering (registration is open)---регистрация
Ongoing (course has started, registration closed)---Впроцессе
Finished (course has ended)---закончился
""",
enum=[status for status in ['upcoming', 'registering', 'ongoing', 'finished']]
),
openapi.Parameter(
'is_free', openapi.IN_QUERY,
description="Ценообразование is_free <bool>",
type=openapi.TYPE_BOOLEAN,
),
openapi.Parameter(
'is_online', openapi.IN_QUERY,
description="Статус участия is_online <bool>",
type=openapi.TYPE_BOOLEAN,
),
])
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
# Handle category_slug with multiple values separated by commas
if category_slugs := filters.get('category_slug'):
category_slugs_list = category_slugs.split(',')
queryset = queryset.filter(category__slug__in=category_slugs_list)
# Handle status with multiple values separated by commas
if statuses := filters.get('status'):
statuses_list = statuses.split(',')
queryset = queryset.filter(status__in=statuses_list)
if is_free := filters.get('is_free'):
is_free = is_free.lower() == 'true'
queryset = queryset.filter(
Q(is_free=is_free) | Q(price=0) if is_free else Q(is_free=False, price__gt=0)
)
if is_online := filters.get('is_online'):
is_online = is_online.lower() == 'true'
queryset = queryset.filter(is_online=is_online)
return queryset
class CourseDetailAPIView(RetrieveAPIView):
queryset = Course.objects.all()
serializer_class = CourseDetailSerializer
lookup_field = "slug"
@swagger_auto_schema(
operation_description=doc_course_detail(),
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
class MyCourseListAPIView(ListAPIView):
serializer_class = MyCourseListSerializer
permission_classes = [IsAuthenticated]
@swagger_auto_schema(manual_parameters=[
openapi.Parameter(
'completed', openapi.IN_QUERY,
description="мои курсы completed <bool> true",
type=openapi.TYPE_BOOLEAN,
),
openapi.Parameter(
'certificate', openapi.IN_QUERY,
type=openapi.TYPE_BOOLEAN,
),
],
operation_description=doc_courses_my_courses(),
operation_summary="Home",
)
def get(self, request, *args, **kwargs):
print(f'--> my-course-> {request}/ {kwargs}')
return super().get(request, *args, **kwargs)
def get_queryset(self):
queryset = Course.objects.exclude(status=Course.StatusChoices.INACTIVE)
request = self.request
filters = request.query_params
student = self.request.user
qs = queryset.filter(participants__student=student)
completed_only = filters.get('completed', '').lower() == 'true'
if completed_only == True:
# نمایش دوره‌هایی که همه درس‌هایشان توسط کاربر تکمیل شده‌اند
qs = qs.annotate(
total_lessons=Count('lessons', distinct=True),
completed_lessons=Count(
'lessons__completions',
filter=Q(lessons__completions__student=student),
distinct=True
)
).filter(total_lessons=F('completed_lessons'))
elif completed_only == False:
# نمایش دوره‌هایی که همه درس‌هایشان تکمیل نشده‌اند
qs = qs.annotate(
total_lessons=Count('lessons', distinct=True),
completed_lessons=Count(
'lessons__completions',
filter=Q(lessons__completions__student=student),
distinct=True
)
).filter(total_lessons__gt=F('completed_lessons'))
if 'completed' not in filters:
certificate = filters.get('certificate', '').lower() == 'true'
if certificate:
qs = qs.exclude(
course_certificates__student=student,
course_certificates__status__in=['pending', 'approved']
)
return qs
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
filter_backends = [SearchFilter]
search_fields = ['title', 'description']
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)

144
apps/course/views/lesson.py

@ -0,0 +1,144 @@
from rest_framework.generics import ListAPIView, RetrieveAPIView, GenericAPIView
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.response import Response
from apps.course.serializers import (
LessonSerializer
)
from apps.course.models import Course, Lesson, LessonCompletion
from apps.course.doc import *
from utils.exceptions import AppAPIException
from rest_framework.permissions import IsAuthenticated
class LessonListView(ListAPIView):
serializer_class = LessonSerializer
queryset = Lesson.objects.filter(is_active=True)
@swagger_auto_schema(
operation_description=doc_courses_lesson(),
)
def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
def get_queryset(self):
course_slug = self.kwargs.get('slug')
course = get_object_or_404(Course, slug=course_slug)
course = Course.objects.filter(slug=course_slug).first()
if not course:
raise AppAPIException({"message": "course not found"}, status_code=status.HTTP_404_NOT_FOUND)
return self.queryset.filter(course=course).order_by('priority','id')
class LessonDetailView(RetrieveAPIView):
serializer_class = LessonSerializer
def get(self, request, *args, **kwargs):
lesson_id = self.kwargs.get('id')
lesson = get_object_or_404(Lesson, id=lesson_id, is_active=True)
course = lesson.course
lessons = Lesson.objects.filter(course=course, is_active=True).order_by('priority')
total_lessons = lessons.count()
current_lesson_number = list(lessons.values_list('id', flat=True)).index(lesson.id) + 1
next_lesson = lessons.filter(priority__gt=lesson.priority).order_by('priority').first()
next_lesson_id = next_lesson.id if next_lesson else None
previous_lesson = lessons.filter(priority__lt=lesson.priority).order_by('-priority').first()
previous_lesson_id = previous_lesson.id if previous_lesson else None
lesson_data = self.get_serializer(lesson).data
lesson_data['total_lessons'] = total_lessons
lesson_data['current_lesson_number'] = current_lesson_number
lesson_data['next_lesson_id'] = next_lesson_id
lesson_data['previous_lesson_id'] = previous_lesson_id
lesson_data['can_go_next'] = next_lesson is not None
# # Get the next and previous lessons based on priority and id
# next_lesson = Lesson.objects.filter(
# course=lesson.course,
# is_active=True,
# priority__gte=lesson.priority,
# id__gt=lesson.id
# ).order_by('priority', 'id').first()
# previous_lesson = Lesson.objects.filter(
# course=lesson.course,
# is_active=True,
# priority__lte=lesson.priority,
# id__lt=lesson.id
# ).order_by('-priority', '-id').first()
# total_lessons = Lesson.objects.filter(course=lesson.course, is_active=True).count()
# # Calculate the current lesson number in the course
# current_lesson_number = Lesson.objects.filter(
# course=lesson.course,
# is_active=True,
# priority__lte=lesson.priority
# ).count()
# # Serialize the current lesson
# lesson_data = self.get_serializer(lesson).data
# # Add current lesson number and total lessons
# lesson_data['current_lesson_number'] = current_lesson_number
# lesson_data['total_lessons'] = total_lessons
# # Add next and previous lesson ids
# lesson_data['next_lesson_id'] = next_lesson.id if next_lesson else None
# lesson_data['previous_lesson_id'] = previous_lesson.id if previous_lesson else None
return Response(lesson_data)
class LessonCompletionCreateAPIView(GenericAPIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
request_body=openapi.Schema(
type=openapi.TYPE_OBJECT,
required=['lesson_id'],
properties={
'lesson_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID of the lesson to be marked as completed'),
},
),
responses={
201: 'Lesson completed successfully.',
200: 'Lesson already completed.',
400: 'Lesson ID is required.',
404: 'Lesson not found.',
}
)
def post(self, request):
student = request.user # Assuming the user is the student
lesson_id = request.data.get('lesson_id')
if not lesson_id:
return Response({'error': 'Lesson ID is required.'}, status=status.HTTP_400_BAD_REQUEST)
try:
lesson = Lesson.objects.get(id=lesson_id)
except Lesson.DoesNotExist:
return Response({'error': 'Lesson not found.'}, status=status.HTTP_404_NOT_FOUND)
# Check if the lesson is already completed by the student
if LessonCompletion.objects.filter(student=student, lesson=lesson).exists():
return Response({'message': 'Lesson already completed.'}, status=status.HTTP_200_OK)
# Create a new completion record
completion = LessonCompletion(student=student, lesson=lesson)
completion.save()
return Response({'message': 'Lesson completed successfully.'}, status=status.HTTP_201_CREATED)

61
apps/course/views/participant.py

@ -0,0 +1,61 @@
from rest_framework import generics
from rest_framework.exceptions import NotFound
from drf_yasg.utils import swagger_auto_schema
from drf_yasg import openapi
from rest_framework.permissions import IsAuthenticated
from apps.account.models import StudentUser
from apps.course.models import Participant, Course
from apps.course.serializers import ParticipantSerializer
from apps.account.serializers import UserProfileSerializer
from apps.course.doc import *
from utils.exceptions import AppAPIException
class CourseParticipantsView(generics.ListAPIView):
serializer_class = UserProfileSerializer
@swagger_auto_schema(
operation_description=doc_course_participants(),
)
def get_queryset(self):
course_slug = self.kwargs.get('slug')
try:
course = Course.objects.get(slug=course_slug)
except Course.DoesNotExist:
raise AppAPIException({'message': "Course not found"}) # Handle course not found
return StudentUser.objects.filter(participated_courses__course=course)
# class ParticipantCreateView(generics.CreateAPIView):
# queryset = StudentUser.objects.all()
# serializer_class = ParticipantSerializer
# permission_classes = [IsAuthenticated]
# def create(self, request, *args, **kwargs):
# user = request.user
# course_slug = self.kwargs.get('slug') # Get the slug from the URL
# try:
# course = Course.objects.get(slug=slug) # Retrieve the Course object
# except Course.DoesNotExist:
# raise AppAPIException({'message': "Course not found"}) # Handle course not found
# if request.data.get('email') != request.user:
# raise AppAPIException({'message': "The email must be for the requesting user"})
# if user.user_type != User.UserType.STUDENT:
# user.change_user_type(User.UserType.STUDENT)
# participant, created = Participant.objects.get_or_create(
# student=user,
# course=course
# )
# serializer = self.get_serializer(participant)
# return Response(serializer.data, status=status.HTTP_201_CREATED)

0
apps/hadis/__init__.py

3
apps/hadis/admin/__init__.py

@ -0,0 +1,3 @@
from .category import *
from .hadis import *
from .transmitter import *

222
apps/hadis/admin/category.py

@ -0,0 +1,222 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from django.http import JsonResponse
from django.urls import path
from django.db import models
from django.contrib import messages
from django.http import HttpResponseRedirect
from django.urls import reverse
from unfold.admin import ModelAdmin
from unfold.decorators import display
from dj_category.admin import BaseCategoryAdmin
from ajaxdatatable.admin import AjaxDatatable
from django.db.models import Case, When, Value
from django.utils.html import format_html
from apps.hadis.models import HadisCategory
from utils.admin import project_admin_site
@admin.register(HadisCategory)
class HadisCategoryAdmin(BaseCategoryAdmin, ModelAdmin):
change_form_template = 'admin/hadiscategory/change_form.html'
change_list_template = 'admin/category_index.html'
fieldsets = (
(None, {
'fields': ('name', 'source_type', 'category_type', 'parent', 'is_active', 'order'),
'classes': ('unfold-fieldset',),
}),
)
search_fields = ['name']
list_display = ['name', 'source_type_badge', 'category_type', 'parent', 'is_active', 'order']
list_filter = ['source_type', 'category_type', 'is_active']
@display(description=_("Source Type"))
def source_type_badge(self, obj):
badge_classes = {
'quran': 'unfold-badge unfold-badge--success',
'hadith': 'unfold-badge unfold-badge--info',
'book': 'unfold-badge unfold-badge--warning',
# Add more source types as needed
}
badge_class = badge_classes.get(obj.source_type, 'unfold-badge')
return format_html('<span class="{}">{}</span>', badge_class, obj.get_source_type_display())
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
return form
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('categories-ajax/hadiscategory/', self.admin_site.admin_view(self.ajax_categories), name='hadiscategory_ajax_categories'),
]
return custom_urls + urls
def get_categories_groupby_language(self, request=None, selected_values=(), is_multiple=False):
return super().get_categories(request, selected_values, is_multiple)
def ajax_update(self, request):
data = request.POST
src_node = self.model.objects.get(pk=int(data['srcNode']))
other_node = self.model.objects.get(pk=int(data['otherNode']))
if src_node.slug in self.base_categories or other_node.slug in self.base_categories:
return JsonResponse({'data': _('This item can not be modified')}, status=401)
mode = data['hitMode']
if mode == 'over':
src_node.move_to(other_node, 'first-child')
elif mode == 'after':
src_node.move_to(other_node, 'right')
elif mode == 'before':
src_node.move_to(other_node, 'left')
return JsonResponse({'data': 'ok'}, safe=False)
def get_categories(self, request=None, selected_values=(), is_multiple=False):
"""
Override the get_categories method to filter by source_type if provided in the request
"""
categories = super().get_categories(request, selected_values, is_multiple)
# If request has source_type parameter, filter the categories
if request and request.GET.get('source_type'):
source_type = request.GET.get('source_type')
# Filter the categories by source_type
filtered_categories = []
for category in categories:
# If it's a dictionary (serialized category)
if isinstance(category, dict) and category.get('source_type') == source_type:
filtered_categories.append(category)
# If it's a model instance
elif hasattr(category, 'source_type') and getattr(category, 'source_type') == source_type:
filtered_categories.append(category)
return filtered_categories
return categories
def ajax_categories(self, request):
"""
Handle AJAX request for categories with source_type filtering and search
"""
# Get source_type from request
source_type = request.GET.get('source_type')
# Get node_id if provided (for single node data)
node_id = request.GET.get('node_id')
# Get search term if provided
search = request.GET.get('search')
# Get parent level filter if provided
parent_level = request.GET.get('parent_level')
if node_id:
# Return data for a specific node
try:
node = self.model.objects.get(pk=int(node_id))
return JsonResponse({
'id': node.id,
'source_type': node.source_type,
'category_type': node.category_type,
'parent': node.parent_id,
'level': node.level_p # Add the level_p property
})
except self.model.DoesNotExist:
return JsonResponse({'error': 'Node not found'}, status=404)
# Get all categories
queryset = self.model.objects.all()
# Annotate queryset with level_p
queryset = queryset.annotate(
level_pp=Case(
When(parent=None, then=Value(1)),
When(parent__isnull=False, parent__parent=None, then=Value(2)),
default=Value(3),
output_field=models.IntegerField()
)
)
# Filter by source_type if provided
if source_type:
queryset = queryset.filter(source_type=source_type)
# Filter by search term if provided
if search:
queryset = queryset.filter(name__icontains=search)
# Filter by parent_level if provided
if parent_level and parent_level.isdigit():
# Convert to integer
level = int(parent_level)
# Filter categories by level_p
queryset = queryset.filter(level_pp=level)
# Convert queryset to list of dictionaries for JSON response
categories = []
for category in queryset:
categories.append({
'key': category.id,
'title': category.name,
'parent': category.parent_id,
'source_type': category.source_type,
'category_type': category.category_type,
'level': category.level_p,
# Add data property to store additional information
'data': {
'parent': category.parent_id,
'level': category.level_p
}
})
return JsonResponse(categories, safe=False)
def save_model(self, request, obj, form, change):
# Get the level choice from the form data
level_choice = request.POST.get('level_choice_hidden')
# Get the parent from AJAX selection if provided
ajax_parent = request.POST.get('ajax_parent')
if ajax_parent and ajax_parent.isdigit():
# Set the parent for the object
try:
parent_category = self.model.objects.get(pk=int(ajax_parent))
obj.parent = parent_category
except self.model.DoesNotExist:
pass
# Let the parent class handle the save
super().save_model(request, obj, form, change)
# Add a message to trigger tree reload via JavaScript
messages.success(request, _("Category saved successfully. Tree will be reloaded."),
extra_tags='unfold-message unfold-message--success')
# Set a flag in the request to redirect back to the category index page
request._category_saved = True
def response_add(self, request, obj, post_url_continue=None):
"""
Override to redirect back to the category index page after adding a new category
"""
if hasattr(request, '_category_saved') and request._category_saved:
return HttpResponseRedirect(reverse('admin:hadis_hadiscategory_changelist'))
return super().response_add(request, obj, post_url_continue)
def response_change(self, request, obj):
"""
Override to redirect back to the category index page after editing a category
"""
if hasattr(request, '_category_saved') and request._category_saved:
return HttpResponseRedirect(reverse('admin:hadis_hadiscategory_changelist'))
return super().response_change(request, obj)
# Register with project_admin_site if needed
# project_admin_site.register(HadisCategory, HadisCategoryAdmin)

161
apps/hadis/admin/hadis.py

@ -0,0 +1,161 @@
from django.contrib import admin
from django.utils.translation import gettext_lazy as _
from dj_category.admin import BaseCategoryAdmin
from ajaxdatatable.admin import AjaxDatatable
from django.http import JsonResponse
from django.urls import path
from django.db.models import Q
from django.utils.safestring import mark_safe
from django.forms.widgets import RadioSelect
from apps.hadis.models import *
from django import forms
from utils.json_editor_field import JsonEditorWidget
# Define color choices
COLOR_CHOICES = [
('red', _('Red')),
('blue', _('Blue')),
('green', _('Green')),
('yellow', _('Yellow')),
('orange', _('Orange')),
('purple', _('Purple')),
('pink', _('Pink')),
('brown', _('Brown')),
('gray', _('Gray')),
('black', _('Black')),
]
class ColorRadioSelect(RadioSelect):
template_name = 'admin/widgets/color_radio.html'
option_template_name = 'admin/widgets/color_radio_option.html'
def get_links_schema():
return {
'type': "array",
'format': 'table',
'title': ' ',
'items': {
'type': 'object',
'title': str(_('Link')),
'properties': {
'text': {'type': 'string', "format": "textarea",'title': str(_('text'))},
'link': {'type': 'string', "format": "textarea", 'title': str(_('link'))},
}
}
}
class HadisOverviewForm(forms.ModelForm):
status_color = forms.ChoiceField(
choices=COLOR_CHOICES,
widget=ColorRadioSelect(),
required=False
)
class Meta:
model = HadisOverview
fields = '__all__'
widgets = {
'links': JsonEditorWidget(attrs={'schema': get_links_schema}),
}
@admin.register(HadisTag)
class HadisTagAdmin(AjaxDatatable):
list_display = ['title', 'status']
search_fields = ['title']
class ReferenceImageInline(admin.TabularInline):
model = ReferenceImage
extra = 1
verbose_name_plural = _('Reference Images')
fields = ('thumbnail', 'priority')
@admin.register(HadisReference)
class HadisReferenceAdmin(AjaxDatatable):
list_display = ['hadis', 'book', 'created_at']
list_filter = ['book']
search_fields = ['hadis__title', 'hadis__number', 'description']
autocomplete_fields = ['hadis', 'book']
readonly_fields = ['created_at']
inlines = [ReferenceImageInline]
fieldsets = (
(None, {
'fields': ('hadis', 'book', 'description')
}),
)
@admin.register(HadisOverview)
class HadisOverviewAdmin(AjaxDatatable):
change_form_template = 'admin/hadisowerview_change_form.html'
form = HadisOverviewForm
ordering = ['hadis__number']
list_display = ['hadis', 'status', 'created_at']
search_fields = ['hadis__title', 'hadis__number', 'status_text',]
autocomplete_fields = ['hadis', 'tags']
fieldsets = (
(None, {
'fields': ('hadis', 'status', 'status_color', 'status_text')
}),
(_('Reference Information'), {
'fields': ('address', 'share_link',),
}),
(_('Additional Information'), {
'fields': ('links', 'tags', 'created_at'),
'classes': ('collapse',),
}),
)
class HadisOverviewInline(admin.StackedInline):
change_form_template = 'admin/hadisowerview_change_form.html'
form = HadisOverviewForm
model = HadisOverview
autocomplete_fields = ['tags', ]
can_delete = False
verbose_name_plural = _('Hadis Overview')
fieldsets = (
(None, {
'fields': ('status', 'status_color', 'status_text', 'address', 'share_link', 'links', 'tags',),
}),
)
extra = 1
min_num = 1
@admin.register(Hadis)
class HadisAdmin(AjaxDatatable):
# form = HadisForm
list_display = ['number', 'title', 'category', 'status', 'created_at']
list_filter = ['status', 'category']
search_fields = ['title', 'text', 'number']
readonly_fields = ['created_at', 'updated_at']
autocomplete_fields = ['category']
inlines = [HadisOverviewInline]
fieldsets = (
(None, {
'fields': ('number', 'title', 'category', 'status')
}),
(_('Content'), {
'fields': ('text', 'translation'),
'classes': ('collapse',),
}),
)
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
if obj is None:
form.base_fields['category'].widget.can_add_related = False
return form

0
apps/hadis/admin/transmitter.py

6
apps/hadis/apps.py

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

452
apps/hadis/doc.py

@ -0,0 +1,452 @@
"""
Swagger documentation for the Hadis API endpoints.
This module provides Swagger documentation for the Hadis API endpoints using drf-yasg.
It defines the request parameters, response schemas, and decorators for the views.
"""
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from apps.hadis.models import HadisCategory
# Parameter definitions
source_type_param = openapi.Parameter(
'source_type',
openapi.IN_QUERY,
description="Filter categories by source type (shia or sunni)",
type=openapi.TYPE_STRING,
enum=[HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI],
required=False
)
# Response schemas
tag_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'id': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Unique identifier for the tag"
),
'title': openapi.Schema(
type=openapi.TYPE_STRING,
description="Title of the tag"
)
},
required=['id', 'title']
)
category_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'id': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Unique identifier for the category"
),
'name': openapi.Schema(
type=openapi.TYPE_STRING,
description="Name of the category"
),
'hadis_count': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Number of hadis items in this category"
),
'source_type': openapi.Schema(
type=openapi.TYPE_STRING,
enum=[HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI],
description="Source type of the category (shia or sunni)"
),
'category_type': openapi.Schema(
type=openapi.TYPE_STRING,
enum=[HadisCategory.ContentType.QURAN, HadisCategory.ContentType.HADITH],
description="Content type of the category (quran or hadith)",
nullable=True
),
'children': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=openapi.Schema(type=openapi.TYPE_OBJECT), # Recursive reference
description="List of child categories"
)
},
required=['id', 'name', 'hadis_count', 'source_type', 'children']
)
categories_response = openapi.Response(
description="Tree structure of hadis categories",
schema=openapi.Schema(
type=openapi.TYPE_ARRAY,
items=category_schema
)
)
hadis_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'number': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Unique number identifier for the hadis"
),
'title': openapi.Schema(
type=openapi.TYPE_STRING,
description="Title of the hadis"
),
'text': openapi.Schema(
type=openapi.TYPE_STRING,
description="Original text of the hadis"
),
'translation': openapi.Schema(
type=openapi.TYPE_STRING,
description="Translation of the hadis text"
),
'tags': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=tag_schema,
description="List of tags associated with this hadis"
)
},
required=['number', 'title', 'text', 'translation', 'tags']
)
hadis_list_response = openapi.Response(
description="List of hadis items in the specified category",
schema=openapi.Schema(
type=openapi.TYPE_ARRAY,
items=hadis_schema
)
)
# Reference image schema
reference_image_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'id': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Unique identifier for the reference image"
),
'thumbnail': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="ID of the thumbnail image",
nullable=True
),
'priority': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Priority of the image (lower values mean higher priority)"
)
},
required=['id', 'priority']
)
# Hadis reference schema
hadis_reference_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'id': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Unique identifier for the hadis reference"
),
'book': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="ID of the referenced book",
nullable=True
),
'description': openapi.Schema(
type=openapi.TYPE_STRING,
description="Description of the reference",
nullable=True
),
'created_at': openapi.Schema(
type=openapi.TYPE_STRING,
format=openapi.FORMAT_DATETIME,
description="Creation timestamp"
),
'images': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=reference_image_schema,
description="List of reference images"
)
},
required=['id', 'created_at', 'images']
)
# Hadis overview schema
hadis_overview_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'status': openapi.Schema(
type=openapi.TYPE_STRING,
description="Status of the hadis"
),
'status_color': openapi.Schema(
type=openapi.TYPE_STRING,
description="Display color for the status"
),
'status_text': openapi.Schema(
type=openapi.TYPE_STRING,
description="Descriptive text for the status",
nullable=True
),
'address': openapi.Schema(
type=openapi.TYPE_STRING,
description="Address information",
nullable=True
),
'links': openapi.Schema(
type=openapi.TYPE_OBJECT,
description="Related links"
),
'tags': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=tag_schema,
description="List of tags associated with this hadis"
),
'share_link': openapi.Schema(
type=openapi.TYPE_STRING,
description="Link for sharing the hadis",
nullable=True
),
'created_at': openapi.Schema(
type=openapi.TYPE_STRING,
format=openapi.FORMAT_DATETIME,
description="Creation timestamp"
)
},
required=['status', 'status_color', 'tags', 'created_at']
)
# Hadis detail schema
hadis_detail_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'id': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Unique identifier for the hadis"
),
'number': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Unique number identifier for the hadis"
),
'title': openapi.Schema(
type=openapi.TYPE_STRING,
description="Title of the hadis"
),
'text': openapi.Schema(
type=openapi.TYPE_STRING,
description="Original text of the hadis"
),
'translation': openapi.Schema(
type=openapi.TYPE_STRING,
description="Translation of the hadis text"
),
'status': openapi.Schema(
type=openapi.TYPE_BOOLEAN,
description="Visibility status of the hadis"
),
'created_at': openapi.Schema(
type=openapi.TYPE_STRING,
format=openapi.FORMAT_DATETIME,
description="Creation timestamp"
),
'updated_at': openapi.Schema(
type=openapi.TYPE_STRING,
format=openapi.FORMAT_DATETIME,
description="Last update timestamp"
),
'overview': hadis_overview_schema,
'first_reference': hadis_reference_schema
},
required=['id', 'number', 'title', 'text', 'translation', 'status', 'created_at', 'updated_at', 'overview']
)
hadis_detail_response = openapi.Response(
description="Detailed information about a specific hadis",
schema=hadis_detail_schema
)
# Transmitter schema
transmitter_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'id': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Unique identifier for the transmitter"
),
'full_name': openapi.Schema(
type=openapi.TYPE_STRING,
description="Full name of the transmitter"
),
'birth_year_hijri': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Birth year in Hijri calendar"
),
'death_year_hijri': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Death year in Hijri calendar"
),
'description': openapi.Schema(
type=openapi.TYPE_STRING,
description="Description of the transmitter",
nullable=True
),
'status': openapi.Schema(
type=openapi.TYPE_STRING,
description="Status of the transmitter"
),
'status_color': openapi.Schema(
type=openapi.TYPE_STRING,
description="Display color for the status"
),
'thumbnail': openapi.Schema(
type=openapi.TYPE_OBJECT,
description="Thumbnail image information",
nullable=True
)
},
required=['id', 'full_name', 'birth_year_hijri', 'death_year_hijri', 'status', 'status_color']
)
# Hadis transmitter schema
hadis_transmitter_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'id': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Unique identifier for the hadis transmitter relation"
),
'transmitter': transmitter_schema,
'description': openapi.Schema(
type=openapi.TYPE_STRING,
description="Description of the transmitter's role in this hadis",
nullable=True
),
'order': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Order in the chain of transmission"
),
'created_at': openapi.Schema(
type=openapi.TYPE_STRING,
format=openapi.FORMAT_DATETIME,
description="Creation timestamp"
)
},
required=['id', 'transmitter', 'order', 'created_at']
)
# Update hadis detail schema to include transmitters
hadis_detail_schema = openapi.Schema(
type=openapi.TYPE_OBJECT,
properties={
'id': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Unique identifier for the hadis"
),
'number': openapi.Schema(
type=openapi.TYPE_INTEGER,
description="Unique number identifier for the hadis"
),
'title': openapi.Schema(
type=openapi.TYPE_STRING,
description="Title of the hadis"
),
'text': openapi.Schema(
type=openapi.TYPE_STRING,
description="Original text of the hadis"
),
'translation': openapi.Schema(
type=openapi.TYPE_STRING,
description="Translation of the hadis text"
),
'status': openapi.Schema(
type=openapi.TYPE_BOOLEAN,
description="Visibility status of the hadis"
),
'created_at': openapi.Schema(
type=openapi.TYPE_STRING,
format=openapi.FORMAT_DATETIME,
description="Creation timestamp"
),
'updated_at': openapi.Schema(
type=openapi.TYPE_STRING,
format=openapi.FORMAT_DATETIME,
description="Last update timestamp"
),
'overview': hadis_overview_schema,
'first_reference': hadis_reference_schema,
'transmitters': openapi.Schema(
type=openapi.TYPE_ARRAY,
items=hadis_transmitter_schema,
description="List of transmitters for this hadis"
)
},
required=['id', 'number', 'title', 'text', 'translation', 'status', 'created_at', 'updated_at', 'overview']
)
hadis_detail_response = openapi.Response(
description="Detailed information about a specific hadis",
schema=hadis_detail_schema
)
# Swagger decorators for views
category_list_swagger = swagger_auto_schema(
operation_id="list_hadis_categories",
operation_description="""
Retrieve a hierarchical tree structure of hadis categories.
This endpoint returns all hadis categories in a tree structure, with parent categories
containing their child categories. Each category includes its ID, name, source type,
category type, and the count of hadis items it contains.
The response can be filtered by source type (shia or sunni) using the query parameter.
If no source type is specified, all categories are returned.
""",
operation_summary="List Hadis Categories",
tags=["Hadis"],
manual_parameters=[source_type_param],
responses={
200: categories_response,
401: "Authentication credentials were not provided or are invalid.",
500: "Internal server error occurred."
}
)
category_hadis_list_swagger = swagger_auto_schema(
operation_id="list_hadis_in_category",
operation_description="""
Retrieve a list of hadis items belonging to a specific category.
This endpoint returns all hadis items that belong to the specified category.
Each hadis item includes its number, title, original text, translation, and associated tags.
The category is specified by its ID in the URL path.
""",
operation_summary="List Hadis Items in Category",
tags=["Hadis"],
responses={
200: hadis_list_response,
401: "Authentication credentials were not provided or are invalid.",
404: "The specified category does not exist.",
500: "Internal server error occurred."
}
)
hadis_detail_swagger = swagger_auto_schema(
operation_id="get_hadis_detail",
operation_description="""
Retrieve detailed information about a specific hadis.
This endpoint returns comprehensive information about a hadis, including:
- Basic hadis details (number, title, text, translation)
- HadisOverview information (status, tags, etc.)
- The first HadisReference with its ReferenceImages
- List of Transmitters in order of transmission chain
The hadis is specified by its ID in the URL path.
""",
operation_summary="Get Hadis Detail",
tags=["Hadis"],
responses={
200: hadis_detail_response,
401: "Authentication credentials were not provided or are invalid.",
404: "The specified hadis does not exist.",
500: "Internal server error occurred."
}
)

141
apps/hadis/migrations/0001_initial.py

@ -0,0 +1,141 @@
# Generated by Django 5.1.8 on 2025-04-03 00:05
import django.db.models.deletion
import filer.fields.image
import mptt.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('library', '0001_initial'),
migrations.swappable_dependency(settings.FILER_IMAGE_MODEL),
]
operations = [
migrations.CreateModel(
name='HadisTag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=355, verbose_name='title')),
('status', models.BooleanField(default=True, verbose_name='status')),
],
),
migrations.CreateModel(
name='HadisCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_active', models.BooleanField(default=True, verbose_name='is active')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('source_type', models.CharField(blank=True, choices=[('shia', 'Shia'), ('sunni', 'Sunni')], default='shia', max_length=10, verbose_name='Source Type')),
('category_type', models.CharField(blank=True, choices=[('quran', 'Quran'), ('hadith', 'Hadith')], max_length=10, null=True, verbose_name='Category Content Type')),
('name', models.CharField(max_length=355, verbose_name='name')),
('order', models.IntegerField(default=0, verbose_name='order')),
('lft', models.PositiveIntegerField(editable=False)),
('rght', models.PositiveIntegerField(editable=False)),
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('level', models.PositiveIntegerField(editable=False)),
('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='hadis.hadiscategory')),
],
options={
'verbose_name': 'Hadis Category',
'verbose_name_plural': 'Hadis Categories',
'ordering': ('order',),
},
),
migrations.CreateModel(
name='Hadis',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('number', models.PositiveIntegerField(unique=True, verbose_name='number')),
('title', models.CharField(max_length=355, verbose_name='title')),
('text', models.TextField(verbose_name='text')),
('translation', models.TextField(blank=True, default='', verbose_name='translation')),
('status', models.BooleanField(default=True, verbose_name='visibility')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')),
('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='hadis.hadiscategory', verbose_name='category')),
],
options={
'verbose_name': 'hadis',
'verbose_name_plural': 'hadises',
},
),
migrations.CreateModel(
name='HadisReference',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.TextField(blank=True, null=True, verbose_name='description')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('book', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hadis_references', to='library.book', verbose_name='book')),
('hadis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='references', to='hadis.hadis', verbose_name='hadis')),
],
options={
'verbose_name': 'Hadis Reference',
'verbose_name_plural': 'Hadis References',
'unique_together': {('hadis', 'book')},
},
),
migrations.CreateModel(
name='ReferenceImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('priority', models.IntegerField(default=0, help_text='Priority of the image, lower values mean higher priority.', verbose_name='Priority')),
('reference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hadis.hadisreference', verbose_name='Hadis Reference')),
('thumbnail', filer.fields.image.FilerImageField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.FILER_IMAGE_MODEL, verbose_name='thumbnail')),
],
options={
'verbose_name': 'Reference Image',
'verbose_name_plural': 'Reference Images',
},
),
migrations.CreateModel(
name='Transmitters',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('full_name', models.CharField(max_length=255)),
('birth_year_hijri', models.IntegerField(verbose_name='Birth Year (Hijri)')),
('death_year_hijri', models.IntegerField(verbose_name='Death Year (Hijri)')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('status', models.CharField(max_length=50, verbose_name='status')),
('status_color', models.CharField(max_length=25, verbose_name='Display Status Color')),
('thumbnail', filer.fields.image.FilerImageField(blank=True, help_text='image allowed', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.FILER_IMAGE_MODEL)),
],
),
migrations.CreateModel(
name='HadisOverview',
fields=[
('hadis', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='hadis.hadis')),
('status', models.CharField(max_length=50, verbose_name='status')),
('status_color', models.CharField(max_length=25, verbose_name='Display Status Color')),
('status_text', models.TextField(blank=True, null=True, verbose_name='Status Text')),
('address', models.TextField(blank=True, null=True, verbose_name='address')),
('links', models.JSONField(blank=True, default=dict, null=True, verbose_name='title')),
('share_link', models.CharField(blank=True, max_length=255, null=True, verbose_name='share link')),
('explanation', models.TextField(blank=True, null=True, verbose_name='explanation')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('tags', models.ManyToManyField(blank=True, related_name='hadises', to='hadis.hadistag', verbose_name='tags')),
],
),
migrations.CreateModel(
name='HadisTransmitter',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.TextField(blank=True, null=True, verbose_name='description')),
('order', models.PositiveIntegerField(default=0, help_text='Order in the chain of transmission', verbose_name='Order')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')),
('hadis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transmitters', to='hadis.hadis', verbose_name='hadis')),
('transmitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hadises', to='hadis.transmitters', verbose_name='transmitter')),
],
options={
'verbose_name': 'Hadis Transmitter',
'verbose_name_plural': 'Hadis Transmitters',
'ordering': ('hadis', 'order'),
'unique_together': {('hadis', 'transmitter', 'order')},
},
),
]

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

Loading…
Cancel
Save