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