commit
e013c4fd24
278 changed files with 24851 additions and 0 deletions
-
419.gitignore
-
32Dockerfile
-
58Dockerfile.prod
-
35Jenkinsfile
-
0README.md
-
125all_packages.txt
-
0apps/account/__init__.py
-
66apps/account/admin/__init__.py
-
12apps/account/admin/notification.py
-
125apps/account/admin/professor.py
-
76apps/account/admin/student.py
-
398apps/account/admin/user.py
-
7apps/account/apps.py
-
28apps/account/custom_user_login.py
-
835apps/account/doc.py
-
0apps/account/management/__init__.py
-
0apps/account/management/commands/__init__.py
-
52apps/account/management/commands/create_groups.py
-
77apps/account/manager.py
-
151apps/account/migrations/0001_initial.py
-
20apps/account/migrations/0002_alter_user_phone_number.py
-
0apps/account/migrations/__init__.py
-
3apps/account/models/__init__.py
-
95apps/account/models/groups.py
-
25apps/account/models/notification.py
-
125apps/account/models/user.py
-
12apps/account/permissions.py
-
2apps/account/serializers/__init__.py
-
26apps/account/serializers/notification.py
-
143apps/account/serializers/user.py
-
61apps/account/tasks.py
-
40apps/account/templates/account/group_help_text.html
-
800apps/account/templates/account/json_editor_field.html
-
33apps/account/templates/account/user_list_section.html
-
38apps/account/urls.py
-
4apps/account/views/__init__.py
-
105apps/account/views/notification.py
-
372apps/account/views/user.py
-
0apps/api/__init__.py
-
75apps/api/admin.py
-
6apps/api/apps.py
-
3apps/api/models.py
-
3apps/api/tests.py
-
10apps/api/urls.py
-
42apps/api/views.py
-
0apps/certificate/__init__.py
-
44apps/certificate/admin.py
-
6apps/certificate/apps.py
-
31apps/certificate/migrations/0001_initial.py
-
18apps/certificate/migrations/0002_alter_certificate_certificate_file.py
-
0apps/certificate/migrations/__init__.py
-
28apps/certificate/models.py
-
50apps/certificate/serializers.py
-
3apps/certificate/tests.py
-
11apps/certificate/urls.py
-
24apps/certificate/views.py
-
0apps/chat/__init__.py
-
59apps/chat/admin.py
-
6apps/chat/apps.py
-
62apps/chat/migrations/0001_initial.py
-
0apps/chat/migrations/__init__.py
-
121apps/chat/models.py
-
3apps/chat/tests.py
-
3apps/chat/views.py
-
0apps/course/__init__.py
-
3apps/course/admin/__init__.py
-
480apps/course/admin/course.py
-
111apps/course/admin/lesson.py
-
33apps/course/admin/participant.py
-
9apps/course/apps.py
-
42apps/course/data/category.json
-
430apps/course/doc.py
-
135apps/course/migrations/0001_initial.py
-
18apps/course/migrations/0002_alter_course_thumbnail.py
-
34apps/course/migrations/0003_alter_course_is_online_alter_course_timing_and_more.py
-
0apps/course/migrations/__init__.py
-
3apps/course/models/__init__.py
-
174apps/course/models/course.py
-
106apps/course/models/lesson.py
-
24apps/course/models/participant.py
-
3apps/course/serializers/__init__.py
-
247apps/course/serializers/course.py
-
57apps/course/serializers/lesson.py
-
17apps/course/serializers/participant.py
-
19apps/course/signals.py
-
29apps/course/templates/course/add_student_form.html
-
3apps/course/tests.py
-
25apps/course/urls.py
-
3apps/course/views/__init__.py
-
220apps/course/views/course.py
-
144apps/course/views/lesson.py
-
61apps/course/views/participant.py
-
0apps/hadis/__init__.py
-
3apps/hadis/admin/__init__.py
-
222apps/hadis/admin/category.py
-
161apps/hadis/admin/hadis.py
-
0apps/hadis/admin/transmitter.py
-
6apps/hadis/apps.py
-
452apps/hadis/doc.py
-
141apps/hadis/migrations/0001_initial.py
@ -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.* |
|||
@ -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"] |
|||
@ -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"] |
|||
@ -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,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,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 |
|||
@ -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',] |
|||
@ -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 |
|||
@ -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 |
|||
@ -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) |
|||
@ -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' |
|||
@ -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 |
|||
@ -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,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.")) |
|||
@ -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") |
|||
@ -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')), |
|||
], |
|||
), |
|||
] |
|||
@ -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,0 +1,3 @@ |
|||
from .user import * |
|||
from .groups import * |
|||
from .notification import * |
|||
@ -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" |
|||
@ -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 |
|||
|
|||
@ -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) |
|||
@ -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 |
|||
@ -0,0 +1,2 @@ |
|||
from .user import * |
|||
from .notification import * |
|||
@ -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) |
|||
@ -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 |
|||
|
|||
@ -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) |
|||
|
|||
|
|||
@ -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> |
|||
@ -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> |
|||
|
|||
@ -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> |
|||
@ -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'), |
|||
|
|||
] |
|||
@ -0,0 +1,4 @@ |
|||
from .user import * |
|||
from .notification import * |
|||
|
|||
|
|||
@ -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) |
|||
|
|||
|
|||
|
|||
|
|||
@ -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,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) |
|||
@ -0,0 +1,6 @@ |
|||
from django.apps import AppConfig |
|||
|
|||
|
|||
class ApiConfig(AppConfig): |
|||
default_auto_field = 'django.db.models.BigAutoField' |
|||
name = 'apps.api' |
|||
@ -0,0 +1,3 @@ |
|||
from django.db import models |
|||
|
|||
# Create your models here. |
|||
@ -0,0 +1,3 @@ |
|||
from django.test import TestCase |
|||
|
|||
# Create your tests here. |
|||
@ -0,0 +1,10 @@ |
|||
|
|||
from django.urls import path |
|||
from .views import HomeView, CountryView |
|||
|
|||
|
|||
|
|||
urlpatterns = [ |
|||
path('', HomeView.as_view()), |
|||
path('countries/', CountryView.as_view()), |
|||
] |
|||
@ -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,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) |
|||
@ -0,0 +1,6 @@ |
|||
from django.apps import AppConfig |
|||
|
|||
|
|||
class CertificateConfig(AppConfig): |
|||
default_auto_field = 'django.db.models.BigAutoField' |
|||
name = 'apps.certificate' |
|||
@ -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')), |
|||
], |
|||
), |
|||
] |
|||
@ -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,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}" |
|||
|
|||
|
|||
@ -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) |
|||
|
|||
|
|||
|
|||
@ -0,0 +1,3 @@ |
|||
from django.test import TestCase |
|||
|
|||
# Create your tests here. |
|||
@ -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'), |
|||
] |
|||
@ -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,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') |
|||
}), |
|||
) |
|||
@ -0,0 +1,6 @@ |
|||
from django.apps import AppConfig |
|||
|
|||
|
|||
class ChatConfig(AppConfig): |
|||
default_auto_field = 'django.db.models.BigAutoField' |
|||
name = 'apps.chat' |
|||
@ -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,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}" |
|||
|
|||
@ -0,0 +1,3 @@ |
|||
from django.test import TestCase |
|||
|
|||
# Create your tests here. |
|||
@ -0,0 +1,3 @@ |
|||
from django.shortcuts import render |
|||
|
|||
# Create your views here. |
|||
@ -0,0 +1,3 @@ |
|||
from .course import * |
|||
from .lesson import * |
|||
from .participant import * |
|||
@ -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) |
|||
@ -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) |
|||
@ -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 |
|||
|
|||
@ -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 |
|||
@ -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" |
|||
} |
|||
] |
|||
@ -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 |
|||
} |
|||
] |
|||
``` |
|||
""" |
|||
@ -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')}, |
|||
}, |
|||
), |
|||
] |
|||
@ -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'), |
|||
), |
|||
] |
|||
@ -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,0 +1,3 @@ |
|||
from .course import * |
|||
from .lesson import * |
|||
from .participant import * |
|||
@ -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" |
|||
@ -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" |
|||
|
|||
|
|||
@ -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') |
|||
@ -0,0 +1,3 @@ |
|||
from .course import * |
|||
from .lesson import * |
|||
from .participant import * |
|||
@ -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'] |
|||
@ -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 |
|||
@ -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'] |
|||
@ -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 |
|||
) |
|||
|
|||
|
|||
@ -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 %} |
|||
@ -0,0 +1,3 @@ |
|||
from django.test import TestCase |
|||
|
|||
# Create your tests here. |
|||
@ -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'), |
|||
|
|||
] |
|||
@ -0,0 +1,3 @@ |
|||
from .course import * |
|||
from .lesson import * |
|||
from .participant import * |
|||
@ -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) |
|||
|
|||
|
|||
|
|||
@ -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) |
|||
@ -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,0 +1,3 @@ |
|||
from .category import * |
|||
from .hadis import * |
|||
from .transmitter import * |
|||
@ -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) |
|||
@ -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,0 +1,6 @@ |
|||
from django.apps import AppConfig |
|||
|
|||
|
|||
class HadisConfig(AppConfig): |
|||
default_auto_field = 'django.db.models.BigAutoField' |
|||
name = 'apps.hadis' |
|||
@ -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." |
|||
} |
|||
) |
|||
@ -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
Write
Preview
Loading…
Cancel
Save
Reference in new issue