commit e013c4fd2451f0363d08c632179037e8d653cd63 Author: alireza Date: Fri Apr 4 06:30:39 2025 +0330 redesign panel admin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24c8e96 --- /dev/null +++ b/.gitignore @@ -0,0 +1,419 @@ + settings.json +# migrations/ +.DS_Store +local-cdn/ +# .env-dev +# .env-prod +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +static/ +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +# In the name of Allah +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# dotenv +.env + +# virtualenv +.venv +venv/ +ENV/ +.vscode +.idea + +*.mp4 +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +.DS_Store +*.sqlite3 +media/ +*.pyc +*.db +*.pid + +# Ignore Django Migrations in Development if you are working on team + +#Only for Development only +#**/migrations/** +#!**/migrations/__init__.py + +#comment migrations ignorance bcz we need it to be exist + + +#server gitignore +passenger_wsgi.py +.htaccess +static/uploads/ +static/quran_audios +tmp/ +Pipfile.lock +quran-pages-audios/*.zip +quran.sql +tafsir.sql +output_file.sql + +src +calendar.json +apps/mafatih/data/mafatih_indonesia/*.json +apps/mafatih/data/mafatih_indonesia/1 +apps/mafatih/data/Germany Duas/*.xlsx +!apps/mafatih/data/mafatih_indonesia/final_jun_11.json +volumes/ + +apps/mafatih/data/*.json +apps/ahkam/data/*.json +!apps/ahkam/data/makarem_fa_data.json + +mediafiles/* +wabot/ +Sabeel Media Content/ + + +*.lock +*.toml +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +# # .env +# .venv +# # env/ +# venv/ +# ENV/ +# env.bak/ +# venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +# .env +# .env.development.local +# .env.test.local +# .env.production.local +# .env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp +.cache + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f0b0c6b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# pull official base image +FROM python:3.9 + +# set work directory +WORKDIR /usr/src/app + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +RUN apt-get update +# RUN apt-get install -y vim +# RUN apt-get install -y ffmpeg +# RUN apt-get install -y cron +# install dependencies +RUN pip install --upgrade pip + +COPY ./requirements.txt . +COPY .env.dev .env + +RUN --mount=type=cache,target=/root/.cache pip install -r requirements.txt + +# copy entrypoint.sh +COPY ./entrypoint.sh . +RUN sed -i 's/\r$//g' /usr/src/app/entrypoint.sh +RUN chmod +x /usr/src/app/entrypoint.sh + +# copy project +COPY . . + +# run entrypoint.sh +# ENTRYPOINT ["/usr/src/app/entrypoint.sh"] diff --git a/Dockerfile.prod b/Dockerfile.prod new file mode 100644 index 0000000..f814ce5 --- /dev/null +++ b/Dockerfile.prod @@ -0,0 +1,58 @@ +# pull official base image +FROM python:3.9-alpine + +# set work directory +WORKDIR /usr/src/app + +# set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# install psycopg2 dependencies +RUN apk update && apk add --no-cache \ + git \ + wget \ + unzip \ + curl \ + postgresql-dev \ + gcc \ + python3-dev \ + musl-dev \ + jpeg-dev \ + zlib-dev \ + freetype-dev \ + gnupg \ + chromium \ + chromium-chromedriver \ + harfbuzz \ + nss \ + freetype \ + ttf-freefont \ + mesa-gl \ + alsa-lib + + +# Set environment variables for Chrome +ENV CHROME_BIN=/usr/bin/chromium-browser +ENV CHROME_DRIVER=/usr/bin/chromedriver + +# install dependencies +RUN pip install --upgrade pip +#RUN python -m pip install Pillow + +COPY ./requirements.txt . +COPY .env.prod .env + +RUN --mount=type=cache,target=/root/.cache pip install -r requirements.txt + +# copy entrypoint.sh +COPY ./entrypoint.sh . +RUN sed -i 's/\r$//g' /usr/src/app/entrypoint.sh +RUN chmod +x /usr/src/app/entrypoint.sh + +# copy project +COPY . . +# Set display port to avoid crash +ENV DISPLAY=:99 +# run entrypoint.sh +ENTRYPOINT ["/usr/src/app/entrypoint.sh"] \ No newline at end of file diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..c046c2b --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,35 @@ +pipeline { + environment { + develop_server_ip = '' + develop_server_name = '' + production_server_ip = "88.99.212.243" + production_server_name = "newhorizon_germany_001_server" + project_path = "/projects/imam-javad/imam-javad_backend" + version = "master" + gitBranch = "origin/master" + } + agent any + stages { + stage('deploy'){ + steps{ + script{ + if(gitBranch=="origin/master"){ + withCredentials([usernamePassword(credentialsId: production_server_name, usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD')]) { + sh 'sshpass -p $PASSWORD ssh -p 1782 $USERNAME@$production_server_ip -o StrictHostKeyChecking=no "cd $project_path && ./runner.sh"' + + def lastCommit = sh(script: 'git log -1 --pretty=format:"%h - %s (%an)"', returnStdout: true).trim() + sh """ + curl -F chat_id=1457670318 \ + -F message_thread_id=6 \ + -F document=@/var/jenkins_home/jobs/${env.JOB_NAME}/builds/${env.BUILD_NUMBER}/log \ + -F caption='Project name: #${env.JOB_NAME} \nBuild status is ${currentBuild.currentResult} \nBuild url: ${BUILD_URL} \nLast Commit: ${lastCommit}' \ + https://api.telegram.org/bot7207581748:AAFeymryw7S44D86LYfWqYK-tSNeV3TOwBs/sendDocument + """ + } + } + } + } + } + } +} + diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/all_packages.txt b/all_packages.txt new file mode 100644 index 0000000..5f6dc96 --- /dev/null +++ b/all_packages.txt @@ -0,0 +1,125 @@ +ajaxdatatable @ https://git.habibapp.com/NewHorizon/ajax-datatable.git/archive/master.zip#sha256=ad9c98bbaeae07fb23c7b4a1c97b67d9a891b9ba5742c10f84b2c6edac18e098 +amqp==5.2.0 +asgiref==3.8.1 +async-timeout==4.0.3 +attrs==23.2.0 +Babel==2.15.0 +beautifulsoup4==4.12.3 +billiard==3.6.4.0 +cachetools==5.5.2 +celery==5.2.1 +certifi==2024.2.2 +cffi==1.16.0 +chardet==5.2.0 +charset-normalizer==3.3.2 +click==8.1.7 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +colorama==0.4.6 +conditional==2.0 +cssselect2==0.8.0 +Deprecated==1.2.18 +diff-match-patch==20230430 +dj_category @ https://git.habibapp.com/NewHorizon/django-category.git/archive/master.zip#sha256=9c20b673cdc03ac3fce2d259a2b3d744940cc723496bf9de63a153c18cc9317a +dj_filer @ https://git.habibapp.com/NewHorizon/django-filer.git/archive/master.zip#sha256=7972b3dc6c8187493fdb1e4c37d467704e3894e5472f0fde2b6accad0615435f +dj_language @ https://git.habibapp.com/NewHorizon/django-language.git/archive/master.zip#sha256=da2e1d6a6bb620281a71e042ccf4c361a48f44363e392f8f8b2d157223f41ffa +Django==5.1.8 +django-ajax-datatable==4.5.0 +django-allauth==65.3.0 +django-autoslug==1.9.9 +django-clone==5.3.3 +django-cors-headers==4.3.1 +django-countries==7.2.1 +django-crispy-forms==1.11.0 +django-debug-toolbar==4.3.0 +django-dynamic-preferences==1.16.0 +django-environ==0.11.2 +django-filer==3.3.1 +django-filter==2.4.0 +django-import-export==4.0.3 +django-js-asset==1.2.2 +django-money==3.5.2 +django-mptt==0.16.0 +django-multiselectfield==0.1.12 +django-parler==2.2 +django-paypal==1.1.2 +django-phonenumber-field==5.2.0 +django-polymorphic==3.0.0 +django-recaptcha==4.1.0 +django-redis==5.4.0 +django-reset-migrations==0.4.0 +django-rosetta==0.9.6 +django-unfold==0.54.0 +djangorestframework==3.16.0 +drf-yasg==1.21.10 +easy-thumbnails==2.10 +exceptiongroup==1.2.1 +geographiclib==2.0 +geopy==2.3.0 +guardian==0.2.3 +gunicorn==22.0.0 +h11==0.14.0 +idna==3.7 +inflection==0.5.1 +Jinja2==3.1.6 +kombu==5.3.7 +limitless_dashboard @ https://git.habibapp.com/NewHorizon/django-limitless-dashboard.git/archive/master.zip#sha256=0c9748ef1938f4d49e9fc4b6613a729f2724340510c4955cebff48556ae84ca0 +lxml==5.3.1 +markdown-it-py==3.0.0 +MarkupSafe==3.0.2 +mdurl==0.1.2 +nwh_seo @ https://git.habibapp.com/NewHorizon/django-seo.git/archive/master.zip#sha256=cd3fbb70df2e3eaa3e26bbbf0191f7d220c3a4bef0c24f6986fe10d615fb53b0 +oauthlib==3.1.0 +outcome==1.3.0.post0 +packaging==24.0 +paypal==1.2.5 +persisting-theory==1.0 +phonenumbers==8.13.37 +pillow==11.0.0 +polib==1.2.0 +prompt_toolkit==3.0.45 +psycopg2-binary==2.9.9 +py-moneyed==3.0 +pycparser==2.22 +Pygments==2.15.0 +PyJWT==2.0.1 +PySocks==1.7.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +python-slugify==8.0.1 +pytz==2025.2 +PyYAML==6.0.2 +redis==4.3.4 +reportlab==4.2.5 +requests==2.32.1 +requests-oauthlib==1.3.0 +rich==13.7.0 +ruamel.yaml==0.18.6 +ruamel.yaml.clib==0.2.12 +selenium==4.21.0 +sentry-sdk==1.6.0 +six==1.16.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +soupsieve==2.5 +sqlparse==0.5.0 +svglib==1.5.1 +tablib==3.5.0 +text-unidecode==1.3 +tinycss2==1.4.0 +trio==0.25.1 +trio-websocket==0.11.1 +typing_extensions==4.13.0 +tzdata==2024.1 +unicode-slugify==0.1.3 +Unidecode==1.1.2 +uritemplate==4.1.1 +urllib3==2.2.1 +vine==5.1.0 +wcwidth==0.2.13 +webdriver-manager==4.0.1 +webencodings==0.5.1 +whitenoise==6.9.0 +wrapt==1.16.0 +wsproto==1.2.0 diff --git a/apps/account/__init__.py b/apps/account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/admin/__init__.py b/apps/account/admin/__init__.py new file mode 100644 index 0000000..edd4d68 --- /dev/null +++ b/apps/account/admin/__init__.py @@ -0,0 +1,66 @@ +from unfold.components import BaseComponent, register_component +from django.template.loader import render_to_string + +from .user import * +from .professor import * +from .student import * + + +@register_component +class AllUserComponent(BaseComponent): + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context["children"] = render_to_string( + "admin/helpers/kpi_progress.html", + { + "total": User.objects.filter(is_active=True).count(), + }, + ) + return context + +@register_component +class GuestUserComponent(BaseComponent): + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context["children"] = render_to_string( + "admin/helpers/kpi_progress.html", + { + "total": User.objects.filter(email__isnull=True).count(), + }, + ) + return context + +@register_component +class ProfessorUserComponent(BaseComponent): + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + professor_count = User.objects.filter(groups__name="Professor Group").count() + context["children"] = render_to_string( + "admin/helpers/kpi_progress.html", + { + "total": professor_count + }, + ) + return context + + + +@register_component +class StudentUserComponent(BaseComponent): + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + student_count = User.objects.filter( + models.Q(groups__name="Student Group") | + models.Q(user_type=User.UserType.STUDENT) + ).distinct().count() + + context["children"] = render_to_string( + "admin/helpers/kpi_progress.html", + { + "total": student_count, + }, + ) + return context \ No newline at end of file diff --git a/apps/account/admin/notification.py b/apps/account/admin/notification.py new file mode 100644 index 0000000..b17d676 --- /dev/null +++ b/apps/account/admin/notification.py @@ -0,0 +1,12 @@ +from ajaxdatatable.admin import AjaxDatatable + +from apps.account.models import User, Notification + +@admin.register(Notification) +class NotificationAdmin(AjaxDatatable): + list_display = ('title', 'user', 'is_read', 'created_at') + list_filter = ('is_read', 'created_at') + search_fields = ('title', 'message', 'user__fullname') + list_editable = ('is_read',) + ordering = ('-created_at',) + autocomplete_fields = ['user',] \ No newline at end of file diff --git a/apps/account/admin/professor.py b/apps/account/admin/professor.py new file mode 100644 index 0000000..1f81d3b --- /dev/null +++ b/apps/account/admin/professor.py @@ -0,0 +1,125 @@ +# This file is no longer used. All admin classes are now in user.pyfrom django.contrib import admin +from django.contrib.auth.forms import UserChangeForm, UsernameField, UserCreationForm +from django.contrib.auth.admin import UserAdmin +from django.utils.translation import gettext_lazy as _ +from rest_framework.authtoken.models import TokenProxy +from ajaxdatatable.admin import AjaxDatatable + +from django.contrib import admin +from apps.account.models import User +from django import forms +from django.urls import path, reverse +from django.shortcuts import render, redirect +from django.contrib import messages +from django.contrib.auth.models import Group +from phonenumber_field.formfields import PhoneNumberField + +from apps.account.models import ProfessorUser + + +class ProfessorUserCreationForm(UserCreationForm): + phone_number = PhoneNumberField( + help_text="Enter the phone number in international format. Example: +989012023212", + required=False + ) + + class Meta: + model = ProfessorUser + fields = ('fullname', 'email', 'phone_number') + + +@admin.register(ProfessorUser) +class ProfessorUserAdmin(UserAdmin, AjaxDatatable): + add_form = ProfessorUserCreationForm + list_display = ( + 'email', 'fullname', 'last_login', 'date_joined', + ) + ordering = 'last_login', + readonly_fields = ('date_joined',) + exclude = ('password', 'user_permissions') + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('fullname', 'email', 'phone_number',), + }), + ('other', { + 'classes': ('wide',), + 'fields': ('avatar', 'info', 'skill'), + }), + ('Password', { + 'classes': ('wide',), + 'fields': ('password1', 'password2'), + }), + + ) + search_fields = ( + 'email', 'fullname', + ) + fieldsets = ( + (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar', 'info', 'skill')}), + (_('Permissions'), { + 'fields': ('is_active', 'groups', 'password'), + }), + (_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}), + ) + + def save_model(self, request, obj, form, change): + if not change: # Creating a new professor + # Check if a user with this email already exists + email = form.cleaned_data.get('email') + existing_user = User.objects.filter(email=email).first() + + if existing_user: + # If user exists and is already a professor, show error + if existing_user.user_type == User.UserType.PROFESSOR: + messages.error(request, f"A professor with the email {email} already exists.") + return + + # If user exists but is not a professor, convert them to professor + existing_user.user_type = User.UserType.PROFESSOR + + # Update user fields from form data + existing_user.fullname = form.cleaned_data.get('fullname') + existing_user.phone_number = form.cleaned_data.get('phone_number') + existing_user.avatar = form.cleaned_data.get('avatar') + existing_user.info = form.cleaned_data.get('info') + existing_user.skill = form.cleaned_data.get('skill') + + # Set password if provided + if 'password1' in form.cleaned_data and form.cleaned_data['password1']: + existing_user.set_password(form.cleaned_data['password1']) + + # Save the user + existing_user.save() + + # Add to professor group + professor_group, _ = Group.objects.get_or_create(name="Professor Group") + existing_user.groups.add(professor_group) + + # Show success message + messages.success(request, f"The user with email {email} has been converted to a professor.") + + # Set obj to None to prevent further processing + obj = None + return + else: + # New user, set password + obj.set_password(form.cleaned_data['password1']) + + if obj: # Only proceed if obj is not None + obj.user_type = User.UserType.PROFESSOR + super().save_model(request, obj, form, change) + + @admin.display(description='Phone Number') + def _phone_number(self, obj): + return obj.phone_number + + + def get_readonly_fields(self, request, obj=None): + """ + Restrict the ability to modify groups to superusers only. + """ + readonly = list(self.readonly_fields) + if not request.user.is_superuser: + readonly.append('groups') + return readonly \ No newline at end of file diff --git a/apps/account/admin/student.py b/apps/account/admin/student.py new file mode 100644 index 0000000..38aeddb --- /dev/null +++ b/apps/account/admin/student.py @@ -0,0 +1,76 @@ +from django.contrib import admin +from django.contrib.auth.forms import UserChangeForm, UsernameField +from django.contrib.auth.admin import UserAdmin +from django.utils.translation import gettext_lazy as _ +from rest_framework.authtoken.models import TokenProxy +from ajaxdatatable.admin import AjaxDatatable + +from django.contrib import admin +from apps.account.models import User +from django import forms +from django.contrib import admin +from django.urls import path, reverse +from django.shortcuts import render, redirect +from django.contrib import messages + +from apps.account.models import StudentUser, User + + + +@admin.register(StudentUser) +class StudentUserAdmin(UserAdmin, AjaxDatatable): + list_display = ( + 'device_id', 'email', 'fullname', 'user_type','last_login', 'date_joined', + ) + ordering = 'last_login', + readonly_fields = ('date_joined',) + exclude = ('password', 'user_permissions') + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('fullname', 'email', 'phone_number',), + }), + ('other', { + 'classes': ('wide',), + 'fields': ('avatar', 'info'), + }), + ('Password', { + 'classes': ('wide',), + 'fields': ('password1', 'password2'), + }), + ) + search_fields = ( + 'email', 'fullname', 'username', + ) + fieldsets = ( + (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}), + (_('Permissions'), { + 'fields': ('is_active', 'groups',), + }), + (_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}), + ) + @admin.display(description='Phone Number') + def _phone_number(self, obj): + return obj.phone_number + + + def get_queryset(self, request): + # محدود کردن نمایش فقط دانش‌آموزان + qs = super().get_queryset(request) + return qs.filter(user_type=User.UserType.STUDENT) + + + def save_model(self, request, obj, form, change): + if not change: + obj.set_password(form.cleaned_data['password1']) + obj.user_type = User.UserType.STUDENT + super().save_model(request, obj, form, change) + + + def has_add_permission(self, request): + if '_popup' in request.GET and request.GET['_popup'] == '1': # بررسی وجود _popup در پارامترهای GET + return True + return False + + def has_delete_permission(self, request, obj=None): + return False \ No newline at end of file diff --git a/apps/account/admin/user.py b/apps/account/admin/user.py new file mode 100644 index 0000000..d4c8b2f --- /dev/null +++ b/apps/account/admin/user.py @@ -0,0 +1,398 @@ +from django.contrib import admin +from django.contrib.auth.forms import UserChangeForm, UsernameField +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.contrib.auth.admin import GroupAdmin as BaseGroupAdmin + +from django.utils.translation import gettext_lazy as _ +from rest_framework.authtoken.models import TokenProxy +from ajaxdatatable.admin import AjaxDatatable + +from apps.account.models import User, Notification +from django import forms +from django.contrib import admin +from django.urls import path, reverse +from django.shortcuts import render, redirect +from django.contrib import messages + +from apps.account.models import ClientUser, AdminUser, StudentUser, ProfessorUser +from phonenumber_field.formfields import PhoneNumberField +from utils.admin import project_admin_site +from unfold.forms import AdminPasswordChangeForm, UserChangeForm, UserCreationForm +from unfold.admin import ModelAdmin, StackedInline, TabularInline +from django.contrib.auth.models import Group +from django.db import models +from unfold.contrib.forms.widgets import WysiwygWidget +from unfold.decorators import action, display +from unfold.sections import TableSection +from unfold.contrib.filters.admin import ( + AutocompleteSelectMultipleFilter, + ChoicesDropdownFilter, + MultipleRelatedDropdownFilter, + RangeDateFilter, + RangeDateTimeFilter, + RangeNumericFilter, + SingleNumericFilter, + TextFilter, +) + + + +class UserAdmin(BaseUserAdmin, ModelAdmin): + form = UserChangeForm + add_form = UserCreationForm + change_password_form = AdminPasswordChangeForm + compressed_fields = False + list_before_template = "account/user_list_section.html" + list_display = ( + 'fullname', 'email', 'is_active', 'display_date_joined', + ) + ordering = ("-id",) + search_fields = ( + 'email', 'fullname', 'username', + ) + list_filter = [ + "is_active", + "is_staff", + ("last_login", RangeDateTimeFilter), + ("date_joined", RangeDateTimeFilter), + ] + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ( ('fullname', 'email'), 'phone_number', 'birthdate', 'gender','avatar', 'skill', 'info'), + }), + (_('Location'), { + 'fields': ('city', 'country'), + 'classes': ('collapse',), + }), + (_('Password'), { + 'fields': ('password1', 'password2'), + 'classes': ('collapse',), + }), + (_('Permissions'), { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups'), + 'classes': ('collapse',), + }), + ) + fieldsets = ( + (None, {"fields": ("email", "fullname")}), + ( + _("Basic Information"), + { + "fields": ("gender", "avatar", "phone_number", "birthdate", 'info', 'skill', "password"), + "classes": ["tab"], + }, + ), + ( + _('Country & City'), { + 'fields': ('city', 'country'), + "classes": ["tab"], + } + ), + ( + _('Device Information'), { + 'fields': ('device_id', 'device_os', 'fcm', 'language', ), + "classes": ["tab"], + } + ), + ( + _('Permissions'), { + 'fields': ('user_type', 'is_active', 'is_staff', 'groups'), + "classes": ["tab"], + } + ), + ( + _('Important dates'), { + 'fields': ('last_login', 'date_joined', 'deleted_at'), + "classes": ["tab"], + } + ), + ) + formfield_overrides = { + models.TextField: { + "widget": WysiwygWidget, + } + } + radio_fields = { + "gender": admin.HORIZONTAL, + } + readonly_fields = ["last_login", "date_joined", 'user_type', ] + + + @display(description=_("Date Joined")) + def display_date_joined(self, instance: User): + return instance.date_joined.strftime("%Y-%m-%d %H:%M") if instance.date_joined else "-" + + @display(description=_("Last Login")) + def display_last_login(self, instance: User): + return instance.last_login.strftime("%Y-%m-%d %H:%M") if instance.last_login else "-" + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.filter(email__isnull=False) + +class GuestUserAdmin(UserAdmin): + list_display = ( + 'device_id', 'device_os', 'is_active', 'display_date_joined', + ) + + def has_add_permission(self, request): + if '_popup' in request.GET and request.GET['_popup'] == '1': + return True + return False + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.filter(email__isnull=True) + + @display(description=_("Date Joined")) + def display_date_joined(self, instance: User): + return instance.date_joined.strftime("%Y-%m-%d %H:%M") if instance.date_joined else "-" + + + + + + + +class StudentUserAdmin(UserAdmin): + list_display = ( + 'display_header', 'email', 'gender', 'display_age', 'courses_count' + ) + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': (('fullname', 'email'), 'phone_number', 'avatar', 'birthdate', 'gender'), + }), + (_('Location'), { + 'fields': (('city', 'country'),), + 'classes': ('collapse',), + }), + (_('password'), { + 'fields': ('password1', 'password2',), + 'classes': ('collapse',), + }), + ) + + + @display(description=_("Student"), header=True) + def display_header(self, instance: StudentUser): + from django.templatetags.static import static + + # Get avatar image path - use user's avatar if available, otherwise use default + avatar_path = instance.avatar.url if instance.avatar else static("images/reading(1).png") + + return [ + instance.fullname, + None, + None, + { + "path": avatar_path, + "height": 30, + "width": 36, + "borderless": True, + # "squared": True, + }, + ] + + @display(description=_("Age")) + def display_age(self, instance: StudentUser): + from django.utils.html import format_html + from datetime import date + + if not instance.birthdate: + return "-" + + today = date.today() + birthdate = instance.birthdate + age = today.year - birthdate.year - ((today.month, today.day) < (birthdate.month, birthdate.day)) + + formatted_date = birthdate.strftime("%Y-%m-%d") + + return format_html( + '{}', + 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( + """ +
+ {} + + visibility + +
+ """, + 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( + '' + 'visibility' + '', + 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( + """ +
+ {} + + visibility + +
+ """, + 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) diff --git a/apps/account/apps.py b/apps/account/apps.py new file mode 100644 index 0000000..1a13bc5 --- /dev/null +++ b/apps/account/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class AccountConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.account' + icon = 'mi-person' \ No newline at end of file diff --git a/apps/account/custom_user_login.py b/apps/account/custom_user_login.py new file mode 100644 index 0000000..8b64339 --- /dev/null +++ b/apps/account/custom_user_login.py @@ -0,0 +1,28 @@ +from django.contrib.auth.backends import BaseBackend +from django.db.models import Q + +from apps.account.models import User +from utils.exceptions import UserNotFoundException +from rest_framework.exceptions import AuthenticationFailed + + +class CustomLoginBackend(BaseBackend): + """ + Authenticate with username email and phone_number. + """ + + def authenticate(self, request, username=None, password=None): + if user := self.get_user(username): + if user.check_password(password): + return user + + return None + + def get_user(self, username): + try: + if isinstance(username, int): + return User.objects.filter(id=int(username)).first() + return User.objects.filter(Q(email=username) | Q(phone_number=str(username))).first() + + except User.DoesNotExist: + return None diff --git a/apps/account/doc.py b/apps/account/doc.py new file mode 100644 index 0000000..be654ad --- /dev/null +++ b/apps/account/doc.py @@ -0,0 +1,835 @@ +def doc_reset(): + return """ +# 🐈 Scenario +🛠️ تنظیم مجدد رمز عبور + +کاربر پس از تأیید کد بازیابی رمز عبور، می‌تواند رمز عبور جدید خود را تنظیم کند. برای این کار، کاربر باید رمز عبور جدید و تأیید آن را وارد کند. + +بعد از ریکاور و وریفای +به این صفحه برای ریست میآید +که باید با همان توکنی که در وریفای دریافت کرده است را درخواست کند + +(نکته بعد از ریست پسورد توکن ذخیره شده حذف شود و کاربر باید با رمز عبور جدیدی که ست کرده است مجددا لاگین را انجام دهد) +--- + +## 🚀 درخواست API + +### URL: +``` +POST /api/reset-password/ +``` + +### Header: +| کلید | مقدار | +|---------------|---------------------------------| +| Content-Type | application/json | +| Authorization | Bearer <توکن احراز هویت> | + +### Body: +```json +{ + "password": "newstrongpassword", + "password_confirmation": "newstrongpassword" +} +``` + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `200` | موفقیت‌آمیز - رمز عبور با موفقیت تغییر یافت. | +| `400` | درخواست نادرست - مشکلات مربوط به داده‌های ارسالی. | +| `401` | عدم احراز هویت - کاربر وارد نشده است یا توکن نامعتبر است. | +| `500` | مشکل موقتی در سرور. | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +{ + "message": "Your password has been changed successfully." +} +``` + +--- + +## 📄 نمونه پاسخ خطا + +### رمز عبور و تأیید رمز عبور برابر نیستند: +```json +{ + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "Passwords do not match." +} +``` + +### رمز عبور کوتاه‌تر از 8 کاراکتر است: +```json +{ + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "Password must be at least 8 characters long." +} +``` + +### عدم احراز هویت: +```json +{ + "status": "error", + "code": "unauthorized", + "status_code": 401, + "message": "Authentication credentials were not provided or are invalid." +} +``` + +### مشکل موقتی در سرور: +```json +{ + "status": "error", + "code": "service_unavailable", + "status_code": 500, + "message": "Service temporarily unavailable." +} +``` + +--- + +## 💡 نکات مهم: +1. **رمز عبور جدید:** + - باید حداقل 8 کاراکتر باشد و تأیید رمز عبور (`password_confirmation`) باید با رمز عبور اصلی یکسان باشد. +2. **امنیت:** + - کاربر باید توکن احراز هویت معتبر برای تنظیم مجدد رمز عبور ارائه دهد. +3. **توکن احراز هویت:** + - فقط کاربران احراز هویت شده می‌توانند رمز عبور خود را تغییر دهند. + +--- + +## 🔧 توضیحات فنی: + +### فرآیند تنظیم مجدد رمز عبور: +1. کاربر باید ابتدا کد بازیابی رمز عبور را تأیید کند. +2. پس از تأیید موفقیت‌آمیز، کاربر با استفاده از توکن احراز هویت، رمز عبور جدید و تأیید آن را وارد می‌کند. +3. اگر داده‌ها معتبر باشند، رمز عبور جدید برای کاربر تنظیم می‌شود. +4. اگر داده‌ها نادرست باشند، پیام خطای مناسب به کاربر بازگردانده می‌شود. + +### ولیدیشن‌ها: +- **رمز عبور:** + - بررسی می‌شود که رمز عبور حداقل 8 کاراکتر باشد. + - بررسی می‌شود که رمز عبور و تأیید آن یکسان باشند. + +--- + +## 📄 نمونه درخواست: + +### درخواست کامل: +```json +{ + "password": "mynewpassword", + "password_confirmation": "mynewpassword" +} +``` + +### پاسخ موفق: +```json +{ + "message": "Your password has been changed successfully." +} +``` +""" + + + +def doc_recover(): + return """ +# 🐈 Scenario +🛠️ بازیابی رمز عبور + +کاربر با وارد کردن ایمیل خود، درخواست بازیابی رمز عبور می‌دهد. +یک کد تأیید به ایمیل کاربر ارسال می‌شود تا کاربر بتواند رمز عبور خود را بازیابی کند. +سپس کاربر باید به صفحه وریفای ریدایرکت شود +و بعد از تایید وریفای با توکن داده شده +به صفحه ریست پسورد ریدایرکت میشود تا پسور جدیدی را ست کند + +--- + +## 🚀 درخواست API + +### URL: +``` +POST /api/recover-password/ +``` + +### Header: +| کلید | مقدار | +|---------------|---------------------------------| +| Content-Type | application/json | +| Authorization | Optional (برای این endpoint نیاز نیست) | + +### Body: +```json +{ + "email": "johndoe@example.com" +} +``` + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `202` | موفقیت‌آمیز - کد بازیابی رمز عبور به ایمیل کاربر ارسال شد. | +| `400` | درخواست نادرست - مشکلات مربوط به داده‌های ارسالی. | +| `404` | کاربر یافت نشد. | +| `500` | مشکل موقتی در سرور. | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +{ + "id": 1, + "fullname": "John Doe", + "phone_number": "1234567890", + "email": "johndoe@example.com", + "avatar": null, + "message": "Forgot password code sent" +} +``` + +--- + +## 📄 نمونه پاسخ خطا + +### کاربر یافت نشد: +```json +{ + "status": "error", + "code": "not_found", + "status_code": 404, + "message": "User not found." +} +``` + +### مشکل موقتی در سرور: +```json +{ + "status": "error", + "code": "service_unavailable", + "status_code": 500, + "message": "Service temporarily unavailable." +} +``` + +--- + +## 💡 نکات مهم: +1. **کد بازیابی رمز عبور:** + - کد تأیید به ایمیل کاربر ارسال می‌شود و باید در مرحله بعدی برای بازیابی رمز عبور استفاده شود. +2. **امنیت:** + - کد بازیابی رمز عبور فقط برای مدت محدود اعتبار دارد و بعد از آن منقضی می‌شود. + +--- + +## 🔧 توضیحات فنی: + +### فرآیند بازیابی رمز عبور: +1. کاربر ایمیل خود را وارد می‌کند. +2. سیستم بررسی می‌کند که آیا کاربری با این ایمیل وجود دارد یا خیر. +3. اگر کاربر یافت شود، یک کد تأیید بازیابی رمز عبور به ایمیل کاربر ارسال می‌شود. +4. کاربر باید این کد را در مرحله بعدی برای تنظیم رمز عبور جدید وارد کند. + +### ولیدیشن‌ها: +- **ایمیل:** + - بررسی می‌شود که ایمیل وارد شده معتبر باشد. + - اگر کاربری با این ایمیل یافت نشود، پیام خطای مناسب برگردانده می‌شود. + +--- + +## 📄 نمونه درخواست: + +### درخواست کامل: +```json +{ + "email": "janedoe@example.com" +} +``` + +### پاسخ موفق: +```json +{ + "id": 2, + "fullname": "Jane Doe", + "phone_number": "0987654321", + "email": "janedoe@example.com", + "avatar": null, + "message": "Forgot password code sent" +} +``` +""" + + + +def doc_login(): + return """ +# 🐈 Scenario +🛠️ ورود به حساب کاربری + +کاربر با وارد کردن ایمیل و رمز عبور خود به سیستم وارد می‌شود. اگر اعتبارنامه‌ها معتبر باشند، توکن احراز هویت برای دسترسی به دیگر بخش‌های سیستم بازگردانده می‌شود. + +--- + +## 🚀 درخواست API + +### URL: +``` +POST /api/login/ +``` + +### Header: +| کلید | مقدار | +|---------------|---------------------------------| +| Content-Type | application/json | +| Authorization | Optional (برای این endpoint نیاز نیست) | + +### Body: +```json +{ + "email": "johndoe@example.com", + "password": "strongpassword", + "fcm": "fcm_token_optional", + "device_id": "device_id_optional" +} +``` + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `201` | موفقیت‌آمیز - کاربر با موفقیت وارد شد و توکن احراز هویت بازگردانده شد. | +| `400` | درخواست نادرست - مشکلات مربوط به داده‌های ارسالی. | +| `404` | کاربر یافت نشد. | +| `500` | مشکل موقتی در سرور. | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +{ + "id": 1, + "fullname": "John Doe", + "email": "johndoe@example.com", + "token": "abc123def456", + "avatar": "https://example.com/avatar.jpg" +} +``` + +--- + +## 📄 نمونه پاسخ خطا + +### ورود ناموفق (اطلاعات اشتباه): +```json +{ + "status": "error", + "code": "invalid_credentials", + "status_code": 400, + "message": "Unable to log in with provided credentials." +} +``` + +### کاربر یافت نشد: +```json +{ + "status": "error", + "code": "not_found", + "status_code": 404, + "message": "User not found." +} +``` + +### مشکل موقتی در سرور: +```json +{ + "status": "error", + "code": "service_unavailable", + "status_code": 500, + "message": "Service temporarily unavailable." +} +``` + +--- + +## 💡 نکات مهم: +1. **رمز عبور:** + - رمز عبور باید صحیح و مطابق با آنچه کاربر هنگام ثبت‌نام ارائه کرده است، باشد. +2. **توکن احراز هویت:** + - پس از ورود موفقیت‌آمیز، توکن احراز هویت به کاربر بازگردانده می‌شود که برای دسترسی به دیگر بخش‌های سیستم نیاز است. +3. **اطلاعات دستگاه:** + - `fcm` و `device_id` به عنوان اطلاعات اختیاری برای شناسایی دستگاه ارسال می‌شوند. + +--- + +## 🔧 توضیحات فنی: + +### فرآیند ورود به حساب کاربری: +1. کاربر ایمیل و رمز عبور خود را وارد می‌کند. +2. سیستم سعی می‌کند کاربر را با استفاده از اعتبارنامه‌های ارائه شده احراز هویت کند. +3. اگر کاربر یافت شود و اعتبارنامه‌ها صحیح باشند، یک توکن احراز هویت ایجاد شده و به کاربر بازگردانده می‌شود. +4. اگر اعتبارنامه نادرست باشند، پیام خطا برگردانده می‌شود. + +### ولیدیشن‌ها: +- **ایمیل و رمز عبور:** + - بررسی می‌شود که ایمیل و رمز عبور وارد شده معتبر باشند. + - اگر کاربر با این ایمیل و رمز عبور یافت نشود، پیام خطای مناسب برگردانده می‌شود. + +--- + +## 📄 نمونه درخواست: + +### درخواست کامل: +```json +{ + "email": "janedoe@example.com", + "password": "mypassword", + "fcm": "fcm_token_example", + "device_id": "device_id_example" +} +``` + +### پاسخ موفق: +```json +{ + "id": 2, + "fullname": "Jane Doe", + "email": "janedoe@example.com", + "token": "xyz987uvw654", + "avatar": null +} +``` +""" + + +def doc_verify(): + return """ +# 🐈 Scenario +📅️ تأیید حساب کاربری با کد تأیید + +کاربر پس از ثبت‌نام، باید با استفاده از کد تأییدی که به ایمیل او ارسال شده است، +حساب کاربری خود را تأیید کند. در این مرحله، کاربر ایمیل و کد تأیید خود را ارسال می‌کند. + +--- + +## 🚀 درخواست API + +### URL: +``` +POST /api/verify/ +``` + +### Header: +| کلید | مقدار | +|---------------|---------------------------------| +| Content-Type | application/json | +| Authorization | Optional (برای این endpoint نیاز نیست) | + +### Body: +```json +{ + "email": "johndoe@example.com", + "code": "12345" +} +``` + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `201` | موفقیت‌آمیز - کاربر تأیید شد و توکن احراز هویت بازگردانده شد. | +| `400` | درخواست نادرست - مشکلات مربوط به داده‌های ارسالی. | +| `404` | کاربر یا کد تأیید یافت نشد. | +| `410` | کد تأیید منقضی شده است. | +| `500` | مشکل موقتی در سرور. | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +{ + "token": "abc123def456", + "user_id": 1, + "phone_number": "1234567890", + "email": "johndoe@example.com", + "fullname": "John Doe", + "avatar": null +} +``` + +--- + +## 📄 نمونه پاسخ خطا + +### کد تأیید نادرست: +```json +{ + "status": "error", + "code": "invalid_verification_code", + "status_code": 400, + "message": "The verification code is invalid." +} +``` + +### کد تأیید منقضی شده است: +```json +{ + "status": "error", + "code": "expired_code", + "status_code": 410, + "message": "The verification code has expired." +} +``` + +### کاربر یا کد تأیید یافت نشد: +```json +{ + "status": "error", + "code": "not_found", + "status_code": 404, + "message": "Verification data not found or expired." +} +``` + +### مشکل موقتی در سرور: +```json +{ + "status": "error", + "code": "service_unavailable", + "status_code": 500, + "message": "Service temporarily unavailable." +} +``` + +--- + +## 💡 نکات مهم: +1. **کد تأیید:** + - کد تأیید باید دقیقاً با کدی که به ایمیل کاربر ارسال شده مطابقت داشته باشد. + - کد تأیید فقط برای یک مدت محدود اعتبار دارد. +2. **خطاها:** + - اگر کد تأیید نادرست باشد، پیام مناسب بازگردانده می‌شود. + - اگر کد تأیید منقضی شده باشد، کاربر باید درخواست کد جدید کند. +3. **توکن احراز هویت:** + - پس از تأیید موفقیت‌آمیز، توکن احراز هویت به کاربر بازگردانده می‌شود که برای دسترسی به دیگر بخش‌های سیستم نیاز است. + +--- + +### ولیدیشن‌ها: +- **کد تأیید:** + - باید حداکثر 5 کاراکتر باشد. + - اگر کد معتبر نباشد یا منقضی شده باشد، پیام خطای مناسب برگردانده می‌شود. + +--- + +## 📄 نمونه درخواست: + +### درخواست کامل: +```json +{ + "email": "janedoe@example.com", + "code": "67890" +} +``` + +### پاسخ موفق: +```json +{ + "token": "xyz987uvw654", + "user_id": 2, + "phone_number": "0987654321", + "email": "janedoe@example.com", + "fullname": "Jane Doe", + "avatar": null +} +``` +""" + + +def doc_register(): + return """ +# 🐈 Scenario +ثبت نام کاربر + +کاربر با وارد کردن اطلاعات مورد نیاز شامل نام کامل، ایمیل، رمز عبور و تأیید رمز عبور درخواست ثبت‌نام ارسال می‌کند. پس از ثبت موفق، یک کد تأیید به ایمیل ارسال می‌شود که برای تکمیل ثبت‌نام مورد نیاز است. + +--- + +## 🚀 درخواست API + +### URL: +``` +POST /api/register/ +``` + +### Header: +| کلید | مقدار | +|---------------|---------------------------------| +| Content-Type | application/json | +| Authorization | Optional (برای این endpoint نیاز نیست) | + +### Body: +```json +{ + "fullname": "John Doe", + "email": "johndoe@example.com", + "password": "strongpassword", + "password_confirmation": "strongpassword", + "fcm": "fcm_token_optional", + "device_id": "device_id_optional" +} +``` + +--- + +## 📊 پاسخ‌ها + +| کد وضعیت | توضیحات | +|---------------|-----------------------------------------------------------| +| `202` | موفقیت‌آمیز - کد تأیید به ایمیل کاربر ارسال شد. | +| `400` | درخواست نادرست - مشکلات مربوط به داده‌های ارسالی. | +| `409` | ایمیل قبلاً ثبت شده است. | +| `404` | کاربر یا منبع یافت نشد. | +| `410` | کد تأیید منقضی شده است. | +| `500` | مشکل موقتی در سرور. | + +--- + +## 📄 نمونه پاسخ موفقیت‌آمیز + +```json +{ + "user": { + "id": 1, + "fullname": "John Doe", + "email": "johndoe@example.com" + }, + "message": "The otp code was sent to the user's email" +} +``` + +--- + +## 📄 نمونه پاسخ خطا + +### ایمیل تکراری: +```json +{ + "status": "error", + "code": "validation_error", + "status_code": 409, + "message": "There were validation errors.", + "errors": [ + { + "field": "email", + "message": "This email is already registered." + } + ] +} +``` + +### رمز عبور و تأیید رمز عبور برابر نیستند: +```json +{ + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "There were validation errors.", + "errors": [ + { + "field": "password_confirmation", + "message": "Passwords do not match." + } + ] +} +``` + +### رمز عبور کوتاه‌تر از 8 کاراکتر است: +```json +{ + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "There were validation errors.", + "errors": [ + { + "field": "password", + "message": "Password must be at least 8 characters long." + } + ] +} +``` + +### درخواست نامعتبر (فیلدهای اجباری): +```json +{ + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "There were validation errors.", + "errors": [ + { + "field": "fullname", + "message": "This field is required." + }, + { + "field": "email", + "message": "This field is required." + }, + { + "field": "password", + "message": "This field is required." + }, + { + "field": "password_confirmation", + "message": "This field is required." + } + ] +} +``` + +### کاربر یافت نشد: +```json +{ + "status": "error", + "code": "not_found", + "status_code": 404, + "message": "The requested resource was not found." +} +``` + +### کد تأیید منقضی شده است: +```json +{ + "status": "error", + "code": "expired_code", + "status_code": 410, + "message": "The verification code has expired." +} +``` + +### مشکل موقتی در سرور: +```json +{ + "status": "error", + "code": "service_unavailable", + "status_code": 500, + "message": "Service temporarily unavailable." +} +``` + +--- + +## 💡 نکات مهم: +1. **رمز عبور:** + - باید حداقل 8 کاراکتر باشد. + - رمز عبور و تأیید رمز عبور (`password_confirmation`) باید یکسان باشند. +2. **ایمیل:** + - باید یک آدرس ایمیل معتبر باشد. + - ایمیل‌های تکراری مجاز نیستند. +3. **کد OTP:** + - کد تأیید به ایمیل ارسال می‌شود و برای وریفای کاربر استفاده می‌شود. +4. **فیلدهای اختیاری:** + - `fcm` و `device_id` در صورت نیاز می‌توانند ارسال شوند اما اجباری نیستند. + +--- + +### ولیدیشن‌ها: +- **ایمیل:** + - بررسی می‌شود که در سیستم موجود نباشد. + - اگر موجود باشد، پیام خطای زیر برگردانده می‌شود: + ```json + { + "status": "error", + "code": "validation_error", + "status_code": 409, + "message": "There were validation errors.", + "errors": [ + { + "field": "email", + "message": "This email is already registered." + } + ] + } + ``` +- **رمز عبور:** + - بررسی می‌شود که حداقل 8 کاراکتر باشد: + ```json + { + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "There were validation errors.", + "errors": [ + { + "field": "password", + "message": "Password must be at least 8 characters long." + } + ] + } + ``` + - بررسی می‌شود که با `password_confirmation` یکسان باشد: + ```json + { + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "There were validation errors.", + "errors": [ + { + "field": "password_confirmation", + "message": "Passwords do not match." + } + ] + } + ``` + +--- + +## 📄 نمونه درخواست: + +### درخواست کامل: +```json +{ + "fullname": "Jane Doe", + "email": "janedoe@example.com", + "password": "securepassword", + "password_confirmation": "securepassword", + "fcm": "fcm_token_example", + "device_id": "device_id_example" +} +``` + +### پاسخ موفق: +```json +{ + "user": { + "id": 2, + "fullname": "Jane Doe", + "email": "janedoe@example.com" + }, + "message": "The otp code was sent to the user's email" +} +``` +""" diff --git a/apps/account/management/__init__.py b/apps/account/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/management/commands/__init__.py b/apps/account/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/management/commands/create_groups.py b/apps/account/management/commands/create_groups.py new file mode 100644 index 0000000..2bec3c9 --- /dev/null +++ b/apps/account/management/commands/create_groups.py @@ -0,0 +1,52 @@ +from django.core.management.base import BaseCommand +from django.contrib.auth.models import Group, Permission +from django.contrib.contenttypes.models import ContentType + +from apps.account.models import User + + + + +class Command(BaseCommand): + help = 'Create default groups and assign permissions to them' + + def handle(self, *args, **kwargs): + # تعریف گروه‌ها و پرمیشن‌ها + groups_permissions = { + "Professor Group": [ + "view_user", "add_user", "change_user" + ], + "Client Group": [ + "view_user" + ], + "Admin Group": [ + "view_user", "add_user", "change_user", "delete_user" + ], + "Super Admin Group": [ + "view_user", "add_user", "change_user", "delete_user", "manage_permissions" + ], + "Student Group": [ + "view_user" + ] + } + + content_type = ContentType.objects.get_for_model(User) + + for group_name, permissions in groups_permissions.items(): + group, created = Group.objects.get_or_create(name=group_name) + if created: + self.stdout.write(self.style.SUCCESS(f"Group '{group_name}' created successfully.")) + else: + self.stdout.write(self.style.WARNING(f"Group '{group_name}' already exists.")) + + for perm_codename in permissions: + permission, created = Permission.objects.get_or_create( + codename=perm_codename, + defaults={ + 'name': f"Can {perm_codename.replace('_', ' ')} User", + 'content_type': content_type + } + ) + group.permissions.add(permission) + + self.stdout.write(self.style.SUCCESS("All groups and permissions have been created successfully.")) diff --git a/apps/account/manager.py b/apps/account/manager.py new file mode 100644 index 0000000..87acc88 --- /dev/null +++ b/apps/account/manager.py @@ -0,0 +1,77 @@ + +from django.contrib.auth.models import BaseUserManager +from django.contrib.auth.models import Group + + +from django.db.models import Manager + + + +class UserManager(BaseUserManager): + + def create_user( + self, + email: str = None, + # fullname: str = None, + password: str = None, + **extra_fields + ): + email = UserManager.normalize_email(email) + user = self.model( + email=email, + # fullname=fullname, + **extra_fields + ) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email, password, **extra_fields): + user = self.create_user( + email=email, + password=password, + **extra_fields + ) + user.is_admin = True + user.is_staff = True + user.is_superuser = True + user.is_active = True + user.user_type="super_admin" + user.save(using=self._db) + return user + + + def change_user_type(self, user, new_user_type): + group_name = f"{new_user_type.capitalize()} Group" + if user.user_type != new_user_type and not user.groups.filter(name=group_name).exists(): + + user.user_type = new_user_type + new_group, _ = Group.objects.get_or_create(name=group_name) + user.groups.add(new_group) + user.save() + return user + return None + + + +class ProfessorUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="professor") + + +class ClientUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="client") + +class AdminUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="admin") + + +class SuperAdminUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="super_admin") + +class StudentUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="student") diff --git a/apps/account/migrations/0001_initial.py b/apps/account/migrations/0001_initial.py new file mode 100644 index 0000000..1c35550 --- /dev/null +++ b/apps/account/migrations/0001_initial.py @@ -0,0 +1,151 @@ +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import dj_language.field +import django.db.models.deletion +import phonenumber_field.modelfields +import utils.validators +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ('dj_language', '0002_auto_20220120_1344'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(blank=True, max_length=150, null=True, unique=True)), + ('email', models.EmailField(blank=True, help_text="Enter the user's email address.", max_length=254, null=True, unique=True, verbose_name='Email Address')), + ('fullname', models.CharField(blank=True, help_text='Enter the full name of the user.', max_length=255, null=True, verbose_name='Full Name')), + ('birthdate', models.DateField(blank=True, null=True, verbose_name='birthdate')), + ('avatar', models.ImageField(blank=True, null=True, upload_to='users/avatars/%Y/%m/')), + ('phone_number', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, validators=[utils.validators.validate_possible_number], verbose_name='phone')), + ('gender', models.CharField(blank=True, choices=[('male', 'Male'), ('female', 'Female')], help_text="Select the user's gender.", max_length=20, null=True, verbose_name='Gender')), + ('user_type', models.CharField(choices=[('professor', 'Professor'), ('client', 'Client'), ('student', 'Student'), ('admin', 'Admin'), ('super_admin', 'Super Admin')], default='client', help_text='Type of the user.', max_length=20, verbose_name='User Type')), + ('date_joined', models.DateTimeField(auto_now_add=True, help_text='The date and time the user registered.', verbose_name='Date Joined')), + ('city', models.CharField(blank=True, max_length=255, null=True, verbose_name='City')), + ('country', models.CharField(blank=True, max_length=255, null=True, verbose_name='country')), + ('device_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='device id')), + ('device_os', models.CharField(choices=[('android', 'android'), ('apple', 'apple')], max_length=16, null=True)), + ('fcm', models.CharField(blank=True, max_length=512, null=True)), + ('is_staff', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='Active')), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('info', models.TextField(blank=True, null=True, verbose_name='Info')), + ('skill', models.CharField(blank=True, max_length=512, null=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('language', dj_language.field.LanguageField(default=69, limit_choices_to={'status': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dj_language.language', verbose_name='language')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'All Users', + 'verbose_name_plural': 'All Users', + 'ordering': ('-id',), + 'unique_together': {('email', 'device_id')}, + }, + ), + migrations.CreateModel( + name='AdminUser', + fields=[ + ], + options={ + 'verbose_name': 'Admin User', + 'verbose_name_plural': 'Admin Users', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='ClientUser', + fields=[ + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'ordering': ('-id',), + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='ProfessorUser', + fields=[ + ], + options={ + 'verbose_name': 'Professor User', + 'verbose_name_plural': 'Professor Users', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='StudentUser', + fields=[ + ], + options={ + 'verbose_name': 'Student User', + 'verbose_name_plural': 'Student Users', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='SuperAdminUser', + fields=[ + ], + options={ + 'verbose_name': 'Super Admin User', + 'verbose_name_plural': 'Super Admin Users', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='LoginHistory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('lat', models.FloatField(blank=True, null=True, verbose_name='lat')), + ('lon', models.FloatField(blank=True, null=True, verbose_name='lon')), + ('country', models.CharField(blank=True, max_length=255, null=True, verbose_name='country')), + ('city', models.CharField(blank=True, max_length=255, null=True, verbose_name='city')), + ('ip', models.CharField(max_length=255, null=True)), + ('timezone', models.CharField(blank=True, max_length=100, null=True)), + ('at_time', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='login_history', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('message', models.TextField(max_length=512, verbose_name='message')), + ('is_read', models.BooleanField(default=False, verbose_name='is read')), + ('service', models.CharField(choices=[('imam-javad', 'Imam Javad'), ('doboodi', 'Doboodi')], default='imam-javad', max_length=20, verbose_name='service')), + ('created_at', models.DateTimeField(auto_now_add=True, null=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, null=True, verbose_name='updated at')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + ), + ] diff --git a/apps/account/migrations/0002_alter_user_phone_number.py b/apps/account/migrations/0002_alter_user_phone_number.py new file mode 100644 index 0000000..5dba363 --- /dev/null +++ b/apps/account/migrations/0002_alter_user_phone_number.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.8 on 2025-04-04 00:09 + +import phonenumber_field.modelfields +import utils.validators +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='phone_number', + field=phonenumber_field.modelfields.PhoneNumberField(blank=True, help_text='e.g., +49 151 12345678', max_length=128, null=True, region=None, validators=[utils.validators.validate_possible_number], verbose_name='Phone Number'), + ), + ] diff --git a/apps/account/migrations/__init__.py b/apps/account/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/account/models/__init__.py b/apps/account/models/__init__.py new file mode 100644 index 0000000..c9f6b41 --- /dev/null +++ b/apps/account/models/__init__.py @@ -0,0 +1,3 @@ +from .user import * +from .groups import * +from .notification import * \ No newline at end of file diff --git a/apps/account/models/groups.py b/apps/account/models/groups.py new file mode 100644 index 0000000..3831644 --- /dev/null +++ b/apps/account/models/groups.py @@ -0,0 +1,95 @@ +from apps.account.models import User +from apps.account.manager import * + +from django.contrib.auth.models import Group + + + + + +class ProfessorUser(User): + objects = ProfessorUserManager() + + def save(self, *args, **kwargs): + self.user_type = User.UserType.PROFESSOR + super().save(*args, **kwargs) + + group, _ = Group.objects.get_or_create(name="Professor Group") + self.groups.add(group) + + class Meta: + proxy = True + verbose_name = "Professor User" + verbose_name_plural = "Professor Users" + + + + +class ClientUser(User): + objects = ClientUserManager() + + def save(self, *args, **kwargs): + self.user_type = User.UserType.CLIENT + super().save(*args, **kwargs) + + group, _ = Group.objects.get_or_create(name="Client Group") + self.groups.add(group) + + + class Meta: + proxy = True + + verbose_name = 'user' + verbose_name_plural = 'users' + ordering = ('-id',) + + + +class AdminUser(User): + objects = AdminUserManager() + + def save(self, *args, **kwargs): + self.user_type = User.UserType.ADMIN + super().save(*args, **kwargs) + + group, _ = Group.objects.get_or_create(name="Admin Group") + self.groups.add(group) + + class Meta: + proxy = True + verbose_name = "Admin User" + verbose_name_plural = "Admin Users" + + + +class SuperAdminUser(User): + objects = SuperAdminUserManager() + + def save(self, *args, **kwargs): + self.user_type = User.UserType.SUPER_ADMIN + self.is_staff = True + super().save(*args, **kwargs) + + + + class Meta: + proxy = True + verbose_name = "Super Admin User" + verbose_name_plural = "Super Admin Users" + + + +class StudentUser(User): + objects = StudentUserManager() + + def save(self, *args, **kwargs): + self.user_type = User.UserType.STUDENT + super().save(*args, **kwargs) + + group, _ = Group.objects.get_or_create(name="Student Group") + self.groups.add(group) + + class Meta: + proxy = True + verbose_name = "Student User" + verbose_name_plural = "Student Users" \ No newline at end of file diff --git a/apps/account/models/notification.py b/apps/account/models/notification.py new file mode 100644 index 0000000..8286fed --- /dev/null +++ b/apps/account/models/notification.py @@ -0,0 +1,25 @@ +from django.db import models +from django.utils.translation import gettext as _ + + +class Notification(models.Model): + class ServiceChoices(models.TextChoices): + IMAM_JAVAD = 'imam-javad', 'Imam Javad' + DOBOODI = 'doboodi', 'Doboodi' + + title = models.CharField(max_length=255, verbose_name=_('title')) + message = models.TextField(max_length=512, verbose_name=_('message')) + user = models.ForeignKey("account.User", on_delete=models.CASCADE, verbose_name=_('user'), related_name='notifications') + is_read = models.BooleanField(default=False, verbose_name=_('is read')) + service = models.CharField( + max_length=20, + choices=ServiceChoices.choices, + default=ServiceChoices.IMAM_JAVAD, + verbose_name=_('service') + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at'), null=True) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at'), null=True) + + def __str__(self): + return self.title + diff --git a/apps/account/models/user.py b/apps/account/models/user.py new file mode 100644 index 0000000..64d1bae --- /dev/null +++ b/apps/account/models/user.py @@ -0,0 +1,125 @@ +import random +from dj_language.field import LanguageField +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone +from phonenumber_field.modelfields import PhoneNumberField +from utils.validators import validate_possible_number +from apps.account.manager import UserManager + + + +class User(AbstractUser): + class DeviceOs(models.TextChoices): + android = 'android', 'android' + apple = 'apple', 'apple' + + class UserType(models.TextChoices): + PROFESSOR = 'professor', 'Professor' + CLIENT = 'client', 'Client' + STUDENT = 'student', "Student" + ADMIN = 'admin', 'Admin' + SUPER_ADMIN = 'super_admin', 'Super Admin' + + class GenderChoices(models.TextChoices): + MALE = 'male', 'Male' + FEMALE = 'female', 'Female' + + last_name = None + first_name = None + username = models.CharField(unique=True, null=True, blank=True, max_length=150) + email = models.EmailField(unique=True, verbose_name="Email Address", help_text="Enter the user's email address.", null=True, blank=True) + fullname = models.CharField(max_length=255, verbose_name="Full Name", help_text="Enter the full name of the user.", null=True, blank=True) + birthdate = models.DateField(verbose_name=_('birthdate'), null=True, blank=True) + + avatar = models.ImageField(null=True, blank=True, upload_to='users/avatars/%Y/%m/') + phone_number = PhoneNumberField( + validators=[validate_possible_number], + null=True, + blank=True, + verbose_name=_('Phone Number'), + help_text="e.g., +49 151 12345678" + ) + language = LanguageField(null=True) + + gender = models.CharField(max_length=20, choices=GenderChoices.choices, null=True, blank=True, verbose_name=_('Gender'), help_text="Select the user's gender.") + user_type = models.CharField(max_length=20, choices=UserType.choices, default=UserType.CLIENT, verbose_name="User Type", help_text="Type of the user.") + date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Date Joined", help_text="The date and time the user registered.") + + city = models.CharField(verbose_name=_('City'), max_length=255, null=True, blank=True) + country = models.CharField(max_length=255, verbose_name=_('country'), null=True, blank=True) + + device_id = models.CharField(verbose_name=_('device id'), max_length=255, null=True, blank=True) + device_os = models.CharField(choices=DeviceOs.choices, null=True, max_length=16) + + fcm = models.CharField(max_length=512, null=True, blank=True) + is_staff = models.BooleanField(default=False) + is_active = models.BooleanField(default=True, verbose_name="Active", help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.") + deleted_at = models.DateTimeField(null=True, blank=True) + info = models.TextField(verbose_name="Info", null=True, blank=True) + skill = models.CharField(max_length=512, null=True, blank=True) + objects = UserManager() + + + EMAIL_FIELD = "email" + USERNAME_FIELD = "email" + REQUIRED_FIELDS = [] + + + def __str__(self): + username = self.email or self.fullname or self.device_id + return f"{username}-({self.user_type})" + + def soft_delete(self): + self.deleted_at = timezone.now() + self.is_active = False + self.fullname = f'{self.fullname}:deleted' + number = str(random.randint(1000000000, 9999999999)) + self.phone_number = f'{self.phone_number}:deleted{number}' + self.email = f'{self.email}:deleted{number}' if self.email else None + self.save() + + def save(self, *args, **kwargs): + self.username = self.email + if User.objects.filter(username=self.email).count(): + self.username = f'{self.email}:{self.id}' + return super().save(*args, **kwargs) + + def get_full_name(self): + return self.fullname + + @property + def is_guest(self): + return self.email is None + + + @property + def user_type_based_on_groups(self): + if self.groups.filter(name="Student Group").exists(): + return self.UserType.STUDENT + elif self.groups.filter(name="Professor Group").exists(): + return self.UserType.PROFESSOR + else: + return self.UserType.CLIENT + + + class Meta: + ordering = ("-id",) + verbose_name = "All Users" + verbose_name_plural = "All Users" + unique_together = ( + 'email', 'device_id' + ) + + + +class LoginHistory(models.Model): + user = models.ForeignKey("account.User", on_delete=models.CASCADE, related_name='login_history') + lat = models.FloatField(verbose_name=_('lat'), null=True, blank=True) + lon = models.FloatField(verbose_name=_('lon'), null=True, blank=True) + country = models.CharField(max_length=255, verbose_name=_('country'), null=True, blank=True) + city = models.CharField(max_length=255, verbose_name=_('city'), null=True, blank=True) + ip = models.CharField(max_length=255, null=True) + timezone = models.CharField(max_length=100, null=True, blank=True) + at_time = models.DateTimeField(auto_now_add=True) diff --git a/apps/account/permissions.py b/apps/account/permissions.py new file mode 100644 index 0000000..65616b5 --- /dev/null +++ b/apps/account/permissions.py @@ -0,0 +1,12 @@ + + + + + +from rest_framework.permissions import BasePermission + + +class IsActiveUser(BasePermission): + + def has_permission(self, request, view): + return request.user and request.user.is_active \ No newline at end of file diff --git a/apps/account/serializers/__init__.py b/apps/account/serializers/__init__.py new file mode 100644 index 0000000..8c6bf0d --- /dev/null +++ b/apps/account/serializers/__init__.py @@ -0,0 +1,2 @@ +from .user import * +from .notification import * diff --git a/apps/account/serializers/notification.py b/apps/account/serializers/notification.py new file mode 100644 index 0000000..d9823e9 --- /dev/null +++ b/apps/account/serializers/notification.py @@ -0,0 +1,26 @@ + + +from rest_framework import serializers +from apps.account.models import Notification +from apps.account.models import User + + + + +class NotificationSerializer(serializers.ModelSerializer): + user_type = serializers.ChoiceField(choices=[('user', 'User'), ('merchant', 'Merchant')], default='user') + service = serializers.ChoiceField(choices=Notification.ServiceChoices.choices, default=Notification.ServiceChoices.IMAM_JAVAD) + + class Meta: + model = Notification + fields = ['id', 'title', 'message', 'is_read', 'user_type', 'service', 'created_at', 'updated_at'] + + + +class NotificationSendSerializer(serializers.Serializer): + title = serializers.CharField() + body = serializers.CharField() + data = serializers.DictField(required=False) + account_id = serializers.CharField(required=True) + user_type = serializers.CharField(required=True) + service = serializers.ChoiceField(choices=Notification.ServiceChoices.choices, default=Notification.ServiceChoices.IMAM_JAVAD) \ No newline at end of file diff --git a/apps/account/serializers/user.py b/apps/account/serializers/user.py new file mode 100644 index 0000000..91d2954 --- /dev/null +++ b/apps/account/serializers/user.py @@ -0,0 +1,143 @@ + + +from rest_framework import serializers +from rest_framework.authtoken.models import Token +from django.contrib.auth.password_validation import validate_password +from django.utils.translation import gettext_lazy as _ +from apps.account.models import User +from utils import FileFieldSerializer, absolute_url +from utils.validators import validate_type_code + + + +class UserProfileSerializer(serializers.ModelSerializer): + avatar = FileFieldSerializer(required=False) + password = serializers.CharField(write_only=True, required=False, validators=[validate_password]) + fullname = serializers.CharField(required=False) + gender = serializers.ChoiceField( + choices=User.GenderChoices.choices, + required=False, + help_text="Select the user's gender." + ) + fcm = serializers.CharField(required=False, help_text="Firebase Cloud Messaging token.") + class Meta: + model = User + fields = ['id', 'device_id', 'fcm', 'fullname', 'avatar', 'email', 'phone_number', 'password', 'info', 'skill', 'city', 'country', 'birthdate', 'gender'] + read_only_fields = ['email', 'info', 'skill', 'device_id'] + + # def validate_email(self, value): + # if User.objects.filter(email=value).exists(): + # raise serializers.ValidationError("This email is already registered.") + # return value + + def update(self, instance, validated_data): + for attr, value in validated_data.items(): + if value is not None: + setattr(instance, attr, value) + + instance.save() + return instance + + +class UserRegisterSerializer(serializers.ModelSerializer): + fcm = serializers.CharField(required=False) + device_id = serializers.CharField(required=True) + email = serializers.EmailField() + + class Meta: + model = User + fields = ['id','fullname', 'email', 'fcm', 'device_id'] + extra_kwargs = { + 'fullname': {'required': True,}, + 'email': {'required': True,}, + 'device_id': {'required': True,}, + } + + def validate_email(self, value): + if User.objects.filter(email=value).exists(): + raise serializers.ValidationError("This email is already registered.") + return value + + + +class UserVerifySerializer(serializers.Serializer): + code = serializers.CharField(max_length=5, validators=[validate_type_code]) + email = serializers.EmailField() + device_id = serializers.CharField(max_length=255, required=False) + + + +class UserLoginSerializer(serializers.Serializer): + password = serializers.CharField(write_only=True) + token = serializers.CharField(allow_null=True, read_only=True, required=False) + fullname = serializers.CharField(allow_null=True, read_only=True, required=False) + avatar = serializers.CharField(allow_null=True, read_only=True, required=False) + email = serializers.EmailField(write_only=True) + password = serializers.CharField(style={'input_type': 'password'}, trim_whitespace=False) + fcm = serializers.CharField(required=False) + device_id = serializers.CharField(required=False) + timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True) + + def validate(self, data): + # Custom validation logic can be added here if needed + # data.pop('fcm', None) + # data.pop('device_id', None) + return data + +# class UserLoginSerializer(serializers.Serializer): +# password = serializers.CharField(write_only=True) +# token = serializers.CharField(allow_null=True, read_only=True, required=False) +# fullname = serializers.CharField(allow_null=True, read_only=True, required=False) +# avatar = serializers.CharField(allow_null=True, read_only=True, required=False) +# email = serializers.EmailField(write_only=True) +# password = serializers.CharField(style={'input_type': 'password'}, trim_whitespace=False) +# fcm = serializers.CharField(required=False) +# device_id = serializers.CharField(required=False) +# timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True) + + + + +class UserRecoverPasswordSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + + class Meta: + model = User + fields = ['email',] + extra_kwargs = { + 'email': {'required': True,}, + } + + +class UserResetPasswordSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + + class Meta: + model = User + fields = ['password', ] + extra_kwargs = { + 'password': {'required': True,}, + } + + + + +class UserGuestSerializer(serializers.ModelSerializer): + lat = serializers.CharField(max_length=255, allow_null=True, allow_blank=True, required=False) + lon = serializers.CharField(max_length=255, allow_null=True, allow_blank=True, required=False) + fcm = serializers.CharField(required=False) + device_id = serializers.CharField(required=False) + device_os = serializers.ChoiceField(choices=User.DeviceOs.choices, required=False) + timezone = serializers.CharField(required=False, allow_null=True, allow_blank=True) + + class Meta: + model = User + fields = ['device_id', 'fcm', 'device_os', 'lat', 'lon', 'timezone'] + + def validate(self, data): + # Make sure at least device_id is provided + if not data.get('device_id'): + raise serializers.ValidationError({"device_id": "Device ID is required for guest users."}) + + return data + diff --git a/apps/account/tasks.py b/apps/account/tasks.py new file mode 100644 index 0000000..40217de --- /dev/null +++ b/apps/account/tasks.py @@ -0,0 +1,61 @@ +import time +from config.settings import base as settings + +from celery import shared_task +import requests +import json + +@shared_task +def send_otp_code(phone_number, code): + BASE_URL_SERVICE = "https://console.melipayamak.com/api/send/simple/" + + phone_number = str(phone_number) + code = str(code) + print(code) + data = {'from': '50004001410202', 'to': phone_number, 'text': code} + response = requests.post(f'{BASE_URL_SERVICE}{settings.OTP_SERIVCE_KEY}', + json=data) + + print(response.json()) + + +def send_otp_code_whatsapp(phone_number, code): + phone = phone_number + if phone.startswith('0'): + phone = phone[1:] + phone = '98' + phone + + urls = [ + "https://7103.api.greenapi.com/waInstance7103107557/sendMessage/dcc7cc469e274389aa3ea4d6dae9d4d126b8b07a09be41c28e", + "https://7103.api.greenapi.com/waInstance7103109151/sendMessage/ed9cbea884cc49fd8032862f1bceca2074f373540dca483382", + "https://7103.api.greenapi.com/waInstance7103109158/sendMessage/92d032caca1541799a4623cfcc86f449ea7f3205b30848eeab", + "https://7103.api.greenapi.com/waInstance7103109163/sendMessage/d31a08b5816c432daa6e256e181274d1d334e4256d3c4555a7", + + ] + payload = { + "chatId": f"{phone}@c.us", + "message": f"Habib App --aqila-- {code}" + } + headers = { + 'Content-Type': 'application/json' + } + + for url in urls: + response = requests.request("POST", url=url, headers=headers, data=json.dumps(payload)) + response.encoding = 'utf-8' + response_data = response.json() + + invoke_status = response_data.get('invokeStatus', {}) + status = invoke_status.get('status', '') + + print(f'>>>>>>>> {response_data}') + print(f"Response: {status}") + + if status != "QUOTE_ALLOWED": + print("OTP sent successfully.") + break + else: + print("QUOTE_ALLOWED error, trying next URL...") + time.sleep(2) + + diff --git a/apps/account/templates/account/group_help_text.html b/apps/account/templates/account/group_help_text.html new file mode 100644 index 0000000..f077e6d --- /dev/null +++ b/apps/account/templates/account/group_help_text.html @@ -0,0 +1,40 @@ + +{% load unfold i18n %} + +
+ {% trans "Driver before template" %} +
+ +
+ {% 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 %} +
diff --git a/apps/account/templates/account/json_editor_field.html b/apps/account/templates/account/json_editor_field.html new file mode 100644 index 0000000..55532db --- /dev/null +++ b/apps/account/templates/account/json_editor_field.html @@ -0,0 +1,800 @@ +{% load i18n %} +
+ +
+
+ + + + + diff --git a/apps/account/templates/account/user_list_section.html b/apps/account/templates/account/user_list_section.html new file mode 100644 index 0000000..3074961 --- /dev/null +++ b/apps/account/templates/account/user_list_section.html @@ -0,0 +1,33 @@ + +{% load unfold i18n %} + + +
+ {% 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 %} + +
diff --git a/apps/account/urls.py b/apps/account/urls.py new file mode 100644 index 0000000..46ff361 --- /dev/null +++ b/apps/account/urls.py @@ -0,0 +1,38 @@ + +from django.urls import path, include + +from rest_framework.routers import DefaultRouter + +from apps.account import views + + + +urlpatterns = [ + # URL for user registration, accepts POST requests for creating new user instances. + + path('register/', views.UserRegisterView.as_view(), name='user-register'), + path('verify/', views.UserVerifyView.as_view(), name='user-verify'), + path('login/', views.UserLoginView.as_view(), name='user-login'), + path('guest/', views.UserGuestView.as_view(), name='user-guest'), + + + # path('notif/', views.NotificationListView.as_view(), name='user-notif'), + # path('notif/read', views.NotificationReadAllView.as_view(), name='user-notif-read-all'), + + + # # URL to get user details, supports GET for fetching user profile based on the provided token. + path('profile/', views.UserProfileView.as_view(), name='user-profile'), + + path('recover/', views.UserRecoverPassword.as_view(), name='user-recover'), + path('reset/', views.UserResetPassword.as_view(), name='user-reset'), + + path('notif/', views.NotificationListView.as_view(), name='user-notif'), + path('notif/read', views.NotificationReadAllView.as_view(), name='user-notif-read-all'), + + # # URL to update user details, supports PUT to update user fields like phone or email given a token. + path('profile/update/', views.UserUpdateView.as_view(), name='user-update'), + + # # delete user account + path('profile/delete/', views.UserDeleteView.as_view(), name='user-delete'), + +] \ No newline at end of file diff --git a/apps/account/views/__init__.py b/apps/account/views/__init__.py new file mode 100644 index 0000000..8ccf072 --- /dev/null +++ b/apps/account/views/__init__.py @@ -0,0 +1,4 @@ +from .user import * +from .notification import * + + diff --git a/apps/account/views/notification.py b/apps/account/views/notification.py new file mode 100644 index 0000000..d370773 --- /dev/null +++ b/apps/account/views/notification.py @@ -0,0 +1,105 @@ + +from rest_framework import generics, status +from rest_framework.response import Response +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from rest_framework.permissions import IsAuthenticated +from apps.account.serializers import NotificationSerializer, NotificationSendSerializer +from apps.account.models import Notification +# from apps.account.fcm_notification import send_notification + + + +class NotificationListView(generics.ListAPIView): + queryset = Notification.objects.all() + serializer_class = NotificationSerializer + permission_classes = [IsAuthenticated,] + + @swagger_auto_schema( + operation_description="Retrieve a list of notifications for the authenticated user or merchant account.", + tags=['Notifications'], + manual_parameters=[ + openapi.Parameter( + 'service', + openapi.IN_QUERY, + description="Filter notifications by service (imam-javad or doboodi)", + type=openapi.TYPE_STRING, + enum=['imam-javad', 'doboodi'], + required=False + ) + ] + ) + def get(self, request, *args, **kwargs): + """ + This API allows you to retrieve a list of notifications based on the authenticated user's type. + If the user is a regular user, their notifications will be fetched from the `Notification` model. + If the user is a merchant, their notifications will be fetched from the `MerchantAccountNotification` model. + + - **Method**: GET + - **URL**: /api/notifications/ + - **Query Parameters**: + - `service`: Optional. Filter notifications by service ('imam-javad' or 'doboodi') + - **Response**: Includes details of notifications such as title, message, is read status, service, creation date, and update date. + - **Headers**: `Authorization: Bearer ` 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) + + + + diff --git a/apps/account/views/user.py b/apps/account/views/user.py new file mode 100644 index 0000000..0ba8cb8 --- /dev/null +++ b/apps/account/views/user.py @@ -0,0 +1,372 @@ +import logging +import requests +import json +from rest_framework.generics import CreateAPIView, RetrieveUpdateAPIView, GenericAPIView, RetrieveAPIView, UpdateAPIView, ListAPIView +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from django.db.models import Q +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.authtoken.models import Token +from rest_framework.exceptions import AuthenticationFailed +from django.utils.translation import gettext_lazy as _ +from django.shortcuts import get_object_or_404 +from rest_framework.authtoken.models import Token + +from django.utils import timezone +from rest_framework.authentication import TokenAuthentication +from django.contrib.auth import authenticate +from phonenumbers import parse, region_code_for_number +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from rest_framework.exceptions import ValidationError + +from utils.exceptions import InvaliedCodeVrify, ExpiredCodeException, ServiceUnavailableException +from apps.account.models import User +from apps.account.serializers import UserRegisterSerializer, UserProfileSerializer, UserVerifySerializer, UserLoginSerializer, UserRecoverPasswordSerializer, UserResetPasswordSerializer, UserGuestSerializer +from utils.redis import RedisManager +from utils.exceptions import AppAPIException +from utils import send_email, is_valid_email +from config.settings import base as settings +from apps.account.permissions import IsActiveUser +from apps.account.doc import * + +logger = logging.getLogger(__name__) + + + +class UserGuestView(CreateAPIView): + permission_classes = [AllowAny] + serializer_class = UserGuestSerializer + + @swagger_auto_schema( + operation_description="Create a guest user account with device information", + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "device_id": openapi.Schema(type=openapi.TYPE_STRING, default="c9f0c1f4f5cee3d7"), + "fcm": openapi.Schema(type=openapi.TYPE_STRING, default=""), + "device_os": openapi.Schema(type=openapi.TYPE_STRING, default="android"), + "lat": openapi.Schema(type=openapi.TYPE_STRING, default="56"), + "lon": openapi.Schema(type=openapi.TYPE_STRING, default="44"), + "timezone": openapi.Schema(type=openapi.TYPE_STRING, default="1.0"), + }, + required=["device_id"], + ), + ) + def post(self, request, *args, **kwargs): + logger.info(f'GuestAuthView--> {request.data}') + return super().post(request, *args, **kwargs) + + @staticmethod + def generate_login_token(user): + token, created = Token.objects.update_or_create(user=user) + return token.key + + def get_client_ip(self): + request = self.request + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = self.perform_create(serializer) + return Response({ + 'token': self.generate_login_token(user), + }, status=200) + + + def perform_create(self, serializer): + device_id = serializer.validated_data.get('device_id') + device_os = serializer.validated_data.get('device_os') + fcm = serializer.validated_data.get('fcm') + lat = serializer.validated_data.pop('lat', None) + lon = serializer.validated_data.pop('lon', None) + user_timezone = serializer.validated_data.pop('timezone', None) + + serializer_data = dict(serializer.validated_data) + + obj = User.objects.select_for_update().filter(Q(device_id=device_id)).first() + if not obj: + obj, created = User.objects.select_for_update().get_or_create( + device_id=device_id, + defaults=serializer_data + ) + if created: + logger.info(f'Guest-(created)->: {obj.device_id}') + + obj.last_login = timezone.now() + obj.save() + login_history_obj = obj.login_history.create( + lat=lat, + lon=lon, + ip=self.get_client_ip(), + timezone=user_timezone, + ) + return obj + + + + +class UserRegisterView(CreateAPIView): + permission_classes = [AllowAny] + serializer_class = UserRegisterSerializer + + + @swagger_auto_schema( + operation_description=doc_register(), + request_body=UserRegisterSerializer, + ) + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + + code = RedisManager.generate_otp_code() + logger.info(f"phone= {data['email']}") + print(f'send {code}/{data["email"]}') + phone_number = RedisManager().add_to_redis(code, **data) + + send_email([data['email']], code) + return Response( + data= { + "user": data, + "message": "The otp code was sent to the user's email" + }, + status=status.HTTP_202_ACCEPTED, + ) + + + +class UserVerifyView(CreateAPIView): + permission_classes = [AllowAny] + serializer_class = UserVerifySerializer + + @swagger_auto_schema( + operation_description=doc_verify(), + request_body=UserVerifySerializer, + ) + def post(self, request, *args, **kwargs): + print(f'-UserVerifyView-> {request.data}') + return super().post(request, *args, **kwargs) + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + print(f'--UserVerifyView---1--') + try: + verify_data = RedisManager().get_by_redis(data['email']) + if not verify_data: + raise ValidationError({"code": "Verification data not found or expired."}) + # raise ExpiredCodeException("Verification data not found or expired.") + except (ServiceUnavailableException) as e: + return AppAPIException({"message": str(e)}, status_code=e.status_code) + except ExpiredCodeException: + # raise ExpiredCodeException("The verification code has expired.") + raise ValidationError({"code": "The verification code has expired."}) + + code = self.valied_code(data['code'], verify_data['code']) + del verify_data['code'] + user = self.perform_create( + email=serializer.data['email'], device_id=serializer.data['device_id'], **verify_data + ) + token, _ = Token.objects.get_or_create(user=user) + return Response(data={ + 'token': str(token.key), + 'user_id': user.id, + 'phone_number': str(user.phone_number) if user.phone_number else None, + 'email': str(user.email), + 'fullname': str(user.fullname), + 'avatar': str(user.avatar) if user.avatar else None + }, status=status.HTTP_201_CREATED) + + def valied_code(self, current_code, save_code): + if (current_code and save_code) and ( current_code != save_code): + raise ValidationError({"code": "code notfound"}) + + return current_code + + def perform_create(self, *args, **kwargs): + email = kwargs.get('email') + device_id = kwargs.get('device_id') + user = User.objects.filter(email=email).first() + if user: + if kwargs['password']: + user.is_active = True + user.deletion_date = None + user.device_id = device_id + user.last_login = timezone.now() + user.save() + else: + user = User.objects.filter(device_id=device_id, email__isnull=True).first() + if not user: + user = User.objects.create(**kwargs) + else: + user.email = email + user.fullname = kwargs['fullname'] + user.device_id = device_id + user.last_login = timezone.now() + user.is_active = True + user.save() + + return user + + +class UserLoginView(CreateAPIView): + permission_classes = [AllowAny] + serializer_class = UserLoginSerializer + + @swagger_auto_schema( + operation_description=doc_login(), + request_body=UserLoginSerializer, + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) + def get_client_ip(self): + request = self.request + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0] + else: + ip = request.META.get('REMOTE_ADDR') + return ip + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + user = authenticate(request, username=request.data['email'], password=data['password']) + if not user: + raise ValidationError({"email": "Unable to log in with provided credentials."}) + user_timezone = serializer.validated_data.pop('timezone', None) + user.last_login = timezone.now() + user.is_active = True + user.save + token, created = Token.objects.get_or_create(user=user) + serializer_data = serializer.data + serializer_data['token'] = token.key + + login_history_obj = user.login_history.create( + ip=self.get_client_ip(), + timezone=user_timezone, + ) + return Response({ + "id": user.id, + "fullname": user.fullname, + "email": user.email, + "token": token.key, + "user_type": user.user_type_based_on_groups, + "avatar": request.build_absolute_uri(user.avatar.url) if user.avatar else None, + }, status=status.HTTP_201_CREATED) + + + +class UserProfileView(RetrieveAPIView): + serializer_class = UserProfileSerializer + permission_classes = [IsAuthenticated, IsActiveUser] + queryset = User.objects.all() + + def get(self, request, *args, **kwargs): + logger.info(f'UserProfileView--> {request.data}') + return super().get(request, *args, **kwargs) + + def get_object(self): + return self.request.user + + +class UserUpdateView(UpdateAPIView): + permission_classes = [IsAuthenticated, IsActiveUser] + serializer_class = UserProfileSerializer + + def put(self, request, *args, **kwargs): + logger.info(f'UserProfileView--> {request.data}') + return super().put(request, *args, **kwargs) + + def get_object(self): + return self.request.user + + +class UserRecoverPassword(CreateAPIView): + serializer_class = UserRecoverPasswordSerializer + + @swagger_auto_schema( + operation_description=doc_recover(), + request_body=UserRecoverPasswordSerializer, + ) + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + user = get_object_or_404(User, email=data['email']) + code = RedisManager.generate_otp_code() + print(f' send {code}') + phone_number = RedisManager().add_to_redis(code, fullname=str(user.fullname), password='', email=data['email']) + + send_email([data['email']], code) + + return Response( + data= { + "id": user.id, + "fullname": user.fullname, + "phone_number": str(user.phone_number) if user.phone_number else None, + "email": user.email if user.email else None, + "avatar": user.avatar if user.avatar else None, + "message": "Forgot password code sent" + }, + status=status.HTTP_202_ACCEPTED, + ) + + +class UserResetPassword(CreateAPIView): + serializer_class = UserResetPasswordSerializer + permission_classes = [IsAuthenticated] + + @swagger_auto_schema( + operation_description=doc_reset(), + request_body=UserResetPasswordSerializer, + ) + def post(self, request, *args, **kwargs): + # Get the logged-in user + user = request.user + + # Use the serializer to validate data + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Set the new password + user.set_password(serializer.validated_data['password']) + user.save() + + # Return a success response + return Response({"message": "Your password has been changed successfully."}, status=status.HTTP_200_OK) + + + + +class UserDeleteView(APIView): + permission_classes = [IsAuthenticated] + + def delete(self, request, *args, **kwargs): + try: + user = request.user + if user.email == "admin@gmail.com": + raise AppAPIException({"message": "Unable to log in with provided credentials."}, status_code=status.HTTP_204_NO_CONTENT) + + user.soft_delete() + if t := Token.objects.filter(user=user).first(): + t.delete() + + return Response({"detail": "Your account has been deleted."}, status=status.HTTP_204_NO_CONTENT) + + except Exception: + # پیام خطای ثابت برای سایر خطاهای غیرمنتظره + return Response({"detail": "User does not exist."}, status=status.HTTP_404_NOT_FOUND) + + diff --git a/apps/api/__init__.py b/apps/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/api/admin.py b/apps/api/admin.py new file mode 100644 index 0000000..3a6f508 --- /dev/null +++ b/apps/api/admin.py @@ -0,0 +1,75 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from unfold.admin import ModelAdmin +from unfold.decorators import display +from django.utils.html import format_html + + +from filer.models.thumbnailoptionmodels import ThumbnailOption +# from filer.admin.thumbnailoptionmodels import ThumbnailOptionAdmin as OriginalThumbnailOptionAdmin + + +admin.site.unregister(ThumbnailOption) + +@admin.register(ThumbnailOption) +class ThumbnailOptionAdmin(ModelAdmin): + list_display = ['name', 'dimensions_display', 'crop', 'upscale', 'preview'] + list_filter = ['crop', 'upscale'] + search_fields = ['name'] + + fieldsets = ( + (None, { + 'fields': ('name', 'width', 'height', 'crop', 'upscale'), + 'classes': ('unfold-fieldset',), + }), + ) + + @display(description=_("Dimensions")) + def dimensions_display(self, obj): + return f"{obj.width} × {obj.height}" + + @display(description=_("Preview")) + def preview(self, obj): + # ایجاد یک نمایش بصری از ابعاد تصویر بندانگشتی + width_percent = min(100, obj.width / 10) # محدود کردن عرض به حداکثر 100% + height_px = min(50, obj.height / 5) # محدود کردن ارتفاع به حداکثر 50px + + return format_html( + '
' + '{} × {}' + '
', + 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) diff --git a/apps/api/apps.py b/apps/api/apps.py new file mode 100644 index 0000000..ae75201 --- /dev/null +++ b/apps/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.api' diff --git a/apps/api/models.py b/apps/api/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/apps/api/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/apps/api/tests.py b/apps/api/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/api/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/api/urls.py b/apps/api/urls.py new file mode 100644 index 0000000..d240486 --- /dev/null +++ b/apps/api/urls.py @@ -0,0 +1,10 @@ + +from django.urls import path +from .views import HomeView, CountryView + + + +urlpatterns = [ + path('', HomeView.as_view()), + path('countries/', CountryView.as_view()), +] diff --git a/apps/api/views.py b/apps/api/views.py new file mode 100644 index 0000000..7ddd4a3 --- /dev/null +++ b/apps/api/views.py @@ -0,0 +1,42 @@ +import random +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response +from rest_framework import serializers + +from rest_framework.authtoken.models import Token +from apps.account.models import User + +class HomeSerializer(serializers.Serializer): + token = serializers.CharField() + +from utils.countries import countries + + +# test class generate token +class HomeView(GenericAPIView): + serializer_class = HomeSerializer + + def get(self, request): + emails = ["zahra@gmail.com", "john.doe@example.com", "alice@example.com"] + phone_numbers = ["09012037621", "09012037615", "09012045432"] + fullnames = ["Alireza", "John Doe", "Alice Smith"] + # انتخاب رندوم از هر لیست + email = random.choice(emails) + phone_number = random.choice(phone_numbers) + fullname = random.choice(fullnames) + # ساخت کاربر جدید + user = User.objects.create( + email=email, + phone_number=phone_number, + fullname=fullname, + ) + # ایجاد توکن برای کاربر + token, created = Token.objects.get_or_create(user=user) + + return Response({'token': token.key}) + +class CountryView(GenericAPIView): + + def get(self, request): + return Response(countries, status=200) + diff --git a/apps/certificate/__init__.py b/apps/certificate/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/certificate/admin.py b/apps/certificate/admin.py new file mode 100644 index 0000000..4efb5cb --- /dev/null +++ b/apps/certificate/admin.py @@ -0,0 +1,44 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from django.utils.html import format_html + +from unfold.admin import ModelAdmin +from unfold.decorators import display + +from apps.certificate.models import Certificate + +from utils.admin import project_admin_site + +@admin.register(Certificate) +class CertificateAdmin(ModelAdmin): + list_display = ['student', 'course', 'certificate_status', 'created_at'] + list_filter = ['status', 'created_at'] + search_fields = ['id', 'student__username', 'student__email', 'course__title'] + readonly_fields = ['created_at', 'updated_at'] + autocomplete_fields = ['student',] + fieldsets = ( + (None, { + 'fields': ('student', 'course', 'status', 'certificate_file') + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + @display(description=_("Status"), ordering="status") + def certificate_status(self, obj): + status_classes = { + 'pending': 'unfold-badge unfold-badge--warning', + 'approved': 'unfold-badge unfold-badge--success', + 'rejected': 'unfold-badge unfold-badge--danger', + 'issued': 'unfold-badge unfold-badge--info', + } + + status_class = status_classes.get(obj.status.lower(), 'unfold-badge') + return format_html('{}', status_class, obj.get_status_display()) + + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset +project_admin_site.register(Certificate, CertificateAdmin) \ No newline at end of file diff --git a/apps/certificate/apps.py b/apps/certificate/apps.py new file mode 100644 index 0000000..6a8b903 --- /dev/null +++ b/apps/certificate/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CertificateConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.certificate' diff --git a/apps/certificate/migrations/0001_initial.py b/apps/certificate/migrations/0001_initial.py new file mode 100644 index 0000000..6faf2f3 --- /dev/null +++ b/apps/certificate/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import django.db.models.deletion +import filer.fields.file +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('account', '0001_initial'), + ('course', '0001_initial'), + ('filer', '0017_image__transparent'), + ] + + operations = [ + migrations.CreateModel( + name='Certificate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('pending', 'pending'), ('approved', 'approved'), ('canceled', 'canceled')], default='pending', max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('certificate_file', filer.fields.file.FilerFileField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='filer.file', verbose_name='certificate_file')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='course_certificates', to='course.course')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to='account.studentuser')), + ], + ), + ] diff --git a/apps/certificate/migrations/0002_alter_certificate_certificate_file.py b/apps/certificate/migrations/0002_alter_certificate_certificate_file.py new file mode 100644 index 0000000..1471ea1 --- /dev/null +++ b/apps/certificate/migrations/0002_alter_certificate_certificate_file.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-04-03 01:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('certificate', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='certificate', + name='certificate_file', + field=models.FileField(blank=True, null=True, upload_to='certificates/', verbose_name='certificate_file'), + ), + ] diff --git a/apps/certificate/migrations/__init__.py b/apps/certificate/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/certificate/models.py b/apps/certificate/models.py new file mode 100644 index 0000000..835f4ed --- /dev/null +++ b/apps/certificate/models.py @@ -0,0 +1,28 @@ +from django.db import models + +from django.utils.translation import gettext_lazy as _ +from filer.fields.file import FilerFileField +from apps.course.models import Course +from apps.account.models import StudentUser + + + +class Certificate(models.Model): + STATUS_CHOICES = [ + ('pending', _('pending')), + ('approved', _('approved')), + ('canceled', _('canceled')), + ] + + student = models.ForeignKey(StudentUser, on_delete=models.CASCADE, related_name='certificates') + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='course_certificates') + status = models.CharField(max_length=10, choices=STATUS_CHOICES, default='pending') + certificate_file = models.FileField(upload_to='certificates/', null=True, blank=True, verbose_name=_('certificate_file')) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"Certificate {self.student.fullname} - {self.course.title}" + + \ No newline at end of file diff --git a/apps/certificate/serializers.py b/apps/certificate/serializers.py new file mode 100644 index 0000000..c816135 --- /dev/null +++ b/apps/certificate/serializers.py @@ -0,0 +1,50 @@ + + +from rest_framework import serializers +from apps.certificate.models import Certificate +from apps.course.serializers import CourseDetailSerializer +from django.conf import settings + + + +class CertificateSerializer(serializers.ModelSerializer): + course = serializers.SerializerMethodField() + certificate_file = serializers.SerializerMethodField() + + class Meta: + model = Certificate + fields = ['id', 'student', 'course', 'status', 'created_at', 'updated_at', 'certificate_file'] + read_only_fields = ['id', 'student', 'status', 'created_at', 'updated_at',] + + def get_course(self, obj): + return CourseDetailSerializer(obj.course, context=self.context).data + + def get_certificate_file(self, obj): + if obj.certificate_file: + request = self.context.get('request') + if request is not None: + return request.build_absolute_uri(obj.certificate_file.url) + return obj.certificate_file.url + return None + + + + +class CertificateRequestSerializer(serializers.ModelSerializer): + class Meta: + model = Certificate + fields = ['id', 'course'] + read_only_fields = ['id'] + + def create(self, validated_data): + user = self.context['request'].user + course = validated_data['course'] + + if Certificate.objects.filter(student=user, course=course, status__in=['pending', 'approved']).exists(): + raise serializers.ValidationError({ + "course": "A certificate request for this course is already pending or approved." + }) + return Certificate.objects.create(student=user, course=course) + + + diff --git a/apps/certificate/tests.py b/apps/certificate/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/certificate/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/certificate/urls.py b/apps/certificate/urls.py new file mode 100644 index 0000000..9b80b74 --- /dev/null +++ b/apps/certificate/urls.py @@ -0,0 +1,11 @@ + + +from django.urls import path +from .views import CertificateRequestView, UserCertificatesListView + + + +urlpatterns = [ + path('request/', CertificateRequestView.as_view(), name='certificate-request'), + path('my-certificates/', UserCertificatesListView.as_view(), name='user-certificates'), +] \ No newline at end of file diff --git a/apps/certificate/views.py b/apps/certificate/views.py new file mode 100644 index 0000000..4e12f21 --- /dev/null +++ b/apps/certificate/views.py @@ -0,0 +1,24 @@ +from rest_framework import generics, permissions + +from apps.certificate.models import Certificate +from apps.certificate.serializers import CertificateRequestSerializer, CertificateSerializer + + + +class CertificateRequestView(generics.CreateAPIView): + queryset = Certificate.objects.all() + serializer_class = CertificateRequestSerializer + permission_classes = [permissions.IsAuthenticated] + + def perform_create(self, serializer): + serializer.save(student=self.request.user) + + + +class UserCertificatesListView(generics.ListAPIView): + serializer_class = CertificateSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + return Certificate.objects.filter(student=self.request.user).order_by('-created_at') + \ No newline at end of file diff --git a/apps/chat/__init__.py b/apps/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/admin.py b/apps/chat/admin.py new file mode 100644 index 0000000..4f88fbe --- /dev/null +++ b/apps/chat/admin.py @@ -0,0 +1,59 @@ +from django.contrib import admin + +from apps.chat.models import RoomMessage, ChatMessage, MessageReadStatus + + + + +@admin.register(MessageReadStatus) +class MessageReadStatusAdmin(admin.ModelAdmin): + list_display = ( + 'user', 'message', 'is_read', 'read_at', + ) + + +@admin.register(RoomMessage) +class RoomMessageAdmin(admin.ModelAdmin): + list_display = ( + 'name', 'room_type', 'course', 'initiator', 'recipient', 'created_at', 'unread_messages_count' + ) + list_filter = ('room_type', 'created_at', 'updated_at', 'course') + search_fields = ('name', 'description', 'course__title', 'initiator__username', 'recipient__username') + ordering = ('-created_at',) + readonly_fields = ('created_at', 'updated_at') + fieldsets = ( + (None, { + 'fields': ('name', 'description', 'room_type') + }), + ('Relations', { + 'fields': ('course', 'initiator', 'recipient') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at') + }), + ) + + +@admin.register(ChatMessage) +class ChatMessageAdmin(admin.ModelAdmin): + list_display = ( + 'room', 'sender', 'content_type', 'content_size', 'sent_at', 'is_deleted' + ) + list_filter = ('content_type', 'is_deleted', 'sent_at', 'updated_at') + search_fields = ('room__name', 'sender__username', 'content') + ordering = ('-sent_at',) + readonly_fields = ('sent_at', 'updated_at') + fieldsets = ( + (None, { + 'fields': ('room', 'sender', 'content', 'content_type') + }), + ('Additional Info', { + 'fields': ('content_size',) + }), + ('Status', { + 'fields': ('is_deleted', 'deleted_at') + }), + ('Timestamps', { + 'fields': ('sent_at', 'updated_at') + }), + ) diff --git a/apps/chat/apps.py b/apps/chat/apps.py new file mode 100644 index 0000000..2d27770 --- /dev/null +++ b/apps/chat/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ChatConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.chat' diff --git a/apps/chat/migrations/0001_initial.py b/apps/chat/migrations/0001_initial.py new file mode 100644 index 0000000..2b1317e --- /dev/null +++ b/apps/chat/migrations/0001_initial.py @@ -0,0 +1,62 @@ +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('course', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='RoomMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Room Name')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('room_type', models.CharField(choices=[('group', 'Group'), ('private', 'Private')], default='group', max_length=10, verbose_name='Room Type')), + ('unread_messages_count', models.IntegerField(default=0)), + ('course', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='room_messages', to='course.course', verbose_name='Course')), + ('initiator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='initiated_rooms', to=settings.AUTH_USER_MODEL, verbose_name='Initiator')), + ('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='messages_received', to=settings.AUTH_USER_MODEL, verbose_name='Recipient')), + ], + ), + migrations.CreateModel( + name='ChatMessage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField(verbose_name='Message Content')), + ('content_type', models.CharField(choices=[('text', 'Text'), ('file', 'File'), ('audio', 'Audio'), ('image', 'Image')], default='text', max_length=10, verbose_name='Chat Type')), + ('content_size', models.PositiveIntegerField(blank=True, null=True, verbose_name='Content Size (bytes)')), + ('is_read', models.BooleanField(default=False, verbose_name='Is Read')), + ('sent_at', models.DateTimeField(auto_now_add=True, verbose_name='Sent At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('deleted_at', models.DateTimeField(blank=True, null=True, verbose_name='Deleted At')), + ('is_deleted', models.BooleanField(default=False, verbose_name='Is deleted')), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages_sent', to=settings.AUTH_USER_MODEL, verbose_name='Sender')), + ('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.roommessage', verbose_name='Room')), + ], + ), + migrations.CreateModel( + name='MessageReadStatus', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_read', models.BooleanField(default=False, verbose_name='Is Read')), + ('read_at', models.DateTimeField(blank=True, null=True, verbose_name='Read At')), + ('message', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_statuses', to='chat.chatmessage', verbose_name='Message')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_statuses', to=settings.AUTH_USER_MODEL, verbose_name='User')), + ], + options={ + 'unique_together': {('user', 'message')}, + }, + ), + ] diff --git a/apps/chat/migrations/__init__.py b/apps/chat/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/models.py b/apps/chat/models.py new file mode 100644 index 0000000..d5263dc --- /dev/null +++ b/apps/chat/models.py @@ -0,0 +1,121 @@ + +from django.db import models + +from apps.account.models import User, User +from apps.course.models import Course + + + +class RoomMessage(models.Model): + class RoomTypeChoices(models.TextChoices): + GROUP = 'group', 'Group' + PRIVATE = 'private', 'Private' + + name = models.CharField( + max_length=255, + verbose_name="Room Name" + ) + description = models.TextField( + verbose_name="Description", + blank=True, + null=True + ) + course = models.ForeignKey(Course,on_delete=models.CASCADE, null=True, blank=True ,related_name="room_messages", verbose_name="Course") + initiator = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="initiated_rooms", + verbose_name="Initiator" + ) + recipient = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="messages_received", + verbose_name="Recipient", + null=True, + blank=True + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name="Created At") + updated_at = models.DateTimeField( + auto_now=True, + verbose_name="Updated At" + ) + room_type = models.CharField( + max_length=10, + choices=RoomTypeChoices.choices, + default=RoomTypeChoices.GROUP, + verbose_name="Room Type" + ) + unread_messages_count = models.IntegerField(default=0) + + def __str__(self): + if self.room_type == self.RoomTypeChoices.GROUP: + return f"Group Room: {self.course.title if self.course else 'N/A'}" + return f"Private Room with {self.recipient}" + + + +class ChatMessage(models.Model): + class ChatTypeChoices(models.TextChoices): + TEXT = 'text', 'Text' + FILE = 'file', 'File' + AUDIO = 'audio', 'Audio' + IMAGE = 'image', 'Image' + + room = models.ForeignKey( + RoomMessage, + on_delete=models.CASCADE, + related_name="messages", + verbose_name="Room", + ) + sender = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="messages_sent", + verbose_name="Sender" + ) + content = models.TextField(verbose_name="Message Content") + content_type = models.CharField( + max_length=10, + choices=ChatTypeChoices.choices, + default=ChatTypeChoices.TEXT, + verbose_name="Chat Type" + ) + content_size = models.PositiveIntegerField( + verbose_name="Content Size (bytes)", + blank=True, + null=True + ) + is_read = models.BooleanField(default=False, verbose_name="Is Read") + + sent_at = models.DateTimeField(auto_now_add=True, verbose_name="Sent At") + updated_at = models.DateTimeField(auto_now=True, verbose_name="Updated At") + deleted_at = models.DateTimeField(null=True, blank=True, verbose_name="Deleted At") + is_deleted = models.BooleanField(default=False, verbose_name="Is deleted") + + def __str__(self): + return f"Message from {self.sender} in {self.room}" + + +class MessageReadStatus(models.Model): + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="read_statuses", + verbose_name="User" + ) + message = models.ForeignKey( + ChatMessage, + on_delete=models.CASCADE, + related_name="read_statuses", + verbose_name="Message" + ) + is_read = models.BooleanField(default=False, verbose_name="Is Read") + read_at = models.DateTimeField(null=True, blank=True, verbose_name="Read At") + + class Meta: + unique_together = ("user", "message") # جلوگیری از ثبت تکراری + + def __str__(self): + return f"User {self.user.fullname} read Message {self.message.id}: {self.is_read}" + \ No newline at end of file diff --git a/apps/chat/tests.py b/apps/chat/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/chat/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/chat/views.py b/apps/chat/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/apps/chat/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apps/course/__init__.py b/apps/course/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/course/admin/__init__.py b/apps/course/admin/__init__.py new file mode 100644 index 0000000..e86b7ee --- /dev/null +++ b/apps/course/admin/__init__.py @@ -0,0 +1,3 @@ +from .course import * +from .lesson import * +from .participant import * \ No newline at end of file diff --git a/apps/course/admin/course.py b/apps/course/admin/course.py new file mode 100644 index 0000000..0cccba7 --- /dev/null +++ b/apps/course/admin/course.py @@ -0,0 +1,480 @@ +import os +import hashlib + +from django.contrib import admin +from django.contrib import messages +from django import forms +from django.utils.translation import gettext_lazy as _ +from django.db import models +from django.utils.html import format_html +from django.shortcuts import redirect +from django.urls import reverse_lazy + +from unfold.admin import ModelAdmin, StackedInline, TabularInline +from unfold.decorators import action, display +from unfold.contrib.forms.widgets import WysiwygWidget +from unfold.sections import TableSection +from unfold.contrib.filters.admin import ( + ChoicesDropdownFilter, + MultipleRelatedDropdownFilter, + RangeDateFilter, + RangeNumericFilter, + TextFilter, +) +from unfold.widgets import ( + UnfoldAdminColorInputWidget, + UnfoldAdminRadioSelectWidget, + UnfoldAdminSelectWidget, + UnfoldAdminSplitDateTimeWidget, + UnfoldAdminTextInputWidget, +) + +from unfold.contrib.forms.widgets import ArrayWidget +from django.contrib.postgres.fields import ArrayField + +from utils.admin import project_admin_site +from utils.json_editor_field import JsonEditorWidget +from apps.course.models import Course, Glossary, Attachment, CourseCategory, Participant +from apps.course.models.lesson import Lesson +from apps.account.models import StudentUser + +from utils.schema import get_weekly_timing_schema, get_course_feature_schema + + +class CourseTableSection(TableSection): + verbose_name = _("Course Categories") + related_name = "courses" + height = 380 + fields = [ + "title", + "status", + "edit_link" + ] + + def edit_link(self, instance): + from django.utils.html import format_html + + return format_html( + '' + 'visibility' + '', + 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( + '{}', + 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('{}', _("Free")) + + if instance.discount_percentage > 0: + return format_html( + '${}' + '${}', + instance.price, + instance.final_price + ) + + return format_html('${}', 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) diff --git a/apps/course/admin/lesson.py b/apps/course/admin/lesson.py new file mode 100644 index 0000000..ff1d342 --- /dev/null +++ b/apps/course/admin/lesson.py @@ -0,0 +1,111 @@ +import os +from django.contrib import admin +from django import forms +from django.utils.translation import gettext_lazy as _ +from django.db import models +from django.utils.html import format_html + +from unfold.admin import ModelAdmin +from unfold.decorators import display +from unfold.contrib.forms.widgets import WysiwygWidget +from unfold.contrib.filters.admin import ( + ChoicesDropdownFilter, + MultipleRelatedDropdownFilter, +) +from unfold.widgets import ( + UnfoldAdminRadioSelectWidget, +) + +from utils.admin import project_admin_site +from apps.course.models.lesson import Lesson, LessonCompletion +from unfold.admin import ModelAdmin, StackedInline, TabularInline + + +class LessonForm(forms.ModelForm): + class Meta: + model = Lesson + fields = '__all__' + widgets = { + 'content_type': UnfoldAdminRadioSelectWidget(), + } + +from apps.quiz.models import Quiz + + +class LessonAdmin(ModelAdmin): + form = LessonForm + list_display = ('title', 'course', 'display_duration', 'content_type', 'is_active', 'priority') + list_filter = ( + ('course', MultipleRelatedDropdownFilter), + ('content_type', ChoicesDropdownFilter), + 'is_active', + ) + search_fields = ('title', 'course__title') + ordering = ('course', 'priority') + autocomplete_fields = ('course', ) + list_filter_submit = True + radio_fields = { + "content_type": admin.HORIZONTAL, + } + conditional_fields = { + 'content_file': "content_type == 'video_file'", + 'video_link': "content_type == 'youtube_link'", + } + + fieldsets = ( + (None, { + 'fields': ('course', 'title', 'priority', 'is_active', 'duration') + }), + (_('Content'), { + 'fields': ('content_type', 'content_file', 'video_link'), + 'classes': [], + }), + ) + + def get_form(self, request, obj=None, change=False, **kwargs): + form = super().get_form(request, obj, change, **kwargs) + + # Enhanced styling for content_type radio buttons + form.base_fields["content_type"].widget = UnfoldAdminRadioSelectWidget( + choices=Lesson.ContentTypeChoices.choices, + radio_style=admin.HORIZONTAL, + attrs={ + "class": "radio-inline flex gap-4 p-2 rounded-lg bg-gray-50 shadow-sm", + "option_class": "flex items-center p-2 rounded-md hover:bg-white hover:shadow-sm transition-all duration-200", + "label_class": "ml-2 font-medium text-gray-700 cursor-pointer", + "input_class": "form-radio h-5 w-5 text-blue-600 transition duration-150 ease-in-out cursor-pointer", + }, + ) + + return form + + @display(description=_("Duration")) + def display_duration(self, obj): + return format_html( + '{} min', + 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) \ No newline at end of file diff --git a/apps/course/admin/participant.py b/apps/course/admin/participant.py new file mode 100644 index 0000000..7f96249 --- /dev/null +++ b/apps/course/admin/participant.py @@ -0,0 +1,33 @@ +# from django.contrib import admin + +# from apps.course.models import Participant +# from apps.account.models import StudentUser, User + + + +# @admin.register(Participant) +# class ParticipantAdmin(admin.ModelAdmin): +# list_display = ('student', 'course', 'joined_date', 'unread_messages_count') +# search_fields = ('student__fullname', 'student__email', 'course__title') +# list_filter = ('course', 'joined_date') +# ordering = ('-joined_date',) +# autocomplete_fields = ['student',] # جستجوی پویا برای فیلد دانش‌آموز + +# def get_readonly_fields(self, request, obj=None): +# """ +# Make fields readonly if the object already exists. +# """ +# if obj: +# return ['student', 'course', 'joined_date'] +# return [] + + +# def get_form(self, request, obj=None, **kwargs): +# form = super().get_form(request, obj, **kwargs) +# if obj is None: # Adding a new participant +# # محدود کردن انتخاب دانش‌آموزان به کاربرانی که از نوع StudentUser هستند +# # form.base_fields['student'].queryset = StudentUser.objects.filter(user_type=User.UserType.STUDENT) +# form.base_fields['student'].widget.can_add_related = True # فعال کردن دکمه اضافه کردن + +# return form + \ No newline at end of file diff --git a/apps/course/apps.py b/apps/course/apps.py new file mode 100644 index 0000000..b56f637 --- /dev/null +++ b/apps/course/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class CourseConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.course' + + def ready(self): + import apps.course.signals \ No newline at end of file diff --git a/apps/course/data/category.json b/apps/course/data/category.json new file mode 100644 index 0000000..092e449 --- /dev/null +++ b/apps/course/data/category.json @@ -0,0 +1,42 @@ +[ + { + "id": 8, + "name": "Комплексный годовой курс", + "slug": "kompleksnyi-godovoi-kurs" + }, + { + "id": 7, + "name": "Исламская философия", + "slug": "islamskaia-filosofiia" + }, + { + "id": 6, + "name": "Арабский диалог", + "slug": "arabskii-dialog" + }, + { + "id": 5, + "name": "грамматике арабского языка", + "slug": "grammatike-arabskogo-iazyka" + }, + { + "id": 4, + "name": "Персидский язык", + "slug": "persidskii-iazyk" + }, + { + "id": 3, + "name": "исламской философии", + "slug": "islamskoi-filosofii" + }, + { + "id": 2, + "name": "Толкование корана", + "slug": "tolkovanie-korana" + }, + { + "id": 1, + "name": "Таджвид Корана", + "slug": "tadzhvid-korana" + } +] \ No newline at end of file diff --git a/apps/course/doc.py b/apps/course/doc.py new file mode 100644 index 0000000..55e929d --- /dev/null +++ b/apps/course/doc.py @@ -0,0 +1,430 @@ +def doc_course_participants(): + return """ +# 🐈 Scenario +🛠️ لیست شرکت‌کنندگان دوره + +--- + +## 🚀 درخواست API + +### URL: +``` +GET /api/courses//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 + } +] +``` +""" diff --git a/apps/course/migrations/0001_initial.py b/apps/course/migrations/0001_initial.py new file mode 100644 index 0000000..8ec50cf --- /dev/null +++ b/apps/course/migrations/0001_initial.py @@ -0,0 +1,135 @@ +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import apps.course.models.course +import apps.course.models.lesson +import django.db.models.deletion +import filer.fields.image +import utils.schema +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('account', '0001_initial'), + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CourseCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='Category Name')), + ('slug', models.SlugField(max_length=255, unique=True)), + ], + ), + migrations.CreateModel( + name='Course', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Course Title')), + ('slug', models.SlugField(allow_unicode=True, unique=True)), + ('video_type', models.CharField(choices=[('video_file', 'Video File'), ('video_link', 'Video Link')], max_length=20, verbose_name='Vedio Type')), + ('video_file', models.FileField(blank=True, null=True, upload_to=apps.course.models.course.course_file_upload_to)), + ('video_link', models.CharField(blank=True, max_length=500, null=True, verbose_name='Video Link')), + ('is_online', models.BooleanField(default=True, verbose_name='Is Online Course')), + ('online_link', models.CharField(blank=True, max_length=500, null=True, verbose_name='Online Class Link')), + ('level', models.CharField(choices=[('beginner', 'Beginner'), ('mid', 'Mid Level'), ('advanced', 'Advanced')], max_length=10, verbose_name='Course Level')), + ('duration', models.PositiveIntegerField(verbose_name='Duration (in hours)')), + ('lessons_count', models.PositiveIntegerField(verbose_name='Number of Lessons')), + ('description', models.TextField(verbose_name='Course Description')), + ('short_description', models.CharField(blank=True, max_length=500, null=True, verbose_name='Short Description')), + ('status', models.CharField(choices=[('inactive', 'Inactive'), ('upcoming', 'Upcoming'), ('registering', 'Registering'), ('ongoing', 'Ongoing'), ('finished', 'Finished')], default='inactive', max_length=15, verbose_name='Course Status')), + ('is_free', models.BooleanField(default=True, verbose_name='Is Free')), + ('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='Course Price')), + ('discount_percentage', models.PositiveIntegerField(default=0, verbose_name='Discount Percentage')), + ('final_price', models.DecimalField(blank=True, decimal_places=2, default=0.0, help_text='This field is automatically calculated based on the discount percentage.', max_digits=10, verbose_name='Course Final Price')), + ('timing', models.JSONField(blank=True, default=utils.schema.default_timing, help_text='The Timing information in JSON format.', null=True, verbose_name='Timing')), + ('features', models.JSONField(blank=True, default=dict, null=True, verbose_name='Course features')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('professor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to='account.professoruser')), + ('thumbnail', filer.fields.image.FilerImageField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.FILER_IMAGE_MODEL, verbose_name='thumbnail')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='courses', to='course.coursecategory', verbose_name='Category')), + ], + options={ + 'verbose_name': 'Course', + 'verbose_name_plural': 'Courses', + }, + ), + migrations.CreateModel( + name='Attachment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Attachment Title')), + ('file', models.FileField(upload_to=apps.course.models.course.attachment_file_upload_to, verbose_name='Attachment File')), + ('file_size', models.PositiveIntegerField(blank=True, null=True, verbose_name='File Size (in bytes)')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='course.course', verbose_name='Course')), + ], + options={ + 'verbose_name': 'Attachment', + 'verbose_name_plural': 'Attachments', + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='Glossary', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=555, verbose_name='Glossary Title')), + ('description', models.TextField(verbose_name='Description')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='glossaries', to='course.course', verbose_name='Course')), + ], + options={ + 'verbose_name': 'Glossary', + 'verbose_name_plural': 'Glossary', + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='Lesson', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='Lesson Title')), + ('priority', models.IntegerField(blank=True, null=True, verbose_name='Priority')), + ('is_active', models.BooleanField(default=True, verbose_name='Is Active')), + ('duration', models.PositiveIntegerField(verbose_name='Duration (in minutes)')), + ('content_type', models.CharField(choices=[('youtube_link', 'Youtube Link'), ('video_file', 'Video File'), ('audio_file', 'Audio File')], max_length=50, verbose_name='Content Type')), + ('content_file', models.FileField(blank=True, null=True, upload_to=apps.course.models.lesson.lesson_file_upload_to)), + ('video_link', models.CharField(blank=True, max_length=500, null=True, verbose_name='Link')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated At')), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lessons', to='course.course', verbose_name='Course')), + ], + ), + migrations.CreateModel( + name='LessonCompletion', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('completed_at', models.DateTimeField(auto_now_add=True)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='completions', to='course.lesson')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='lesson_completions', to='account.studentuser')), + ], + options={ + 'unique_together': {('student', 'lesson')}, + }, + ), + migrations.CreateModel( + name='Participant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('joined_date', models.DateTimeField(auto_now_add=True)), + ('unread_messages_count', models.IntegerField(default=0)), + ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='course.course')), + ('student', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participated_courses', to='account.studentuser')), + ], + options={ + 'unique_together': {('student', 'course')}, + }, + ), + ] diff --git a/apps/course/migrations/0002_alter_course_thumbnail.py b/apps/course/migrations/0002_alter_course_thumbnail.py new file mode 100644 index 0000000..bf66856 --- /dev/null +++ b/apps/course/migrations/0002_alter_course_thumbnail.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-04-03 01:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='thumbnail', + field=models.ImageField(blank=True, null=True, upload_to='courses/thumbnails/', verbose_name='Thumbnail'), + ), + ] diff --git a/apps/course/migrations/0003_alter_course_is_online_alter_course_timing_and_more.py b/apps/course/migrations/0003_alter_course_is_online_alter_course_timing_and_more.py new file mode 100644 index 0000000..fb1dc35 --- /dev/null +++ b/apps/course/migrations/0003_alter_course_is_online_alter_course_timing_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.1.8 on 2025-04-04 00:09 + +import utils.schema +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0002_alter_course_thumbnail'), + ] + + operations = [ + migrations.AlterField( + model_name='course', + name='is_online', + field=models.BooleanField(default=False, verbose_name='Is Online Course'), + ), + migrations.AlterField( + model_name='course', + name='timing', + field=models.JSONField(blank=True, default=utils.schema.default_timing, null=True, verbose_name='Timing'), + ), + migrations.AlterField( + model_name='course', + name='video_link', + field=models.CharField(blank=True, max_length=500, null=True), + ), + migrations.AlterField( + model_name='course', + name='video_type', + field=models.CharField(choices=[('youtube_link', 'Youtube Link'), ('video_file', 'Video File')], max_length=20, verbose_name='Preview Video Type (YouTube Link or File Upload)'), + ), + ] diff --git a/apps/course/migrations/__init__.py b/apps/course/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/course/models/__init__.py b/apps/course/models/__init__.py new file mode 100644 index 0000000..e86b7ee --- /dev/null +++ b/apps/course/models/__init__.py @@ -0,0 +1,3 @@ +from .course import * +from .lesson import * +from .participant import * \ No newline at end of file diff --git a/apps/course/models/course.py b/apps/course/models/course.py new file mode 100644 index 0000000..cab4c95 --- /dev/null +++ b/apps/course/models/course.py @@ -0,0 +1,174 @@ +import os +from decimal import Decimal +import math +from django.db import models +from django.db.models import TextChoices +from django.utils.translation import gettext_lazy as _ + +from apps.account.models import ProfessorUser +from utils.schema import default_timing +from utils import generate_slug_for_model + + + +def course_file_upload_to(instance, filename): + return os.path.join(f"courses/{instance.slug}/videos/{filename}") + + + +def attachment_file_upload_to(instance, filename): + return os.path.join(f"courses/{instance.course.slug}/attachments/{filename}") + + + + +class CourseCategory(models.Model): + name = models.CharField(max_length=255, verbose_name='Category Name') + slug = models.SlugField(unique=True, max_length=255) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = generate_slug_for_model(CourseCategory, self.name) + super().save(*args, **kwargs) + + @property + def course_count(self): + return self.courses.exclude(status="inactive").count() + +class Course(models.Model): + + class LevelChoices(TextChoices): + BEGINNER = 'beginner', 'Beginner' + MID = 'mid', 'Mid Level' + ADVANCED = 'advanced', 'Advanced' + + class StatusChoices(TextChoices): + INACTIVE = 'inactive', 'Inactive' # Not Active (does not show) + UPCOMING = 'upcoming', 'Upcoming' # Upcoming (visible but registration not allowed)-Предстоящие + REGISTERING = 'registering', 'Registering' # Registering (registration is open)-регистрация + ONGOING = 'ongoing', 'Ongoing' # Ongoing (course has started, registration closed)-В процессе + FINISHED = 'finished', 'Finished' # Finished (course has ended)-закончился + + class VedioTypeChoices(models.TextChoices): + YOUTUBE_LINK = 'youtube_link', 'Youtube Link' + VIDEO_FILE = 'video_file', 'Video File' + + + title = models.CharField(max_length=255, verbose_name='Course Title') + slug = models.SlugField(allow_unicode=True, unique=True) + category = models.ForeignKey(CourseCategory, on_delete=models.CASCADE, related_name='courses', verbose_name='Category') + professor = models.ForeignKey( + ProfessorUser, + on_delete=models.CASCADE, + related_name="courses" + ) + + thumbnail = models.ImageField(upload_to="courses/thumbnails/", null=True, blank=True, verbose_name=_('Thumbnail')) + video_type = models.CharField( + max_length=20, + choices=VedioTypeChoices.choices, + verbose_name='Preview Video Type (YouTube Link or File Upload)' + ) + video_file = models.FileField( + upload_to=course_file_upload_to, + null=True, + blank=True + ) + video_link = models.CharField(max_length=500, null=True, blank=True) + + is_online = models.BooleanField(default=False, verbose_name='Is Online Course') + online_link = models.CharField(max_length=500, null=True, blank=True, verbose_name='Online Class Link') + level = models.CharField(max_length=10, choices=LevelChoices.choices, verbose_name='Course Level') + duration = models.PositiveIntegerField(verbose_name='Duration (in hours)') + lessons_count = models.PositiveIntegerField(verbose_name='Number of Lessons') + + description = models.TextField(verbose_name='Course Description') + short_description = models.CharField(max_length=500, blank=True, null=True, verbose_name="Short Description") + status = models.CharField(max_length=15, choices=StatusChoices.choices, default=StatusChoices.INACTIVE, verbose_name='Course Status') + is_free = models.BooleanField(default=True, verbose_name='Is Free') + price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name='Course Price') + discount_percentage = models.PositiveIntegerField(default=0, verbose_name='Discount Percentage') + final_price = models.DecimalField( + verbose_name=_('Course Final Price'), decimal_places=2, max_digits=10, default=0.00, blank=True, + help_text=_('This field is automatically calculated based on the discount percentage.') + ) + + timing = models.JSONField(blank=True, null=True, default=default_timing, verbose_name=_("Timing")) + features = models.JSONField(verbose_name=_('Course features'), default=dict, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + + + def __str__(self): + return self.title + + def get_completed_lessons_count(self, student): + return self.lessons.filter(completions__student=student).count() + + def is_student_participant(self, student): + return self.participants.filter(student=student).exists() + + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = generate_slug_for_model(Course, self.title) + + if self.discount_percentage > 0: + discount_amount = (self.price * self.discount_percentage) / 100 + final_price = self.price - discount_amount + self.final_price = Decimal(math.ceil(final_price)).quantize(Decimal('0.00')) + else: + self.final_price = Decimal(math.ceil(self.price)).quantize(Decimal('0.00')) + + super().save(*args, **kwargs) + + + class Meta: + verbose_name = "Course" + verbose_name_plural = "Courses" + + + +class Glossary(models.Model): + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='glossaries', verbose_name='Course') + title = models.CharField(max_length=555, verbose_name='Glossary Title') + description = models.TextField(verbose_name='Description') + + def __str__(self): + return f"{self.course.title} - {self.title}" + + + class Meta: + ordering = ("-id",) + verbose_name = "Glossary" + verbose_name_plural = "Glossary" + + + +class Attachment(models.Model): + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='attachments', verbose_name='Course') + title = models.CharField(max_length=255, verbose_name='Attachment Title') + file = models.FileField( + upload_to=attachment_file_upload_to, + verbose_name='Attachment File' + ) + + file_size = models.PositiveIntegerField(verbose_name='File Size (in bytes)', null=True, blank=True) + + def save(self, *args, **kwargs): + # Calculate the file size before saving + if self.file and not self.file_size: + self.file_size = self.file.size + super().save(*args, **kwargs) + + + def __str__(self): + return f"{self.course.title} - {self.title}" + + class Meta: + ordering = ("-id",) + verbose_name = "Attachment" + verbose_name_plural = "Attachments" \ No newline at end of file diff --git a/apps/course/models/lesson.py b/apps/course/models/lesson.py new file mode 100644 index 0000000..b73b31f --- /dev/null +++ b/apps/course/models/lesson.py @@ -0,0 +1,106 @@ +import os +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from filer.fields.image import FilerImageField +from filer.fields.file import FilerFileField + +from apps.account.models import StudentUser + + +def lesson_file_upload_to(instance, filename): + return os.path.join(f"courses/{instance.course.slug}/lessons/{filename}") + + + + +class Lesson(models.Model): + class ContentTypeChoices(models.TextChoices): + YOUTUBE_LINK = 'youtube_link', 'Youtube Link' + VIDEO_FILE = 'video_file', 'Video File' + + course = models.ForeignKey("course.Course", on_delete=models.CASCADE, related_name='lessons', verbose_name='Course') + title = models.CharField(max_length=255, verbose_name='Lesson Title') + priority = models.IntegerField(null=True, blank=True, verbose_name='Priority') + is_active = models.BooleanField(default=True, verbose_name=_('Is Active')) + duration = models.PositiveIntegerField(verbose_name='Duration (in minutes)') + content_type = models.CharField(max_length=50, choices=ContentTypeChoices.choices, verbose_name='Content Type') + content_file = models.FileField( + null=True, + blank=True, + upload_to=lesson_file_upload_to, + ) + video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name='Link') + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At")) + + + def __str__(self): + return f"{self.course.title} - {self.title}" + + def is_completed_by(self, student): + return self.completions.filter(student=student).exists() + + + def save(self, *args, **kwargs): + print(f'---> start') + if self.priority is None: + # If priority is not set, set it to the next available priority + max_priority = self.course.lessons.aggregate(max_priority=models.Max('priority'))['max_priority'] + self.priority = (max_priority or 0) + 1 + else: + self._adjust_priorities() + super().save(*args, **kwargs) + + + def _adjust_priorities(self): + # Adjust priorities of other lessons in the course + lessons = self.course.lessons.exclude(pk=self.pk) + # Shift priorities for lessons with the same or higher priority + lessons.filter(priority__gte=self.priority).update(priority=models.F('priority') + 1) + + + # # If priority is set, adjust the priorities of other lessons + # lessons = self.course.lessons.exclude(pk=self.pk).order_by('priority') + + # updated_priorities = [] + # inserted = False + + # for lesson in lessons: + # if lesson.priority >= self.priority and not inserted: + # updated_priorities.append((self.priority, self)) + # inserted = True + # updated_priorities.append((lesson.priority if not inserted else lesson.priority + 1, lesson)) + + # if not inserted: + # updated_priorities.append((self.priority, self)) + + # # Update priorities in bulk + # for priority, lesson in updated_priorities: + # lesson.priority = priority + # lesson.save(update_fields=['priority']) + + + +class LessonCompletion(models.Model): + student = models.ForeignKey( + StudentUser, + on_delete=models.CASCADE, + related_name='lesson_completions' + ) + lesson = models.ForeignKey( + Lesson, + on_delete=models.CASCADE, + related_name='completions' + ) + completed_at = models.DateTimeField(auto_now_add=True) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + + class Meta: + unique_together = ('student', 'lesson') + + def __str__(self): + return f"{self.student.fullname} - {self.lesson.title} - Completed" + + \ No newline at end of file diff --git a/apps/course/models/participant.py b/apps/course/models/participant.py new file mode 100644 index 0000000..eee680a --- /dev/null +++ b/apps/course/models/participant.py @@ -0,0 +1,24 @@ + +from django.db import models + + +from apps.account.models import StudentUser, User +from apps.course.models import Course + + +class Participant(models.Model): + student = models.ForeignKey( + StudentUser, + on_delete=models.CASCADE, + related_name='participated_courses' + ) + course = models.ForeignKey( + Course, + on_delete=models.CASCADE, + related_name='participants' + ) + joined_date = models.DateTimeField(auto_now_add=True) + unread_messages_count = models.IntegerField(default=0) + + class Meta: + unique_together = ('student', 'course') \ No newline at end of file diff --git a/apps/course/serializers/__init__.py b/apps/course/serializers/__init__.py new file mode 100644 index 0000000..e86b7ee --- /dev/null +++ b/apps/course/serializers/__init__.py @@ -0,0 +1,3 @@ +from .course import * +from .lesson import * +from .participant import * \ No newline at end of file diff --git a/apps/course/serializers/course.py b/apps/course/serializers/course.py new file mode 100644 index 0000000..cba2e5f --- /dev/null +++ b/apps/course/serializers/course.py @@ -0,0 +1,247 @@ +from rest_framework import serializers + +# from dj_filer.admin import get_thumbs +from utils import get_thumbs +from apps.course.models import Course, CourseCategory, Attachment, Glossary, LessonCompletion, Participant, Lesson +from apps.chat.models import RoomMessage +from apps.account.serializers import UserProfileSerializer + + + +class CourseCategorySerializer(serializers.ModelSerializer): + course_count = serializers.SerializerMethodField() + + class Meta: + model = CourseCategory + fields = ['name', 'slug', 'course_count'] + + def get_course_count(self, obj): + return obj.course_count + + +class CourseListSerializer(serializers.ModelSerializer): + category = CourseCategorySerializer() + thumbnail = serializers.SerializerMethodField() + participant_count = serializers.SerializerMethodField() + lessons_count = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = [ + 'id', + 'title', + 'slug', + 'participant_count', + 'category', + 'thumbnail', + 'is_online', + 'online_link', + 'level', + 'duration', + 'lessons_count', + 'short_description', + 'status', + 'is_free', + 'price', + 'discount_percentage', + 'final_price', + ] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + def get_participant_count(self, obj): + return obj.participants.count() + + def get_lessons_count(self, obj): + lessons_count = obj.lessons.filter(is_active=True).count() + return max(lessons_count, obj.lessons_count) + + + + + +class CourseDetailSerializer(serializers.ModelSerializer): + category = CourseCategorySerializer() + professor = UserProfileSerializer() + thumbnail = serializers.SerializerMethodField() + participant_count = serializers.SerializerMethodField() + access = serializers.SerializerMethodField() + lessons_complated_count = serializers.SerializerMethodField() + lessons_count = serializers.SerializerMethodField() + last_lesson_id = serializers.SerializerMethodField() + room_id = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = [ + 'id', + 'title', + 'slug', + 'category', + 'access', + 'participant_count', + 'professor', + 'thumbnail', + 'video_type', + 'video_file', + 'video_link', + 'is_online', + 'online_link', + 'level', + 'description', + 'duration', + 'lessons_count', + 'lessons_complated_count', + 'short_description', + 'status', + 'is_free', + 'price', + 'discount_percentage', + 'final_price', + 'timing', + 'features', + 'last_lesson_id', + 'room_id', + ] + + def get_room_id(self, obj): + room_message = RoomMessage.objects.filter(course=obj).first() + if room_message: + return room_message.id + return None + + def get_last_lesson_id(self, obj): + request = self.context.get('request') + if request and request.user.is_authenticated: + user = request.user + + # آخرین درس تکمیل‌شده توسط کاربر + last_completed_lesson = LessonCompletion.objects.filter( + student=user, + lesson__course=obj + ).order_by('-completed_at').first() + + if last_completed_lesson: + # پیدا کردن درس بعدی بر اساس priority + next_lesson = Lesson.objects.filter( + course=obj, + priority__gt=last_completed_lesson.lesson.priority, + is_active=True + ).order_by('priority').first() + if not next_lesson: + next_lesson = Lesson.objects.filter( + course=obj, + is_active=True + ).order_by('priority').first() + if next_lesson: + return next_lesson.id + return None + + + + def get_access(self, obj): + if student := self._get_authenticated_user(): + if not self._is_participant(student, obj): + return False + return True + return False + + def get_is_professor(self, obj): + if professor := self._get_authenticated_user(): + return obj.professor == professor + return False + + def get_lessons_count(self, obj): + lessons_count = obj.lessons.filter(is_active=True).count() + return max(lessons_count, obj.lessons_count) + + + def get_lessons_complated_count(self, obj): + if student := self._get_authenticated_user(): + if not self._is_participant(student, obj): + return None + return self._get_completed_lessons_count(student, obj) + return None + + def _is_participant(self, student, course): + """Helper method to check if a student is a participant in the given course.""" + return Participant.objects.filter(student=student, course=course).exists() + + def _get_authenticated_user(self): + """Helper method to retrieve the authenticated user from the context.""" + request = self.context.get('request') + return request.user if request and request.user.is_authenticated else None + + def _get_completed_lessons_count(self, student, course): + """Helper method to count completed lessons for the student in the given course.""" + return LessonCompletion.objects.filter( + student=student, + lesson__course=course + ).count() + + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + def get_participant_count(self, obj): + return obj.participants.count() + + + +class MyCourseListSerializer(serializers.ModelSerializer): + category = CourseCategorySerializer() + thumbnail = serializers.SerializerMethodField() + lessons_complated_count = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = [ + 'id', + 'title', + 'slug', + 'category', + 'thumbnail', + 'lessons_count', + 'lessons_complated_count', + 'short_description', + 'status', + ] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + def get_lessons_complated_count(self, obj): + if student := self._get_authenticated_user(): + if not self._is_participant(student, obj): + return None + return self._get_completed_lessons_count(student, obj) + return None + + def _is_participant(self, student, course): + """Helper method to check if a student is a participant in the given course.""" + return Participant.objects.filter(student=student, course=course).exists() + + def _get_authenticated_user(self): + """Helper method to retrieve the authenticated user from the context.""" + request = self.context.get('request') + return request.user if request and request.user.is_authenticated else None + + def _get_completed_lessons_count(self, student, course): + """Helper method to count completed lessons for the student in the given course.""" + return LessonCompletion.objects.filter( + student=student, + lesson__course=course + ).count() + + +class AttachmentSerializer(serializers.ModelSerializer): + class Meta: + model = Attachment + fields = ['id', 'title', 'file', 'file_size'] + + +class GlossarySerializer(serializers.ModelSerializer): + class Meta: + model = Glossary + fields = ['id', 'title', 'description'] \ No newline at end of file diff --git a/apps/course/serializers/lesson.py b/apps/course/serializers/lesson.py new file mode 100644 index 0000000..c90f900 --- /dev/null +++ b/apps/course/serializers/lesson.py @@ -0,0 +1,57 @@ +from rest_framework import serializers +from apps.course.models import Lesson, Participant, LessonCompletion +from apps.quiz.serializers import QuizListSerializer + + + + + +class LessonSerializer(serializers.ModelSerializer): + is_complated = serializers.SerializerMethodField() + quizs = serializers.SerializerMethodField() + permission = serializers.SerializerMethodField() + + class Meta: + model = Lesson + fields = ['id', 'title', 'priority', 'is_active', 'permission','duration', 'content_type', 'content_file', 'video_link', 'is_complated', 'quizs'] + + def get_permission(self, obj): + if student := self._get_authenticated_user(): + if not self._is_participant(student, obj.course): + return False + return True + return False + + def _get_authenticated_user(self): + """Helper method to retrieve the authenticated user from the context.""" + request = self.context.get('request') + return request.user if request and request.user.is_authenticated else None + + def _is_participant(self, student, course): + """Helper method to check if a student is a participant in the given course.""" + return Participant.objects.filter(student=student, course=course).exists() + + def get_is_complated(self, obj): + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return False + user = request.user + is_participant = Participant.objects.filter( + student=user, + course=obj.course + ).exists() + + if not is_participant: + return False + + return LessonCompletion.objects.filter( + student=user, + lesson=obj + ).exists() + + + def get_quizs(self, obj): + quizzes = obj.quizzes.all() # استفاده از related_name 'quizzes' برای دسترسی به کوییزهای درس + if quizzes.exists(): + return QuizListSerializer(quizzes, many=True, context=self.context).data + return None diff --git a/apps/course/serializers/participant.py b/apps/course/serializers/participant.py new file mode 100644 index 0000000..8a00bc2 --- /dev/null +++ b/apps/course/serializers/participant.py @@ -0,0 +1,17 @@ +from rest_framework import serializers + + +from apps.course.models import Lesson, Participant, LessonCompletion +from apps.account.models import StudentUser, User + + + + +class ParticipantSerializer(serializers.ModelSerializer): + email = serializers.EmailField(required=True) + gender = serializers.ChoiceField(choices=User.GenderChoices.choices, required=True) + + + class Meta: + model = StudentUser + fields = ['fullname' , 'phone_number', 'gender', 'email', 'birthdate'] diff --git a/apps/course/signals.py b/apps/course/signals.py new file mode 100644 index 0000000..d0edf96 --- /dev/null +++ b/apps/course/signals.py @@ -0,0 +1,19 @@ + +from django.db.models.signals import post_save +from django.dispatch import receiver +from apps.course.models import Course +from apps.chat.models import RoomMessage + + +@receiver(post_save, sender=Course) +def create_room_message_for_course(sender, instance, created, **kwargs): + if created: # فقط برای موارد جدید اجرا شود + RoomMessage.objects.create( + name=f"{instance.title} - Group", + description=f"Group chat for course: {instance.title}", + initiator=instance.professor, # استاد به‌عنوان سازنده اتاق + course=instance, + room_type=RoomMessage.RoomTypeChoices.GROUP + ) + + \ No newline at end of file diff --git a/apps/course/templates/course/add_student_form.html b/apps/course/templates/course/add_student_form.html new file mode 100644 index 0000000..ecd1d31 --- /dev/null +++ b/apps/course/templates/course/add_student_form.html @@ -0,0 +1,29 @@ +{% extends "admin/base_site.html" %} + +{% load i18n unfold %} + +{% block breadcrumbs %}{% endblock %} + +{% block extrahead %} + {{ block.super }} + + {{ form.media }} +{% endblock %} + +{% block content %} +
+
+ {% csrf_token %} + + {% for field in form %} + {% include "unfold/helpers/field.html" with field=field %} + {% endfor %} +
+ +
+ {% component "unfold/components/button.html" with submit=1 %} + {% trans "Submit form" %} + {% endcomponent %} +
+
+{% endblock %} diff --git a/apps/course/tests.py b/apps/course/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/course/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/course/urls.py b/apps/course/urls.py new file mode 100644 index 0000000..ff96734 --- /dev/null +++ b/apps/course/urls.py @@ -0,0 +1,25 @@ + +from django.urls import path + +from . import views + + + +urlpatterns = [ + path('categories/', views.CourseCategoryAPIView.as_view(), name='course-categories'), + path('', views.CourseListAPIView.as_view(), name='course-list'), + path('my-courses/', views.MyCourseListAPIView.as_view(), name='course-my-courses-list'), + path('lesson/completion/', views.LessonCompletionCreateAPIView.as_view(), name='lesson-completion'), + + path('/', views.CourseDetailAPIView.as_view(), name='course-detail'), + path('/attachments/', views.AttachmentListAPIView.as_view(), name='course-attachment-list'), + path('/glossaries/', views.GlossaryListAPIView.as_view(), name='course-glossary-list'), + path('/lessons/', views.LessonListView.as_view(), name='course-lesson-list'), + path('lesson//', views.LessonDetailView.as_view(), name='lesson-detail'), + + path('/participants/', views.CourseParticipantsView.as_view(), name='course-participant-list'), + + + # path('/participant/join/', views.ParticipantCreateView.as_view(), name='course-participant-join'), + +] diff --git a/apps/course/views/__init__.py b/apps/course/views/__init__.py new file mode 100644 index 0000000..e86b7ee --- /dev/null +++ b/apps/course/views/__init__.py @@ -0,0 +1,3 @@ +from .course import * +from .lesson import * +from .participant import * \ No newline at end of file diff --git a/apps/course/views/course.py b/apps/course/views/course.py new file mode 100644 index 0000000..5b46165 --- /dev/null +++ b/apps/course/views/course.py @@ -0,0 +1,220 @@ +from rest_framework.generics import ListAPIView, RetrieveAPIView +from django.db.models import Count, Q, F +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from rest_framework.exceptions import NotFound +from rest_framework.permissions import IsAuthenticated +from rest_framework.filters import SearchFilter + + +from apps.course.serializers import ( + CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer, + AttachmentSerializer, GlossarySerializer, MyCourseListSerializer +) +from apps.course.models import Course, CourseCategory, Attachment, Glossary, Participant +from apps.course.doc import * + + +class CourseCategoryAPIView(ListAPIView): + queryset = CourseCategory.objects.all() + serializer_class = CourseCategorySerializer + + @swagger_auto_schema( + operation_description=doc_course_category(), + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + + + +class CourseListAPIView(ListAPIView): + queryset = Course.objects.all().exclude(status=Course.StatusChoices.INACTIVE) + serializer_class = CourseListSerializer + filter_backends = [SearchFilter] + search_fields = ['title'] + + @swagger_auto_schema( + operation_description=doc_course_list(), + manual_parameters=[ + openapi.Parameter( + 'category_slug', openapi.IN_QUERY, + description="Category of the Course", + type=openapi.TYPE_STRING, + # enum=[category.slug for category in CourseCategory.objects.all()] + ), + openapi.Parameter( + 'status', openapi.IN_QUERY, + type=openapi.TYPE_STRING, + description="""Status => + Upcoming (visible but registration not allowed)---Предстоящие + Registering (registration is open)---регистрация + Ongoing (course has started, registration closed)---Впроцессе + Finished (course has ended)---закончился + """, + enum=[status for status in ['upcoming', 'registering', 'ongoing', 'finished']] + ), + openapi.Parameter( + 'is_free', openapi.IN_QUERY, + description="Ценообразование is_free ", + type=openapi.TYPE_BOOLEAN, + ), + openapi.Parameter( + 'is_online', openapi.IN_QUERY, + description="Статус участия is_online ", + 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 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) + + + \ No newline at end of file diff --git a/apps/course/views/lesson.py b/apps/course/views/lesson.py new file mode 100644 index 0000000..a4958a3 --- /dev/null +++ b/apps/course/views/lesson.py @@ -0,0 +1,144 @@ +from rest_framework.generics import ListAPIView, RetrieveAPIView, GenericAPIView + +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.response import Response + +from apps.course.serializers import ( + LessonSerializer +) +from apps.course.models import Course, Lesson, LessonCompletion +from apps.course.doc import * +from utils.exceptions import AppAPIException +from rest_framework.permissions import IsAuthenticated + + + +class LessonListView(ListAPIView): + serializer_class = LessonSerializer + queryset = Lesson.objects.filter(is_active=True) + + @swagger_auto_schema( + operation_description=doc_courses_lesson(), + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + course_slug = self.kwargs.get('slug') + course = get_object_or_404(Course, slug=course_slug) + course = Course.objects.filter(slug=course_slug).first() + if not course: + raise AppAPIException({"message": "course not found"}, status_code=status.HTTP_404_NOT_FOUND) + + return self.queryset.filter(course=course).order_by('priority','id') + + + + +class LessonDetailView(RetrieveAPIView): + serializer_class = LessonSerializer + + def get(self, request, *args, **kwargs): + lesson_id = self.kwargs.get('id') + lesson = get_object_or_404(Lesson, id=lesson_id, is_active=True) + + course = lesson.course + lessons = Lesson.objects.filter(course=course, is_active=True).order_by('priority') + + total_lessons = lessons.count() + current_lesson_number = list(lessons.values_list('id', flat=True)).index(lesson.id) + 1 + next_lesson = lessons.filter(priority__gt=lesson.priority).order_by('priority').first() + next_lesson_id = next_lesson.id if next_lesson else None + previous_lesson = lessons.filter(priority__lt=lesson.priority).order_by('-priority').first() + previous_lesson_id = previous_lesson.id if previous_lesson else None + + lesson_data = self.get_serializer(lesson).data + lesson_data['total_lessons'] = total_lessons + lesson_data['current_lesson_number'] = current_lesson_number + lesson_data['next_lesson_id'] = next_lesson_id + lesson_data['previous_lesson_id'] = previous_lesson_id + lesson_data['can_go_next'] = next_lesson is not None + + + + + # # Get the next and previous lessons based on priority and id + # next_lesson = Lesson.objects.filter( + # course=lesson.course, + # is_active=True, + # priority__gte=lesson.priority, + # id__gt=lesson.id + # ).order_by('priority', 'id').first() + + # previous_lesson = Lesson.objects.filter( + # course=lesson.course, + # is_active=True, + # priority__lte=lesson.priority, + # id__lt=lesson.id + # ).order_by('-priority', '-id').first() + + # total_lessons = Lesson.objects.filter(course=lesson.course, is_active=True).count() + # # Calculate the current lesson number in the course + # current_lesson_number = Lesson.objects.filter( + # course=lesson.course, + # is_active=True, + # priority__lte=lesson.priority + # ).count() + + # # Serialize the current lesson + # lesson_data = self.get_serializer(lesson).data + # # Add current lesson number and total lessons + # lesson_data['current_lesson_number'] = current_lesson_number + # lesson_data['total_lessons'] = total_lessons + + # # Add next and previous lesson ids + # lesson_data['next_lesson_id'] = next_lesson.id if next_lesson else None + # lesson_data['previous_lesson_id'] = previous_lesson.id if previous_lesson else None + + + return Response(lesson_data) + + + +class LessonCompletionCreateAPIView(GenericAPIView): + permission_classes = [IsAuthenticated] + + + @swagger_auto_schema( + request_body=openapi.Schema( + type=openapi.TYPE_OBJECT, + required=['lesson_id'], + properties={ + 'lesson_id': openapi.Schema(type=openapi.TYPE_INTEGER, description='ID of the lesson to be marked as completed'), + }, + ), + responses={ + 201: 'Lesson completed successfully.', + 200: 'Lesson already completed.', + 400: 'Lesson ID is required.', + 404: 'Lesson not found.', + } + ) + def post(self, request): + student = request.user # Assuming the user is the student + lesson_id = request.data.get('lesson_id') + + if not lesson_id: + return Response({'error': 'Lesson ID is required.'}, status=status.HTTP_400_BAD_REQUEST) + try: + lesson = Lesson.objects.get(id=lesson_id) + except Lesson.DoesNotExist: + return Response({'error': 'Lesson not found.'}, status=status.HTTP_404_NOT_FOUND) + + # Check if the lesson is already completed by the student + if LessonCompletion.objects.filter(student=student, lesson=lesson).exists(): + return Response({'message': 'Lesson already completed.'}, status=status.HTTP_200_OK) + + # Create a new completion record + completion = LessonCompletion(student=student, lesson=lesson) + completion.save() + + return Response({'message': 'Lesson completed successfully.'}, status=status.HTTP_201_CREATED) \ No newline at end of file diff --git a/apps/course/views/participant.py b/apps/course/views/participant.py new file mode 100644 index 0000000..a89e9ae --- /dev/null +++ b/apps/course/views/participant.py @@ -0,0 +1,61 @@ + +from rest_framework import generics +from rest_framework.exceptions import NotFound +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from rest_framework.permissions import IsAuthenticated + +from apps.account.models import StudentUser +from apps.course.models import Participant, Course +from apps.course.serializers import ParticipantSerializer +from apps.account.serializers import UserProfileSerializer +from apps.course.doc import * +from utils.exceptions import AppAPIException + + + +class CourseParticipantsView(generics.ListAPIView): + serializer_class = UserProfileSerializer + + @swagger_auto_schema( + operation_description=doc_course_participants(), + ) + def get_queryset(self): + course_slug = self.kwargs.get('slug') + try: + course = Course.objects.get(slug=course_slug) + except Course.DoesNotExist: + raise AppAPIException({'message': "Course not found"}) # Handle course not found + + return StudentUser.objects.filter(participated_courses__course=course) + + + + +# class ParticipantCreateView(generics.CreateAPIView): +# queryset = StudentUser.objects.all() +# serializer_class = ParticipantSerializer +# permission_classes = [IsAuthenticated] + + +# def create(self, request, *args, **kwargs): +# user = request.user +# course_slug = self.kwargs.get('slug') # Get the slug from the URL +# try: +# course = Course.objects.get(slug=slug) # Retrieve the Course object +# except Course.DoesNotExist: +# raise AppAPIException({'message': "Course not found"}) # Handle course not found + +# if request.data.get('email') != request.user: +# raise AppAPIException({'message': "The email must be for the requesting user"}) + +# if user.user_type != User.UserType.STUDENT: +# user.change_user_type(User.UserType.STUDENT) + +# participant, created = Participant.objects.get_or_create( +# student=user, +# course=course +# ) + +# serializer = self.get_serializer(participant) +# return Response(serializer.data, status=status.HTTP_201_CREATED) \ No newline at end of file diff --git a/apps/hadis/__init__.py b/apps/hadis/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/hadis/admin/__init__.py b/apps/hadis/admin/__init__.py new file mode 100644 index 0000000..ba8ce70 --- /dev/null +++ b/apps/hadis/admin/__init__.py @@ -0,0 +1,3 @@ +from .category import * +from .hadis import * +from .transmitter import * \ No newline at end of file diff --git a/apps/hadis/admin/category.py b/apps/hadis/admin/category.py new file mode 100644 index 0000000..5524941 --- /dev/null +++ b/apps/hadis/admin/category.py @@ -0,0 +1,222 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from django.http import JsonResponse +from django.urls import path +from django.db import models +from django.contrib import messages +from django.http import HttpResponseRedirect +from django.urls import reverse + +from unfold.admin import ModelAdmin +from unfold.decorators import display + +from dj_category.admin import BaseCategoryAdmin +from ajaxdatatable.admin import AjaxDatatable +from django.db.models import Case, When, Value +from django.utils.html import format_html + +from apps.hadis.models import HadisCategory +from utils.admin import project_admin_site + + +@admin.register(HadisCategory) +class HadisCategoryAdmin(BaseCategoryAdmin, ModelAdmin): + change_form_template = 'admin/hadiscategory/change_form.html' + change_list_template = 'admin/category_index.html' + + fieldsets = ( + (None, { + 'fields': ('name', 'source_type', 'category_type', 'parent', 'is_active', 'order'), + 'classes': ('unfold-fieldset',), + }), + ) + + search_fields = ['name'] + list_display = ['name', 'source_type_badge', 'category_type', 'parent', 'is_active', 'order'] + list_filter = ['source_type', 'category_type', 'is_active'] + + @display(description=_("Source Type")) + def source_type_badge(self, obj): + badge_classes = { + 'quran': 'unfold-badge unfold-badge--success', + 'hadith': 'unfold-badge unfold-badge--info', + 'book': 'unfold-badge unfold-badge--warning', + # Add more source types as needed + } + badge_class = badge_classes.get(obj.source_type, 'unfold-badge') + return format_html('{}', 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) diff --git a/apps/hadis/admin/hadis.py b/apps/hadis/admin/hadis.py new file mode 100644 index 0000000..444918c --- /dev/null +++ b/apps/hadis/admin/hadis.py @@ -0,0 +1,161 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from dj_category.admin import BaseCategoryAdmin +from ajaxdatatable.admin import AjaxDatatable +from django.http import JsonResponse +from django.urls import path +from django.db.models import Q +from django.utils.safestring import mark_safe +from django.forms.widgets import RadioSelect + +from apps.hadis.models import * +from django import forms +from utils.json_editor_field import JsonEditorWidget + +# Define color choices +COLOR_CHOICES = [ + ('red', _('Red')), + ('blue', _('Blue')), + ('green', _('Green')), + ('yellow', _('Yellow')), + ('orange', _('Orange')), + ('purple', _('Purple')), + ('pink', _('Pink')), + ('brown', _('Brown')), + ('gray', _('Gray')), + ('black', _('Black')), +] + +class ColorRadioSelect(RadioSelect): + template_name = 'admin/widgets/color_radio.html' + option_template_name = 'admin/widgets/color_radio_option.html' + + +def get_links_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Link')), + 'properties': { + 'text': {'type': 'string', "format": "textarea",'title': str(_('text'))}, + 'link': {'type': 'string', "format": "textarea", 'title': str(_('link'))}, + } + } + } + +class HadisOverviewForm(forms.ModelForm): + status_color = forms.ChoiceField( + choices=COLOR_CHOICES, + widget=ColorRadioSelect(), + required=False + ) + + class Meta: + model = HadisOverview + fields = '__all__' + widgets = { + 'links': JsonEditorWidget(attrs={'schema': get_links_schema}), + } + + + + + +@admin.register(HadisTag) +class HadisTagAdmin(AjaxDatatable): + list_display = ['title', 'status'] + search_fields = ['title'] + + +class ReferenceImageInline(admin.TabularInline): + model = ReferenceImage + extra = 1 + verbose_name_plural = _('Reference Images') + fields = ('thumbnail', 'priority') + + +@admin.register(HadisReference) +class HadisReferenceAdmin(AjaxDatatable): + list_display = ['hadis', 'book', 'created_at'] + list_filter = ['book'] + search_fields = ['hadis__title', 'hadis__number', 'description'] + autocomplete_fields = ['hadis', 'book'] + readonly_fields = ['created_at'] + inlines = [ReferenceImageInline] + fieldsets = ( + (None, { + 'fields': ('hadis', 'book', 'description') + }), + ) + + + + +@admin.register(HadisOverview) +class HadisOverviewAdmin(AjaxDatatable): + change_form_template = 'admin/hadisowerview_change_form.html' + form = HadisOverviewForm + ordering = ['hadis__number'] + list_display = ['hadis', 'status', 'created_at'] + search_fields = ['hadis__title', 'hadis__number', 'status_text',] + autocomplete_fields = ['hadis', 'tags'] + fieldsets = ( + (None, { + 'fields': ('hadis', 'status', 'status_color', 'status_text') + }), + (_('Reference Information'), { + 'fields': ('address', 'share_link',), + }), + (_('Additional Information'), { + 'fields': ('links', 'tags', 'created_at'), + 'classes': ('collapse',), + }), + ) + + +class HadisOverviewInline(admin.StackedInline): + change_form_template = 'admin/hadisowerview_change_form.html' + form = HadisOverviewForm + model = HadisOverview + autocomplete_fields = ['tags', ] + can_delete = False + verbose_name_plural = _('Hadis Overview') + fieldsets = ( + (None, { + 'fields': ('status', 'status_color', 'status_text', 'address', 'share_link', 'links', 'tags',), + }), + ) + extra = 1 + min_num = 1 + + +@admin.register(Hadis) +class HadisAdmin(AjaxDatatable): + # form = HadisForm + list_display = ['number', 'title', 'category', 'status', 'created_at'] + list_filter = ['status', 'category'] + search_fields = ['title', 'text', 'number'] + readonly_fields = ['created_at', 'updated_at'] + autocomplete_fields = ['category'] + inlines = [HadisOverviewInline] + fieldsets = ( + (None, { + 'fields': ('number', 'title', 'category', 'status') + }), + (_('Content'), { + 'fields': ('text', 'translation'), + 'classes': ('collapse',), + }), + ) + + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + if obj is None: + form.base_fields['category'].widget.can_add_related = False + + return form + diff --git a/apps/hadis/admin/transmitter.py b/apps/hadis/admin/transmitter.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/hadis/apps.py b/apps/hadis/apps.py new file mode 100644 index 0000000..47fcf3d --- /dev/null +++ b/apps/hadis/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class HadisConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.hadis' diff --git a/apps/hadis/doc.py b/apps/hadis/doc.py new file mode 100644 index 0000000..c2dda6f --- /dev/null +++ b/apps/hadis/doc.py @@ -0,0 +1,452 @@ +""" +Swagger documentation for the Hadis API endpoints. + +This module provides Swagger documentation for the Hadis API endpoints using drf-yasg. +It defines the request parameters, response schemas, and decorators for the views. +""" + +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema + +from apps.hadis.models import HadisCategory + +# Parameter definitions +source_type_param = openapi.Parameter( + 'source_type', + openapi.IN_QUERY, + description="Filter categories by source type (shia or sunni)", + type=openapi.TYPE_STRING, + enum=[HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI], + required=False +) + +# Response schemas +tag_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the tag" + ), + 'title': openapi.Schema( + type=openapi.TYPE_STRING, + description="Title of the tag" + ) + }, + required=['id', 'title'] +) + +category_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the category" + ), + 'name': openapi.Schema( + type=openapi.TYPE_STRING, + description="Name of the category" + ), + 'hadis_count': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Number of hadis items in this category" + ), + 'source_type': openapi.Schema( + type=openapi.TYPE_STRING, + enum=[HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI], + description="Source type of the category (shia or sunni)" + ), + 'category_type': openapi.Schema( + type=openapi.TYPE_STRING, + enum=[HadisCategory.ContentType.QURAN, HadisCategory.ContentType.HADITH], + description="Content type of the category (quran or hadith)", + nullable=True + ), + 'children': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=openapi.Schema(type=openapi.TYPE_OBJECT), # Recursive reference + description="List of child categories" + ) + }, + required=['id', 'name', 'hadis_count', 'source_type', 'children'] +) + +categories_response = openapi.Response( + description="Tree structure of hadis categories", + schema=openapi.Schema( + type=openapi.TYPE_ARRAY, + items=category_schema + ) +) + +hadis_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'number': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique number identifier for the hadis" + ), + 'title': openapi.Schema( + type=openapi.TYPE_STRING, + description="Title of the hadis" + ), + 'text': openapi.Schema( + type=openapi.TYPE_STRING, + description="Original text of the hadis" + ), + 'translation': openapi.Schema( + type=openapi.TYPE_STRING, + description="Translation of the hadis text" + ), + 'tags': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=tag_schema, + description="List of tags associated with this hadis" + ) + }, + required=['number', 'title', 'text', 'translation', 'tags'] +) + +hadis_list_response = openapi.Response( + description="List of hadis items in the specified category", + schema=openapi.Schema( + type=openapi.TYPE_ARRAY, + items=hadis_schema + ) +) + +# Reference image schema +reference_image_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the reference image" + ), + 'thumbnail': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="ID of the thumbnail image", + nullable=True + ), + 'priority': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Priority of the image (lower values mean higher priority)" + ) + }, + required=['id', 'priority'] +) + +# Hadis reference schema +hadis_reference_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the hadis reference" + ), + 'book': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="ID of the referenced book", + nullable=True + ), + 'description': openapi.Schema( + type=openapi.TYPE_STRING, + description="Description of the reference", + nullable=True + ), + 'created_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Creation timestamp" + ), + 'images': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=reference_image_schema, + description="List of reference images" + ) + }, + required=['id', 'created_at', 'images'] +) + +# Hadis overview schema +hadis_overview_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'status': openapi.Schema( + type=openapi.TYPE_STRING, + description="Status of the hadis" + ), + 'status_color': openapi.Schema( + type=openapi.TYPE_STRING, + description="Display color for the status" + ), + 'status_text': openapi.Schema( + type=openapi.TYPE_STRING, + description="Descriptive text for the status", + nullable=True + ), + 'address': openapi.Schema( + type=openapi.TYPE_STRING, + description="Address information", + nullable=True + ), + 'links': openapi.Schema( + type=openapi.TYPE_OBJECT, + description="Related links" + ), + 'tags': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=tag_schema, + description="List of tags associated with this hadis" + ), + 'share_link': openapi.Schema( + type=openapi.TYPE_STRING, + description="Link for sharing the hadis", + nullable=True + ), + 'created_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Creation timestamp" + ) + }, + required=['status', 'status_color', 'tags', 'created_at'] +) + +# Hadis detail schema +hadis_detail_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the hadis" + ), + 'number': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique number identifier for the hadis" + ), + 'title': openapi.Schema( + type=openapi.TYPE_STRING, + description="Title of the hadis" + ), + 'text': openapi.Schema( + type=openapi.TYPE_STRING, + description="Original text of the hadis" + ), + 'translation': openapi.Schema( + type=openapi.TYPE_STRING, + description="Translation of the hadis text" + ), + 'status': openapi.Schema( + type=openapi.TYPE_BOOLEAN, + description="Visibility status of the hadis" + ), + 'created_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Creation timestamp" + ), + 'updated_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Last update timestamp" + ), + 'overview': hadis_overview_schema, + 'first_reference': hadis_reference_schema + }, + required=['id', 'number', 'title', 'text', 'translation', 'status', 'created_at', 'updated_at', 'overview'] +) + +hadis_detail_response = openapi.Response( + description="Detailed information about a specific hadis", + schema=hadis_detail_schema +) + +# Transmitter schema +transmitter_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the transmitter" + ), + 'full_name': openapi.Schema( + type=openapi.TYPE_STRING, + description="Full name of the transmitter" + ), + 'birth_year_hijri': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Birth year in Hijri calendar" + ), + 'death_year_hijri': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Death year in Hijri calendar" + ), + 'description': openapi.Schema( + type=openapi.TYPE_STRING, + description="Description of the transmitter", + nullable=True + ), + 'status': openapi.Schema( + type=openapi.TYPE_STRING, + description="Status of the transmitter" + ), + 'status_color': openapi.Schema( + type=openapi.TYPE_STRING, + description="Display color for the status" + ), + 'thumbnail': openapi.Schema( + type=openapi.TYPE_OBJECT, + description="Thumbnail image information", + nullable=True + ) + }, + required=['id', 'full_name', 'birth_year_hijri', 'death_year_hijri', 'status', 'status_color'] +) + +# Hadis transmitter schema +hadis_transmitter_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the hadis transmitter relation" + ), + 'transmitter': transmitter_schema, + 'description': openapi.Schema( + type=openapi.TYPE_STRING, + description="Description of the transmitter's role in this hadis", + nullable=True + ), + 'order': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Order in the chain of transmission" + ), + 'created_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Creation timestamp" + ) + }, + required=['id', 'transmitter', 'order', 'created_at'] +) + +# Update hadis detail schema to include transmitters +hadis_detail_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the hadis" + ), + 'number': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique number identifier for the hadis" + ), + 'title': openapi.Schema( + type=openapi.TYPE_STRING, + description="Title of the hadis" + ), + 'text': openapi.Schema( + type=openapi.TYPE_STRING, + description="Original text of the hadis" + ), + 'translation': openapi.Schema( + type=openapi.TYPE_STRING, + description="Translation of the hadis text" + ), + 'status': openapi.Schema( + type=openapi.TYPE_BOOLEAN, + description="Visibility status of the hadis" + ), + 'created_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Creation timestamp" + ), + 'updated_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Last update timestamp" + ), + 'overview': hadis_overview_schema, + 'first_reference': hadis_reference_schema, + 'transmitters': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=hadis_transmitter_schema, + description="List of transmitters for this hadis" + ) + }, + required=['id', 'number', 'title', 'text', 'translation', 'status', 'created_at', 'updated_at', 'overview'] +) + +hadis_detail_response = openapi.Response( + description="Detailed information about a specific hadis", + schema=hadis_detail_schema +) + +# Swagger decorators for views +category_list_swagger = swagger_auto_schema( + operation_id="list_hadis_categories", + operation_description=""" + Retrieve a hierarchical tree structure of hadis categories. + + This endpoint returns all hadis categories in a tree structure, with parent categories + containing their child categories. Each category includes its ID, name, source type, + category type, and the count of hadis items it contains. + + The response can be filtered by source type (shia or sunni) using the query parameter. + If no source type is specified, all categories are returned. + """, + operation_summary="List Hadis Categories", + tags=["Hadis"], + manual_parameters=[source_type_param], + responses={ + 200: categories_response, + 401: "Authentication credentials were not provided or are invalid.", + 500: "Internal server error occurred." + } +) + +category_hadis_list_swagger = swagger_auto_schema( + operation_id="list_hadis_in_category", + operation_description=""" + Retrieve a list of hadis items belonging to a specific category. + + This endpoint returns all hadis items that belong to the specified category. + Each hadis item includes its number, title, original text, translation, and associated tags. + + The category is specified by its ID in the URL path. + """, + operation_summary="List Hadis Items in Category", + tags=["Hadis"], + responses={ + 200: hadis_list_response, + 401: "Authentication credentials were not provided or are invalid.", + 404: "The specified category does not exist.", + 500: "Internal server error occurred." + } +) + +hadis_detail_swagger = swagger_auto_schema( + operation_id="get_hadis_detail", + operation_description=""" + Retrieve detailed information about a specific hadis. + + This endpoint returns comprehensive information about a hadis, including: + - Basic hadis details (number, title, text, translation) + - HadisOverview information (status, tags, etc.) + - The first HadisReference with its ReferenceImages + - List of Transmitters in order of transmission chain + + The hadis is specified by its ID in the URL path. + """, + operation_summary="Get Hadis Detail", + tags=["Hadis"], + responses={ + 200: hadis_detail_response, + 401: "Authentication credentials were not provided or are invalid.", + 404: "The specified hadis does not exist.", + 500: "Internal server error occurred." + } +) \ No newline at end of file diff --git a/apps/hadis/migrations/0001_initial.py b/apps/hadis/migrations/0001_initial.py new file mode 100644 index 0000000..8e1e108 --- /dev/null +++ b/apps/hadis/migrations/0001_initial.py @@ -0,0 +1,141 @@ +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import django.db.models.deletion +import filer.fields.image +import mptt.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('library', '0001_initial'), + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='HadisTag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=355, verbose_name='title')), + ('status', models.BooleanField(default=True, verbose_name='status')), + ], + ), + migrations.CreateModel( + name='HadisCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_active', models.BooleanField(default=True, verbose_name='is active')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('source_type', models.CharField(blank=True, choices=[('shia', 'Shia'), ('sunni', 'Sunni')], default='shia', max_length=10, verbose_name='Source Type')), + ('category_type', models.CharField(blank=True, choices=[('quran', 'Quran'), ('hadith', 'Hadith')], max_length=10, null=True, verbose_name='Category Content Type')), + ('name', models.CharField(max_length=355, verbose_name='name')), + ('order', models.IntegerField(default=0, verbose_name='order')), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='hadis.hadiscategory')), + ], + options={ + 'verbose_name': 'Hadis Category', + 'verbose_name_plural': 'Hadis Categories', + 'ordering': ('order',), + }, + ), + migrations.CreateModel( + name='Hadis', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('number', models.PositiveIntegerField(unique=True, verbose_name='number')), + ('title', models.CharField(max_length=355, verbose_name='title')), + ('text', models.TextField(verbose_name='text')), + ('translation', models.TextField(blank=True, default='', verbose_name='translation')), + ('status', models.BooleanField(default=True, verbose_name='visibility')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('category', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='hadis.hadiscategory', verbose_name='category')), + ], + options={ + 'verbose_name': 'hadis', + 'verbose_name_plural': 'hadises', + }, + ), + migrations.CreateModel( + name='HadisReference', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('book', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hadis_references', to='library.book', verbose_name='book')), + ('hadis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='references', to='hadis.hadis', verbose_name='hadis')), + ], + options={ + 'verbose_name': 'Hadis Reference', + 'verbose_name_plural': 'Hadis References', + 'unique_together': {('hadis', 'book')}, + }, + ), + migrations.CreateModel( + name='ReferenceImage', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('priority', models.IntegerField(default=0, help_text='Priority of the image, lower values mean higher priority.', verbose_name='Priority')), + ('reference', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='hadis.hadisreference', verbose_name='Hadis Reference')), + ('thumbnail', filer.fields.image.FilerImageField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to=settings.FILER_IMAGE_MODEL, verbose_name='thumbnail')), + ], + options={ + 'verbose_name': 'Reference Image', + 'verbose_name_plural': 'Reference Images', + }, + ), + migrations.CreateModel( + name='Transmitters', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('full_name', models.CharField(max_length=255)), + ('birth_year_hijri', models.IntegerField(verbose_name='Birth Year (Hijri)')), + ('death_year_hijri', models.IntegerField(verbose_name='Death Year (Hijri)')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), + ('status', models.CharField(max_length=50, verbose_name='status')), + ('status_color', models.CharField(max_length=25, verbose_name='Display Status Color')), + ('thumbnail', filer.fields.image.FilerImageField(blank=True, help_text='image allowed', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.FILER_IMAGE_MODEL)), + ], + ), + migrations.CreateModel( + name='HadisOverview', + fields=[ + ('hadis', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='hadis.hadis')), + ('status', models.CharField(max_length=50, verbose_name='status')), + ('status_color', models.CharField(max_length=25, verbose_name='Display Status Color')), + ('status_text', models.TextField(blank=True, null=True, verbose_name='Status Text')), + ('address', models.TextField(blank=True, null=True, verbose_name='address')), + ('links', models.JSONField(blank=True, default=dict, null=True, verbose_name='title')), + ('share_link', models.CharField(blank=True, max_length=255, null=True, verbose_name='share link')), + ('explanation', models.TextField(blank=True, null=True, verbose_name='explanation')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('tags', models.ManyToManyField(blank=True, related_name='hadises', to='hadis.hadistag', verbose_name='tags')), + ], + ), + migrations.CreateModel( + name='HadisTransmitter', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ('order', models.PositiveIntegerField(default=0, help_text='Order in the chain of transmission', verbose_name='Order')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('hadis', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transmitters', to='hadis.hadis', verbose_name='hadis')), + ('transmitter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='hadises', to='hadis.transmitters', verbose_name='transmitter')), + ], + options={ + 'verbose_name': 'Hadis Transmitter', + 'verbose_name_plural': 'Hadis Transmitters', + 'ordering': ('hadis', 'order'), + 'unique_together': {('hadis', 'transmitter', 'order')}, + }, + ), + ] diff --git a/apps/hadis/migrations/__init__.py b/apps/hadis/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/hadis/models/__init__.py b/apps/hadis/models/__init__.py new file mode 100644 index 0000000..ba8ce70 --- /dev/null +++ b/apps/hadis/models/__init__.py @@ -0,0 +1,3 @@ +from .category import * +from .hadis import * +from .transmitter import * \ No newline at end of file diff --git a/apps/hadis/models/category.py b/apps/hadis/models/category.py new file mode 100644 index 0000000..7fde53e --- /dev/null +++ b/apps/hadis/models/category.py @@ -0,0 +1,105 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ValidationError +from dj_category.models import BaseCategoryAbstract + + +class HadisCategory(BaseCategoryAbstract): + class SourceType(models.TextChoices): + SHIA = 'shia', _('Shia') + SUNNI = 'sunni', _('Sunni') + + class ContentType(models.TextChoices): + QURAN = 'quran', _('Quran') + HADITH = 'hadith', _('Hadith') + + class LevelChoices(models.IntegerChoices): + LEVEL_1 = 1, _('Level 1 (Root)') + LEVEL_2 = 2, _('Level 2 (Child)') + LEVEL_3 = 3, _('Level 3 (Grandchild)') + + source_type = models.CharField(max_length=10, choices=SourceType.choices, default=SourceType.SHIA, verbose_name=_('Source Type'), blank=True) + category_type = models.CharField(max_length=10, choices=ContentType.choices, verbose_name=_('Category Content Type'), blank=True, null=True) + name = models.CharField(max_length=355, verbose_name=_('name')) + order = models.IntegerField(default=0, verbose_name=_('order')) + slug = None + content_type = None + language = None + language_id = None + + # This field is not stored in the database, it's only used for the form + level_choice = None + + class Meta: + verbose_name = _('Hadis Category') + verbose_name_plural = _('Hadis Categories') + ordering = ('order',) + + def __str__(self): + return f'<{str(self.level_p)}>{self.name}' + + def __repr__(self): + return f'<{str(self.level_p)}>{self.name}' + + def clean(self): + super().clean() + + # Skip validation for new objects that haven't been saved yet + # This allows the admin form to set these values properly + if self.pk is None: + return + + # For existing objects, apply the validation rules + if self.level_p == 1 and self.category_type: + raise ValidationError(_("Level 1 cannot have content type")) + + if self.level_p == 2 and not self.category_type: + raise ValidationError(_("Level 2 must have content type")) + + if self.level_p == 3 and (self.source_type or self.category_type): + raise ValidationError(_("Level 3 cannot have source/content type")) + + + def save(self, *args, **kwargs): + self.clean() + + # Get the level from the parent structure + level = self.level_p + + # Apply level-specific logic + # if level == 2 and self.parent: + # For level 2, inherit source_type from parent + # self.source_type = self.parent.source_type + # elif level == 3: + # For level 3, inherit both from parent + # if self.parent and self.parent.parent: + # self.source_type = self.parent.source_type + # self.category_type = self.parent.category_type + + # Call the parent class's save method + super().save(*args, **kwargs) + + @property + def level_p(self): + if not self.parent: + return 1 + elif not self.parent.parent: + return 2 + else: + return 3 + def get_level_info(self): + info = { + 'level': self.level_p, + 'source_type': None, + 'category_type': None, + } + if self.level_p == 1: + info['source_type'] = self.source_type + elif self.level_p == 2: + info['source_type'] = self.parent.source_type + info['category_type'] = self.category_type + return info + + + + diff --git a/apps/hadis/models/hadis.py b/apps/hadis/models/hadis.py new file mode 100644 index 0000000..cbe94d9 --- /dev/null +++ b/apps/hadis/models/hadis.py @@ -0,0 +1,104 @@ + + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ValidationError +from filer.fields.image import FilerImageField + + +class HadisTag(models.Model): + title = models.CharField(max_length=355, verbose_name=_('title')) + status = models.BooleanField(default=True, verbose_name=_('status')) + + def __str__(self): + return f"{self.title}" + + + + +class Hadis(models.Model): + number = models.PositiveIntegerField(verbose_name=_('number'), unique=True) + title = models.CharField(max_length=355, verbose_name=_('title')) + text = models.TextField(verbose_name=_('text')) + translation = models.TextField(verbose_name=_('translation'), blank=True, default='') + + category = models.ForeignKey("hadis.HadisCategory", null=True, on_delete=models.SET_NULL, verbose_name=_('category'), ) + + 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')) + + def __str__(self): + return f"<{self.number}> {self.title[:32]}" + + @property + def get_tags(self): + return self.tags.all().order_by('hadistagrelation__priority') + + class Meta: + verbose_name = _('hadis') + verbose_name_plural = _('hadises') + + +class HadisOverview(models.Model): + hadis = models.OneToOneField(Hadis, on_delete=models.CASCADE, primary_key=True) + 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(verbose_name=_('Status Text'), null=True, blank=True) + address = models.TextField(verbose_name=_('address'), null=True, blank=True) + links = models.JSONField(verbose_name=_('title'), null=True, blank=True, default=dict) + tags = models.ManyToManyField("HadisTag", related_name="hadises", verbose_name=_('tags'), blank=True) + share_link = models.CharField(max_length=255, verbose_name=_('share link'), null=True, blank=True) + explanation = models.TextField(verbose_name=_('explanation'), null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + + +class HadisReference(models.Model): + hadis = models.ForeignKey( + Hadis, + on_delete=models.CASCADE, + verbose_name=_('hadis'), + related_name='references' + ) + book = models.ForeignKey("library.Book", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_('book'), related_name='hadis_references') + description = models.TextField(verbose_name=_('description'), blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + + class Meta: + verbose_name = _('Hadis Reference') + verbose_name_plural = _('Hadis References') + unique_together = ('hadis', 'book') + + def __str__(self): + return f'{self.hadis.number}-{self.book.title}' + +class ReferenceImage(models.Model): + reference = models.ForeignKey(HadisReference, verbose_name="Hadis Reference", on_delete=models.CASCADE) + thumbnail = FilerImageField( + related_name='+', on_delete=models.PROTECT, null=True, blank=True, + verbose_name=_('thumbnail') + ) + priority = models.IntegerField( + default=0, + verbose_name=_("Priority"), + help_text=_("Priority of the image, lower values mean higher priority.") + ) + + + class Meta: + verbose_name = _('Reference Image') + verbose_name_plural = _('Reference Images') + + def __str__(self): + return f'{self.reference.title}-{self.id}' + + def save(self, *args, **kwargs): + if ReferenceImage.objects.filter(reference=self.reference, priority=self.priority).exists(): + ReferenceImage.objects.filter( + reference=self.reference, + priority__gte=self.priority + ).update(priority=F('priority') + 1) + + super().save(*args, **kwargs) + diff --git a/apps/hadis/models/transmitter.py b/apps/hadis/models/transmitter.py new file mode 100644 index 0000000..5ec2e0b --- /dev/null +++ b/apps/hadis/models/transmitter.py @@ -0,0 +1,53 @@ + + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.core.exceptions import ValidationError +from filer.fields.image import FilerImageField + + + +class Transmitters(models.Model): + 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 = FilerImageField(related_name="+", on_delete=models.CASCADE, help_text=_( + 'image allowed' + ), null=True, blank=True) + + def __str__(self): + return self.full_name + + +class HadisTransmitter(models.Model): + hadis = models.ForeignKey( + "hadis.Hadis", + on_delete=models.CASCADE, + verbose_name=_('hadis'), + related_name='transmitters' + ) + transmitter = models.ForeignKey( + Transmitters, + on_delete=models.CASCADE, + verbose_name=_('transmitter'), + related_name='hadises' + ) + description = models.TextField(verbose_name=_('description'), blank=True, null=True) + order = models.PositiveIntegerField( + default=0, + verbose_name=_('Order'), + help_text=_('Order in the chain of transmission') + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + + class Meta: + verbose_name = _('Hadis Transmitter') + verbose_name_plural = _('Hadis Transmitters') + ordering = ('hadis', 'order') + unique_together = ('hadis', 'transmitter', 'order') + + def __str__(self): + return f'{self.hadis.number} - {self.transmitter.full_name} ({self.order})' diff --git a/apps/hadis/serializers.py b/apps/hadis/serializers.py new file mode 100644 index 0000000..5c43283 --- /dev/null +++ b/apps/hadis/serializers.py @@ -0,0 +1,127 @@ + +from rest_framework import serializers +from utils import get_thumbs +from apps.hadis.models import * + + +class HadisCategorySerializer(serializers.ModelSerializer): + children = serializers.SerializerMethodField('get_children') + name = serializers.SerializerMethodField() + hadis_count = serializers.SerializerMethodField() + source_type = serializers.CharField(read_only=True) + + + def get_children(self, obj): + return [self.to_dict(cat) for cat in obj.get_children()] + + def to_dict(self, c): + children = c.get_children() + + return { + 'id': c.id, + 'name': c.name, + 'hadis_count': c.hadis_count, + 'source_type': c.source_type, + 'category_type': c.category_type, + 'children': [] if not children else [self.to_dict(i) for i in children], + } + + class Meta: + model = HadisCategory + fields = ['id', 'name', 'hadis_count', 'source_type','children'] + + +class HadisTagSerializer(serializers.ModelSerializer): + class Meta: + model = HadisTag + fields = ('id', 'title') + + +class HadisSerializer(serializers.ModelSerializer): + class Meta: + model = Hadis + fields = ('number', 'title', 'text', 'translation',) + + +class ReferenceImageSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + + class Meta: + model = ReferenceImage + fields = ('id', 'thumbnail', 'priority') + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + +class HadisReferenceSerializer(serializers.ModelSerializer): + images = serializers.SerializerMethodField() + + class Meta: + model = HadisReference + fields = ('id', 'book', 'description', 'created_at', 'images') + + def get_images(self, obj): + return ReferenceImageSerializer( + obj.referenceimage_set.all(), + many=True, + context=self.context + ).data + + +class TransmittersSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + + class Meta: + model = Transmitters + fields = ('id', 'full_name', 'birth_year_hijri', 'death_year_hijri', + 'description', 'status', 'status_color', 'thumbnail') + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + +class HadisTransmitterSerializer(serializers.ModelSerializer): + transmitter = serializers.SerializerMethodField() + + class Meta: + model = HadisTransmitter + fields = ('id', 'transmitter', 'description', 'order', 'created_at') + + def get_transmitter(self, obj): + return TransmittersSerializer( + obj.transmitter, + context=self.context + ).data + + +class HadisOverviewSerializer(serializers.ModelSerializer): + tags = serializers.SerializerMethodField() + + class Meta: + model = HadisOverview + fields = ('status', 'status_color', 'status_text', 'address', 'links', 'tags', 'share_link', 'explanation', 'created_at') + + def get_tags(self, obj): + return HadisTagSerializer( + obj.tags.all(), + many=True, + context=self.context + ).data + + +class HadisDetailSerializer(serializers.ModelSerializer): + overview = HadisOverviewSerializer(source='hadisoverview', read_only=True) + reference = serializers.SerializerMethodField() + transmitters = HadisTransmitterSerializer(many=True, read_only=True) + + class Meta: + model = Hadis + fields = ('id', 'number', 'title', 'text', 'translation', 'status', + 'created_at', 'updated_at', 'overview', 'reference', 'transmitters') + + def get_reference(self, obj): + reference = obj.references.first() + if reference: + return HadisReferenceSerializer(reference, context=self.context).data + return None \ No newline at end of file diff --git a/apps/hadis/templates/admin/category_index.html b/apps/hadis/templates/admin/category_index.html new file mode 100644 index 0000000..a3653c0 --- /dev/null +++ b/apps/hadis/templates/admin/category_index.html @@ -0,0 +1,2343 @@ +{% extends 'admin/change_form.html' %} +{% load i18n static admin_modify mptt_tags %} + +{% block content %} +
+
+
+ +
+
+
{% trans "Category Tree Editor" %}
+
+
+

+ {% trans "Make your category and sort it by drag and drop . and try to edit items by double click." %} +

+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{% csrf_token %} + {% block form_top %}{% endblock %} +
+ {% block field_sets %} + + + +
+
+ + + +
+
+
+ + {% trans "Level 1 categories represent source types: Shia or Sunni" %} +
+ +
+
+
+ + {% trans "Level 2 categories are children of Shia/Sunni with content type: Quran or Hadith" %} +
+ +
+
+
+ + {% trans "Level 3 categories are children of Quran or Hadith categories" %} +
+ +
+
+
+
+ + +
+
+
+ + + + + {% trans "Select a parent category or leave empty for top-level category" %} + +
+
+
+ + {% for fieldset in adminform %} + {% include "admin/includes/fieldset.html" with fullwidth="" %} + {% endfor %} + + {% endblock %} +
+ {% include "admin/submit_line.html" with show_save_and_continue=True show_delete_link=True %} +
+
+
+
+
+
+ + + +
+{% endblock %} + +{% block scripts %} + {{ block.super }} + + + + + + + + + + + + + +{% endblock %} diff --git a/apps/hadis/templates/admin/hadiscategory/change_form.html b/apps/hadis/templates/admin/hadiscategory/change_form.html new file mode 100644 index 0000000..40424b1 --- /dev/null +++ b/apps/hadis/templates/admin/hadiscategory/change_form.html @@ -0,0 +1,42 @@ +{% extends 'admin/category_index.html' %} +{% load i18n admin_urls %} + +{% block extrahead %} +{{ block.super }} + +{% endblock %} + +{% block scripts %} +{{ block.super }} + +{% endblock %} \ No newline at end of file diff --git a/apps/hadis/templates/admin/hadisowerview_change_form.html b/apps/hadis/templates/admin/hadisowerview_change_form.html new file mode 100644 index 0000000..0ccd13b --- /dev/null +++ b/apps/hadis/templates/admin/hadisowerview_change_form.html @@ -0,0 +1,153 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} +{% load static %} + +{% block submit_buttons_bottom %} + {{ block.super }} + + + +{% endblock %} + +{% block scripts %} + {{ block.super }} + + + +{% endblock %} diff --git a/apps/hadis/templates/admin/widgets/color_radio.html b/apps/hadis/templates/admin/widgets/color_radio.html new file mode 100644 index 0000000..aa7786a --- /dev/null +++ b/apps/hadis/templates/admin/widgets/color_radio.html @@ -0,0 +1,7 @@ +{% for group, options, index in widget.optgroups %} + {% for option in options %} +
+ {% include option.template_name with widget=option %} +
+ {% endfor %} +{% endfor %} \ No newline at end of file diff --git a/apps/hadis/templates/admin/widgets/color_radio_option.html b/apps/hadis/templates/admin/widgets/color_radio_option.html new file mode 100644 index 0000000..7897608 --- /dev/null +++ b/apps/hadis/templates/admin/widgets/color_radio_option.html @@ -0,0 +1,9 @@ +{% if widget.wrap_label %} + +{% endif %} + + + {{ widget.label }} +{% if widget.wrap_label %} + +{% endif %} \ No newline at end of file diff --git a/apps/hadis/tests.py b/apps/hadis/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/hadis/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/hadis/urls.py b/apps/hadis/urls.py new file mode 100644 index 0000000..bf75802 --- /dev/null +++ b/apps/hadis/urls.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from . import views + + +urlpatterns = [ + path('categories/', views.CategoryListView.as_view(), name='category-list'), + + path('categories//hadis/', views.CategoryHadisListView.as_view(), name='category-hadis-list'), + path('/', views.HadisDetailView.as_view(), name='hadis-detail'), + + +] \ No newline at end of file diff --git a/apps/hadis/views/__init__.py b/apps/hadis/views/__init__.py new file mode 100644 index 0000000..b239bfe --- /dev/null +++ b/apps/hadis/views/__init__.py @@ -0,0 +1,3 @@ +from .category import * +from .hadis import * +# from .transmitter import * \ No newline at end of file diff --git a/apps/hadis/views/category.py b/apps/hadis/views/category.py new file mode 100644 index 0000000..df35e82 --- /dev/null +++ b/apps/hadis/views/category.py @@ -0,0 +1,301 @@ +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django.db.models import Subquery, Count, F, OuterRef, Q, Prefetch, Case, When, Value, IntegerField +from rest_framework.pagination import PageNumberPagination +from rest_framework.generics import ListAPIView +from django.core.cache import cache +from django.conf import settings +import hashlib +import json + + +from apps.hadis.models import * +from apps.hadis.serializers import * +from apps.hadis.doc import category_list_swagger, category_hadis_list_swagger + + +class CategoryPagination(PageNumberPagination): + page_size = 10 + page_size_query_param = 'page_size' + max_page_size = 100 + + +class CategoryListView(ListAPIView): + serializer_class = HadisCategorySerializer + permission_classes = (IsAuthenticated,) + pagination_class = CategoryPagination + # Cache timeout in seconds (1 hour) + CACHE_TIMEOUT = 60 * 60 + + def get_cache_key(self, source_type=None): + """ + Generate a unique cache key based on the view name and filter parameters. + + Args: + source_type: Optional source_type filter parameter + + Returns: + A unique cache key string + """ + # Base key with the view name + key_parts = ['category_tree'] + + # Add filter parameters to make the key specific + if source_type: + key_parts.append(f'source_type:{source_type}') + + # Join all parts with a separator + key = ':'.join(key_parts) + + return key + + @classmethod + def invalidate_cache(cls, source_type=None): + """ + Invalidate the category tree cache. + + Args: + source_type: Optional source_type to invalidate specific cache. + If None, invalidates all category tree caches. + """ + if source_type: + # Invalidate specific tree cache + tree_cache_key = cls().get_cache_key(source_type) + cache.delete(tree_cache_key) + + # Invalidate all paginated caches for this source_type + paginated_pattern = f'category_tree_paginated:source_type:{source_type}*' + paginated_keys = cache.keys(paginated_pattern) + if paginated_keys: + cache.delete_many(paginated_keys) + else: + # Invalidate all category tree caches (both full trees and paginated results) + # This uses cache key pattern matching if supported by the cache backend + # For Redis, we can use wildcards + all_cache_keys = cache.keys('category_tree*') + if all_cache_keys: + cache.delete_many(all_cache_keys) + else: + # Fallback: delete specific known keys + for st in [HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI]: + # Delete tree cache + tree_cache_key = cls().get_cache_key(st) + cache.delete(tree_cache_key) + + # Try to delete paginated caches + try: + paginated_pattern = f'category_tree_paginated:source_type:{st}*' + paginated_keys = cache.keys(paginated_pattern) + if paginated_keys: + cache.delete_many(paginated_keys) + except: + pass + + # Also delete the default keys (no source_type) + cache.delete(cls().get_cache_key()) + try: + default_paginated_keys = cache.keys('category_tree_paginated:page:*') + if default_paginated_keys: + cache.delete_many(default_paginated_keys) + except: + pass + + def get_children(self, obj): + return [self.to_dict(cat) for cat in obj.get_children()] + + def to_dict(self, c): + """ + Convert a category to a dictionary with proper tree structure based on level. + + Args: + c: The HadisCategory instance + + Returns: + Dictionary representation of the category with proper tree structure + """ + # Get the level of this category + level = c.level_p + + # Determine source_type and category_type based on level + source_type = None + category_type = None + + if level == 1: + # Level 1 (Root) - Has its own source_type + source_type = c.source_type + category_type = None + elif level == 2: + # Level 2 (Child) - Inherits source_type from parent, has own category_type + if c.parent: + source_type = c.parent.source_type + else: + source_type = c.source_type + category_type = c.category_type + elif level == 3: + # Level 3 (Grandchild) - Inherits source_type from grandparent, category_type from parent + if c.parent and c.parent.parent: + source_type = c.parent.parent.source_type + category_type = c.parent.category_type + else: + source_type = c.source_type + category_type = c.category_type + + # Get direct children - use getattr to handle both model instances and cached trees + if hasattr(c, 'get_children'): + # For model instances + children = c.get_children() + else: + # For cached trees + children = getattr(c, 'children', []) + + # Create the dictionary representation + return { + 'id': c.id, + 'name': c.name, + 'hadis_count': getattr(c, 'hadis_count', 0), + 'source_type': source_type, + 'category_type': category_type, + 'children': [] if not children else [self.to_dict(child) for child in children], + } + + def get_pagination_cache_key(self, source_type=None, page=1, page_size=None): + """ + Generate a cache key for paginated results. + + Args: + source_type: Optional source_type filter + page: Page number + page_size: Number of items per page + + Returns: + A unique cache key for the paginated results + """ + # Base key with the view name + key_parts = ['category_tree_paginated'] + + # Add filter parameters + if source_type: + key_parts.append(f'source_type:{source_type}') + + # Add pagination parameters + key_parts.append(f'page:{page}') + if page_size: + key_parts.append(f'page_size:{page_size}') + else: + key_parts.append(f'page_size:{self.pagination_class.page_size}') + + # Join all parts with a separator + key = ':'.join(key_parts) + + return key + + @category_list_swagger + def get(self, request, *args, **kwargs): + from mptt.templatetags.mptt_tags import cache_tree_children + + # Get source_type filter from query params + source_type = request.query_params.get('source_type', None) + + # Get pagination parameters + page = request.query_params.get('page', 1) + page_size = request.query_params.get('page_size', self.pagination_class.page_size) + + # Try to get paginated response from cache first + pagination_cache_key = self.get_pagination_cache_key(source_type, page, page_size) + cached_response = cache.get(pagination_cache_key) + + if cached_response: + return Response(cached_response) + + # Generate a unique cache key for the full tree + tree_cache_key = self.get_cache_key(source_type) + + # Try to get the tree from cache first + tree = cache.get(tree_cache_key) + + # If not in cache, build the tree + if tree is None: + # Build filter query + filter_query = Q(is_active=True) + if source_type and source_type in [HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI]: + filter_query &= Q(source_type=source_type) + + # Get ALL categories with hadis count - this is important to include all levels + queryset = HadisCategory.objects.filter(filter_query).select_related( + 'parent', 'parent__parent' # Prefetch parent relationships for efficient access + ).annotate( + hadis_count=Count('hadis'), + ) + + # Use cache_tree_children to build the full tree structure + # This will properly set up the parent-child relationships for the entire tree + all_categories = cache_tree_children(queryset) + + # Filter to get only level 1 (root) categories as the starting point for our tree + root_categories = [category for category in all_categories if category.parent is None] + + # Build the tree + tree = [] + for c in root_categories: + # Convert to dictionary with proper tree structure based on level + tdata = self.to_dict(c) + + # Calculate total hadis_count including all children recursively + def calculate_total_hadis_count(node): + total = node['hadis_count'] + for child in node['children']: + total += calculate_total_hadis_count(child) + return total + + # Update the hadis_count to include all children + tdata['hadis_count'] = calculate_total_hadis_count(tdata) + + # Add to the result tree + tree.append(tdata) + + # Store the tree in cache + cache.set(tree_cache_key, tree, self.CACHE_TIMEOUT) + + # Apply pagination only to the root categories (level 1) + page_obj = self.paginate_queryset(tree) + + if page_obj is not None: + # Get paginated response + response = self.get_paginated_response(page_obj) + + # Cache the paginated response + cache.set(pagination_cache_key, response.data, self.CACHE_TIMEOUT) + + return response + + # If pagination is not applied, return the full tree + return Response(tree) + + def get_queryset(self): + """ + Get the base queryset for the serializer. + This is used by DRF's default list() method if we don't override get(). + + Note: This method is not used directly in our implementation since we override get(), + but it's kept for completeness and API compatibility. + """ + source_type = self.request.query_params.get('source_type', None) + + # Build filter query + filter_query = Q(is_active=True) + if source_type and source_type in [HadisCategory.SourceType.SHIA, HadisCategory.SourceType.SUNNI]: + filter_query &= Q(source_type=source_type) + + # Get ALL categories with proper prefetching for efficiency + queryset = HadisCategory.objects.filter(filter_query).select_related( + 'parent', 'parent__parent' + ).prefetch_related( + 'children', 'children__children' # Prefetch two levels of children + ).annotate( + hadis_count=Count('hadis'), + ) + + # Filter to only return root categories (level 1) + queryset = queryset.filter(parent=None) + + return queryset diff --git a/apps/hadis/views/hadis.py b/apps/hadis/views/hadis.py new file mode 100644 index 0000000..f0b285e --- /dev/null +++ b/apps/hadis/views/hadis.py @@ -0,0 +1,75 @@ +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from django.db.models import Subquery, Count, F, OuterRef, Q, Prefetch +from rest_framework.generics import ListAPIView, RetrieveAPIView +from django.shortcuts import get_object_or_404 + + +from apps.hadis.models import * +from apps.hadis.serializers import * +from apps.hadis.doc import category_list_swagger, category_hadis_list_swagger, hadis_detail_swagger + + + +class CategoryHadisListView(ListAPIView): + serializer_class = HadisSerializer + permission_classes = (IsAuthenticated,) + + @category_hadis_list_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + def get_queryset(self): + categories = HadisCategory.objects.filter(id=self.kwargs['pk']).order_by('-order') + return Hadis.objects.filter( + Q(category__in=categories), + status=True, + ).prefetch_related( + 'category', + ) + + +class HadisDetailView(RetrieveAPIView): + """ + API endpoint to retrieve detailed information about a specific hadis. + + Returns: + - Hadis details (number, title, text, translation) + - HadisOverview information (status, tags, etc.) + - First HadisReference with its ReferenceImages + - List of Transmitters + """ + serializer_class = HadisDetailSerializer + permission_classes = (IsAuthenticated,) + + @hadis_detail_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_object(self): + hadis_id = self.kwargs.get('pk') + queryset = Hadis.objects.filter(id=hadis_id) + + # Prefetch related data to optimize queries + queryset = queryset.prefetch_related( + 'hadisoverview', + 'hadisoverview__tags', + Prefetch( + 'references', + queryset=HadisReference.objects.prefetch_related( + 'referenceimage_set', + 'book' + ) + ), + Prefetch( + 'transmitters', + queryset=HadisTransmitter.objects.select_related('transmitter').order_by('order') + ) + ) + + return get_object_or_404(queryset, id=hadis_id) + + def get_serializer_context(self): + context = super().get_serializer_context() + context.update({'request': self.request}) + return context + diff --git a/apps/library/__init__.py b/apps/library/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/library/admin.py b/apps/library/admin.py new file mode 100644 index 0000000..52e1380 --- /dev/null +++ b/apps/library/admin.py @@ -0,0 +1,192 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from django.urls import reverse +from django.utils.html import format_html +from ajaxdatatable.admin import AjaxDatatable + +from apps.library.models import * + + +@admin.register(Book) +class BookAdmin(AjaxDatatable): + list_display = ('title', 'slug', 'status', 'pin', 'file_type', 'view_count', 'created_at') + list_filter = ('status', 'pin', 'file_type', 'created_at', 'updated_at') + search_fields = ('title', 'slug', 'summary', 'description') + # autocomplete_fields = ('categories', 'collections', ) + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'summary', 'description', 'thumbnail', 'pages_count') + }), + (_('Status'), { + 'fields': ('status', 'pin') + }), + (_('File Information'), { + 'fields': ('file_type', 'book_file') + }), + (_('Relations'), { + 'fields': ('categories', 'collections') + }), + (_('Statistics'), { + 'fields': ('view_count',) + }), + ) + + + +class BookCollectionAdminBase(AjaxDatatable): + """Base admin class for all book collection types""" + list_display = ('get_title', 'status', 'order', 'count_books') + list_filter = ('status',) + search_fields = ('title',) + autocomplete_fields = ('books',) + ordering = ('order',) + + fieldsets = ( + (None, { + 'fields': ('title', 'summary', 'status', 'order') + }), + (_('Books'), { + 'fields': ('books',) + }), + ) + + exclude = ('display_position',) + + def get_title(self, obj): + return str(obj.title) + get_title.short_description = _('Title') + + + @admin.display(description=_('Number of Books')) + def count_books(self, obj): + count = obj.books.count() + if count > 0: + url = reverse('admin:library_book_changelist') + f'?collections__id__exact={obj.id}' + return format_html('{}', url, count) + return count + + + + + + +@admin.register(PinnedBookCollection) +class PinnedBookCollectionAdmin(BookCollectionAdminBase): + """Admin for pinned book collections only""" + + def get_queryset(self, request): + # Only show pinned collections + return super().get_queryset(request).filter(display_position=BookCollection.DisplayPosition.PINNED) + + def save_model(self, request, obj, form, change): + # Ensure the display_position is always set to PINNED + obj.display_position = BookCollection.DisplayPosition.PINNED + super().save_model(request, obj, form, change) + + +@admin.register(MiddleBookCollection) +class MiddleBookCollectionAdmin(BookCollectionAdminBase): + """Admin for middle section book collections only""" + + def get_queryset(self, request): + # Only show middle section collections + return super().get_queryset(request).filter(display_position=BookCollection.DisplayPosition.MIDDLE) + + def has_add_permission(self, request): + # Check if a middle collection already exists + exists = BookCollection.objects.filter(display_position=BookCollection.DisplayPosition.MIDDLE).exists() + # Only allow adding if no middle collection exists + return not exists + + def has_delete_permission(self, request, obj=None): + # Prevent deletion of the middle collection + return False + + def save_model(self, request, obj, form, change): + # Ensure the display_position is always set to MIDDLE + obj.display_position = BookCollection.DisplayPosition.MIDDLE + super().save_model(request, obj, form, change) + + def changelist_view(self, request, extra_context=None): + # Check if a middle collection exists + try: + # Try to get the first (and should be only) middle collection + obj = self.get_queryset(request).first() + if obj: + # If it exists, redirect to the change view for this object + from django.http import HttpResponseRedirect + from django.urls import reverse + url = reverse( + 'admin:%s_%s_change' % (obj._meta.app_label, obj._meta.model_name), + args=[obj.pk] + ) + return HttpResponseRedirect(url) + except Exception: + # If any error occurs, just show the changelist view as usual + pass + + # If no object exists or there was an error, show the default changelist view + return super().changelist_view(request, extra_context) + + +@admin.register(BottomBookCollection) +class BottomBookCollectionAdmin(BookCollectionAdminBase): + """Admin for bottom section book collections only""" + + def get_queryset(self, request): + # Only show bottom section collections + return super().get_queryset(request).filter(display_position=BookCollection.DisplayPosition.BOTTOM) + + def has_add_permission(self, request): + # Check if a bottom collection already exists + exists = BookCollection.objects.filter(display_position=BookCollection.DisplayPosition.BOTTOM).exists() + # Only allow adding if no bottom collection exists + return not exists + + def has_delete_permission(self, request, obj=None): + # Prevent deletion of the bottom collection + return False + + def save_model(self, request, obj, form, change): + # Ensure the display_position is always set to BOTTOM + obj.display_position = BookCollection.DisplayPosition.BOTTOM + super().save_model(request, obj, form, change) + + def changelist_view(self, request, extra_context=None): + # Check if a bottom collection exists + try: + # Try to get the first (and should be only) bottom collection + obj = self.get_queryset(request).first() + if obj: + # If it exists, redirect to the change view for this object + from django.http import HttpResponseRedirect + from django.urls import reverse + url = reverse( + 'admin:%s_%s_change' % (obj._meta.app_label, obj._meta.model_name), + args=[obj.pk] + ) + return HttpResponseRedirect(url) + except Exception: + # If any error occurs, just show the changelist view as usual + pass + + # If no object exists or there was an error, show the default changelist view + return super().changelist_view(request, extra_context) + + + +@admin.register(Category) +class CategoryAdmin(AjaxDatatable): + list_display = ('title', 'slug', 'status', 'count_books', 'created_at') + list_filter = ('status', 'created_at', 'updated_at') + search_fields = ('title', 'slug') + # autocomplete_fields = ('books',) + + @admin.display(description=_('Number of Books')) + def count_books(self, obj): + count = obj.books_count + if count > 0: + url = reverse('admin:library_book_changelist') + f'?categories__id__exact={obj.id}' + return format_html('{}', url, count) + return count + diff --git a/apps/library/apps.py b/apps/library/apps.py new file mode 100644 index 0000000..166aebd --- /dev/null +++ b/apps/library/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class LibraryConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.library' + verbose_name = _('Library') + icon = 'mi-library-books' diff --git a/apps/library/doc.py b/apps/library/doc.py new file mode 100644 index 0000000..0c932a8 --- /dev/null +++ b/apps/library/doc.py @@ -0,0 +1,220 @@ +""" +Swagger documentation for the Library API endpoints. + +This module provides Swagger documentation for the Library 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 + +# Parameter definitions +collection_id_param = openapi.Parameter( + 'collection_id', + openapi.IN_QUERY, + description="Filter books by collection ID", + type=openapi.TYPE_INTEGER, + required=False +) + +middle_param = openapi.Parameter( + 'middle', + openapi.IN_QUERY, + description="Filter books by middle section collection (any value will trigger the filter)", + type=openapi.TYPE_STRING, + required=False +) + +bottom_param = openapi.Parameter( + 'bottom', + openapi.IN_QUERY, + description="Filter books by bottom section collection (any value will trigger the filter)", + type=openapi.TYPE_STRING, + required=False +) + +search_param = openapi.Parameter( + 'search', + openapi.IN_QUERY, + description="Search books by title, summary, or author", + type=openapi.TYPE_STRING, + required=False +) + +# Response schemas +book_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'id': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Unique identifier for the book" + ), + 'title': openapi.Schema( + type=openapi.TYPE_STRING, + description="Title of the book" + ), + 'slug': openapi.Schema( + type=openapi.TYPE_STRING, + description="URL-friendly slug for the book" + ), + 'summary': openapi.Schema( + type=openapi.TYPE_STRING, + description="Brief summary of the book" + ), + 'description': openapi.Schema( + type=openapi.TYPE_STRING, + description="Detailed description of the book" + ), + 'thumbnail_url': openapi.Schema( + type=openapi.TYPE_STRING, + description="URL to the book's thumbnail image", + nullable=True + ), + 'author': openapi.Schema( + type=openapi.TYPE_STRING, + description="Author of the book" + ), + 'status': openapi.Schema( + type=openapi.TYPE_BOOLEAN, + description="Whether the book is active/visible" + ), + 'pin': openapi.Schema( + type=openapi.TYPE_BOOLEAN, + description="Whether the book is pinned to the top" + ), + 'view_count': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Number of views for the book" + ), + 'download_count': openapi.Schema( + type=openapi.TYPE_INTEGER, + description="Number of downloads for the book" + ), + 'file_type': openapi.Schema( + type=openapi.TYPE_STRING, + description="Type of the book file (PDF, EPUB, etc.)" + ), + 'book_file': openapi.Schema( + type=openapi.TYPE_STRING, + description="URL to the book file", + nullable=True + ), + 'created_at': openapi.Schema( + type=openapi.TYPE_STRING, + format=openapi.FORMAT_DATETIME, + description="Creation timestamp" + ) + }, + required=['id', 'title', 'slug', 'status', 'created_at'] +) + +books_response = openapi.Response( + description="List of books with pagination", + schema=openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + 'count': openapi.Schema(type=openapi.TYPE_INTEGER), + 'next': openapi.Schema(type=openapi.TYPE_STRING, nullable=True), + 'previous': openapi.Schema(type=openapi.TYPE_STRING, nullable=True), + 'results': openapi.Schema( + type=openapi.TYPE_ARRAY, + items=book_schema + ) + } + ) +) + +# Book detail response +book_detail_response = openapi.Response( + description="Detailed information about a specific book", + schema=book_schema +) + +# Swagger decorators for views +book_detail_swagger = swagger_auto_schema( + operation_id="get_book_detail", + operation_description=""" + Retrieve detailed information about a specific book. + + This endpoint returns comprehensive information about a book, including: + - Basic book details (title, slug, summary, description) + - Thumbnail image URL + - Author information + - Status and pin information + - View and download counts + - File type and book file URL + - Creation and update timestamps + - Categories and collections the book belongs to + - Number of pages + + The book is specified by its ID in the URL path. + """, + operation_summary="Get Book Detail", + tags=["Library"], + responses={ + 200: book_detail_response, + 401: "Authentication credentials were not provided or are invalid.", + 404: "The specified book does not exist.", + 500: "Internal server error occurred." + } +) + +book_list_swagger = swagger_auto_schema( + operation_id="list_books", + operation_description=""" + Retrieve a list of books with filtering and search capabilities. + + This endpoint returns a paginated list of books. Each book includes its title, slug, + summary, description, thumbnail, author, status, pin, view count, download count, + file type, book file URL, and creation timestamp. + + You can filter books by: + - Collection ID using the query parameter 'collection_id' + - Middle section collection using the query parameter 'middle' + - Bottom section collection using the query parameter 'bottom' + + You can also search for books by title, summary, or author using the query parameter 'search'. + """, + operation_summary="List Books", + tags=["Library"], + manual_parameters=[collection_id_param, middle_param, bottom_param, search_param], + responses={ + 200: books_response, + 401: "Authentication credentials were not provided or are invalid.", + 500: "Internal server error occurred." + } +) + +category_list_swagger = swagger_auto_schema( + operation_id="list_categories", + operation_description=""" + Retrieve a list of book categories. + + This endpoint returns a paginated list of book categories. Each category includes its + title, slug, status, books count, and timestamps. + """, + operation_summary="List Book Categories", + tags=["Library"], + responses={ + 200: "List of book categories", + 401: "Authentication credentials were not provided or are invalid.", + 500: "Internal server error occurred." + } +) + +pinned_collection_list_swagger = swagger_auto_schema( + operation_id="list_pinned_collections", + operation_description=""" + Retrieve a list of pinned book collections with their top book covers. + + This endpoint returns a list of pinned book collections. Each collection includes its + title and the covers of its top books by view count. + """, + operation_summary="List Pinned Book Collections", + tags=["Library"], + responses={ + 200: "List of pinned book collections with covers", + 401: "Authentication credentials were not provided or are invalid.", + 500: "Internal server error occurred." + } +) \ No newline at end of file diff --git a/apps/library/migrations/0001_initial.py b/apps/library/migrations/0001_initial.py new file mode 100644 index 0000000..7455c72 --- /dev/null +++ b/apps/library/migrations/0001_initial.py @@ -0,0 +1,123 @@ +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import django.db.models.deletion +import filer.fields.image +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('slug', models.SlugField(max_length=255, unique=True)), + ('status', models.BooleanField(default=True, verbose_name='status')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ], + options={ + 'verbose_name': 'Category', + 'verbose_name_plural': 'Categories', + }, + ), + migrations.CreateModel( + name='Book', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('slug', models.SlugField(max_length=255, unique=True)), + ('summary', models.CharField(blank=True, help_text='could be null', max_length=512, null=True)), + ('description', models.TextField(blank=True, help_text='could be null', null=True)), + ('author', models.CharField(blank=True, max_length=255, null=True)), + ('pages_count', models.CharField(help_text='eg. 34', max_length=255, null=True, verbose_name='Number of Pages')), + ('status', models.BooleanField(default=True, verbose_name='status')), + ('pin', models.BooleanField(default=True, verbose_name='Pin to top')), + ('view_count', models.PositiveBigIntegerField(default=0, verbose_name='view count')), + ('download_count', models.PositiveBigIntegerField(default=0, verbose_name='view count')), + ('file_type', models.CharField(choices=[('pdf', 'Pdf'), ('epub', 'Epub'), ('docx', 'Docx')], default='pdf', max_length=16, verbose_name='File Type')), + ('book_file', models.FileField(blank=True, max_length=550, null=True, upload_to='books', verbose_name='Book File')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('thumbnail', filer.fields.image.FilerImageField(blank=True, help_text='image allowed', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.FILER_IMAGE_MODEL)), + ], + options={ + 'verbose_name': 'Book', + 'verbose_name_plural': 'Books', + }, + ), + migrations.CreateModel( + name='BookCollection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('summary', models.CharField(blank=True, help_text='could be null', max_length=512, null=True)), + ('display_position', models.CharField(choices=[('pinned', 'Pinned'), ('middle', 'Middle Section'), ('bottom', 'Bottom Section')], default='pinned', max_length=20, verbose_name='Display Position')), + ('status', models.BooleanField(default=True, verbose_name='status')), + ('order', models.IntegerField(default=0, verbose_name='order')), + ('books', models.ManyToManyField(blank=True, related_name='related_collections_books', to='library.book', verbose_name='Books')), + ], + options={ + 'verbose_name': 'Book Collection', + 'verbose_name_plural': 'Book Collections', + }, + ), + migrations.AddField( + model_name='book', + name='collections', + field=models.ManyToManyField(blank=True, related_name='related_collections', to='library.bookcollection', verbose_name='collections'), + ), + migrations.CreateModel( + name='BottomBookCollection', + fields=[ + ], + options={ + 'verbose_name': 'Bottom Section Book Collection', + 'verbose_name_plural': 'Bottom Section Book Collections', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('library.bookcollection',), + ), + migrations.CreateModel( + name='MiddleBookCollection', + fields=[ + ], + options={ + 'verbose_name': 'Middle Section Book Collection', + 'verbose_name_plural': 'Middle Section Book Collections', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('library.bookcollection',), + ), + migrations.CreateModel( + name='PinnedBookCollection', + fields=[ + ], + options={ + 'verbose_name': 'Pinned Book Collection', + 'verbose_name_plural': 'Pinned Book Collections', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('library.bookcollection',), + ), + migrations.AddField( + model_name='book', + name='categories', + field=models.ManyToManyField(blank=True, related_name='related_categories', to='library.category', verbose_name='categories'), + ), + ] diff --git a/apps/library/migrations/0002_alter_book_thumbnail.py b/apps/library/migrations/0002_alter_book_thumbnail.py new file mode 100644 index 0000000..c4a6803 --- /dev/null +++ b/apps/library/migrations/0002_alter_book_thumbnail.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-04-03 01:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('library', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='book', + name='thumbnail', + field=models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='book_thumbnails/'), + ), + ] diff --git a/apps/library/migrations/__init__.py b/apps/library/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/library/models.py b/apps/library/models.py new file mode 100644 index 0000000..42e3113 --- /dev/null +++ b/apps/library/models.py @@ -0,0 +1,130 @@ + +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from filer.fields.image import FilerImageField + + +class BookCollection(models.Model): + class DisplayPosition(models.TextChoices): + PINNED = 'pinned', _('Pinned') + MIDDLE = 'middle', _('Middle Section') + BOTTOM = 'bottom', _('Bottom Section') + + title = models.CharField(max_length=255) + summary = models.CharField(max_length=512, null=True, blank=True, help_text=_('could be null')) + display_position = models.CharField( + max_length=20, + choices=DisplayPosition.choices, + default=DisplayPosition.PINNED, + verbose_name=_('Display Position') + ) + status = models.BooleanField(_('status'), default=True) + order = models.IntegerField(default=0, verbose_name=_('order')) + books = models.ManyToManyField('library.Book', related_name='related_collections_books',through="library.Book_collections" ,verbose_name=_('Books'), blank=True) + + def __str__(self): + return f'Collection #{self.id}/{self.title}' + + class Meta: + verbose_name = _('Book Collection') + verbose_name_plural = _('Book Collections') + + +class PinnedBookCollection(BookCollection): + """ + Proxy model for pinned book collections + """ + class Meta: + proxy = True + verbose_name = _('Pinned Book Collection') + verbose_name_plural = _('Pinned Book Collections') + + +class MiddleBookCollection(BookCollection): + """ + Proxy model for middle section book collections + """ + class Meta: + proxy = True + verbose_name = _('Middle Section Book Collection') + verbose_name_plural = _('Middle Section Book Collections') + + +class BottomBookCollection(BookCollection): + """ + Proxy model for bottom section book collections + """ + class Meta: + proxy = True + verbose_name = _('Bottom Section Book Collection') + verbose_name_plural = _('Bottom Section Book Collections') + + +class Category(models.Model): + title = models.CharField(max_length=255) + slug = models.SlugField(max_length=255, unique=True) + status = models.BooleanField(default=True, verbose_name=_('status')) + # books = models.ManyToManyField('library.Book', related_name='related_categories_books',through="library.Book_categories" ,verbose_name=_('Books'), 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')) + + def __str__(self): + return self.title + + @property + def books_count(self): + """Return the number of books in this category""" + return self.related_categories.count() + + class Meta: + verbose_name = _('Category') + verbose_name_plural = _('Categories') + + +class Book(models.Model): + class FileType(models.TextChoices): + pdf = 'pdf', 'Pdf' + epub = 'epub', 'Epub' + docx = 'docx', 'Docx' + + title = models.CharField(max_length=255) + slug = models.SlugField(max_length=255, unique=True) + + summary = models.CharField(max_length=512, null=True, blank=True, help_text=_('could be null')) + description = models.TextField(null=True, blank=True, help_text=_('could be null')) + thumbnail = models.ImageField(upload_to='book_thumbnails/', null=True, blank=True, help_text=_('image allowed')) + author = models.CharField(max_length=255, null=True, blank=True) + pages_count = models.CharField(verbose_name=_('Number of Pages'), max_length=255, help_text=_('eg. 34'), null=True) + status = models.BooleanField(default=True, verbose_name=_('status')) + pin = models.BooleanField(default=True, verbose_name=_('Pin to top')) + + categories = models.ManyToManyField(Category, related_name='related_categories', verbose_name=_('categories'), blank=True) + collections = models.ManyToManyField(BookCollection, related_name='related_collections', verbose_name=_('collections'), blank=True) + + + view_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count')) + download_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count')) + + # seo_fields = SeoGenericRelation(verbose_name=_('soe fields')) + file_type = models.CharField(verbose_name=_('File Type'), choices=FileType.choices, default=FileType.pdf, max_length=16) + book_file = models.FileField(null=True, blank=True, max_length=550, upload_to='books', verbose_name='Book File') + + 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.id}>-{self.title}' + + def increment_view_count(self): + """Increment the view count by 1 and save the model""" + self.view_count += 1 + self.save(update_fields=['view_count']) + + + class Meta: + verbose_name = _('Book') + verbose_name_plural = _('Books') + + diff --git a/apps/library/serializers.py b/apps/library/serializers.py new file mode 100644 index 0000000..d7c2bd0 --- /dev/null +++ b/apps/library/serializers.py @@ -0,0 +1,57 @@ + + + +from utils import get_thumbs +from django.db.models import Avg, Q +from rest_framework import serializers + +from apps.library.models import * + + + + +class CategorySerializer(serializers.ModelSerializer): + books_count = serializers.IntegerField(read_only=True) + + class Meta: + model = Category + fields = ('id', 'title', 'slug', 'status', 'books_count', 'created_at', 'updated_at') + + +class PinnedBookCollectionSerializer(serializers.ModelSerializer): + covers = serializers.SerializerMethodField() + + def get_covers(self, obj: BookCollection): + books = obj.books.all().order_by('-view_count')[:3] + + images = [] + for book in books: + if book.thumbnail: + url = get_thumbs(book.thumbnail, self.context.get('request')) + if url.get('md'): + images.append(url['md']) + + return images + + class Meta: + model = BookCollection + fields = ('id', 'title', 'covers') + + +class BookSerializer(serializers.ModelSerializer): + thumbnail = serializers.SerializerMethodField() + + def get_thumbnail(self, obj): + if obj.thumbnail: + return get_thumbs(obj.thumbnail, self.context.get('request')) + return None + + class Meta: + model = Book + fields = ( + 'id', 'title', 'slug', 'summary', 'description', 'thumbnail', + 'author', 'status', 'pin', 'view_count', 'download_count', + 'file_type', 'book_file', 'created_at' + ) + + diff --git a/apps/library/tests.py b/apps/library/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/library/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/library/urls.py b/apps/library/urls.py new file mode 100644 index 0000000..5eeb026 --- /dev/null +++ b/apps/library/urls.py @@ -0,0 +1,15 @@ +from django.urls import path + +from apps.library.views import ( + CategoryListView, + PinnedBookCollectionListView, + BookListView, + BookDetailView, +) + +urlpatterns = [ + path('categories/', CategoryListView.as_view(), name='category-list'), + path('pinned-collections/', PinnedBookCollectionListView.as_view(), name='pinned-collection-list'), + path('books/', BookListView.as_view(), name='book-list'), + path('books//', BookDetailView.as_view(), name='book-detail'), +] \ No newline at end of file diff --git a/apps/library/views.py b/apps/library/views.py new file mode 100644 index 0000000..688037a --- /dev/null +++ b/apps/library/views.py @@ -0,0 +1,116 @@ +from django.db.models import Q, Count +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.generics import ListAPIView, RetrieveAPIView +from rest_framework.filters import SearchFilter + +from apps.library.models import * +from apps.library.serializers import * +from apps.library.doc import ( + book_list_swagger, + book_detail_swagger, + category_list_swagger, + pinned_collection_list_swagger +) + + + +class CategoryListView(ListAPIView): + """ + API view to list all book categories + """ + serializer_class = CategorySerializer + permission_classes = (IsAuthenticated,) + + @category_list_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return Category.objects.filter( + status=True + ).annotate( + books_count=Count('related_categories') + ).order_by('title') + + +class PinnedBookCollectionListView(ListAPIView): + """ + API view to list pinned book collections with their top 3 book covers + """ + serializer_class = PinnedBookCollectionSerializer + permission_classes = (IsAuthenticated,) + pagination_class = None + + @pinned_collection_list_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + return BookCollection.objects.filter( + status=True, + display_position=BookCollection.DisplayPosition.PINNED + ).order_by('-order', '-id') + + +class BookListView(ListAPIView): + """ + API view to list books with filtering and search capabilities + """ + serializer_class = BookSerializer + permission_classes = (IsAuthenticated,) + filter_backends = [SearchFilter] + search_fields = ['title', 'summary', 'author'] + + @book_list_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + queryset = Book.objects.filter(status=True) + + # Filter by collection if provided + collection_id = self.request.query_params.get('collection_id') + if collection_id: + queryset = queryset.filter(collections__id=collection_id) + + # Filter by middle collection if requested + # if self.request.query_params.get('middle'): + # middle_collections = BookCollection.objects.filter( + # status=True, + # display_position=BookCollection.DisplayPosition.MIDDLE + # ) + # if middle_collections.exists(): + # queryset = queryset.filter(collections__in=middle_collections) + + # Filter by bottom collection if requested + # if self.request.query_params.get('bottom'): + # bottom_collections = BookCollection.objects.filter( + # status=True, + # display_position=BookCollection.DisplayPosition.BOTTOM + # ) + # if bottom_collections.exists(): + # queryset = queryset.filter(collections__in=bottom_collections) + + return queryset.order_by('-pin', '-created_at') + + +class BookDetailView(RetrieveAPIView): + """ + API view to retrieve detailed information about a specific book + """ + serializer_class = BookSerializer + permission_classes = (IsAuthenticated,) + queryset = Book.objects.filter(status=True) + + @book_detail_swagger + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + # Increment view count when book details are viewed + instance.increment_view_count() + serializer = self.get_serializer(instance) + return Response(serializer.data) + diff --git a/apps/podcast/__init__.py b/apps/podcast/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/podcast/admin.py b/apps/podcast/admin.py new file mode 100644 index 0000000..fb046ae --- /dev/null +++ b/apps/podcast/admin.py @@ -0,0 +1,23 @@ +from django.contrib import admin +from ajaxdatatable.admin import AjaxDatatable + +from apps.podcast.models import * + + + + +class PodcastInCollectionInline(admin.TabularInline): + model = PodcastInCollection + extra = 1 + + +@admin.register(PodcastCollection) +class PodcastCollectionAdmin(AjaxDatatable): + list_display = ('title',) + inlines = [PodcastInCollectionInline] + + +@admin.register(Podcast) +class PodcastAdmin(AjaxDatatable): + list_display = ('title', 'view_count', 'download_count', 'status') + search_fields = ('title',) diff --git a/apps/podcast/apps.py b/apps/podcast/apps.py new file mode 100644 index 0000000..076e705 --- /dev/null +++ b/apps/podcast/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class PodcastConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.podcast' + + diff --git a/apps/podcast/migrations/__init__.py b/apps/podcast/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/podcast/models.py b/apps/podcast/models.py new file mode 100644 index 0000000..f90c459 --- /dev/null +++ b/apps/podcast/models.py @@ -0,0 +1,87 @@ +from django.db import models + + + +class PodcastCategory(models.Model): + title = models.CharField(max_length=255, verbose_name=_('title')) + slug = models.SlugField(allow_unicode=True, unique=True, verbose_name=_('slug')) + + status = models.BooleanField(default=True, verbose_name=_('status')) + order = models.PositiveIntegerField(default=0, verbose_name=_('order')) + 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 + + class Meta: + verbose_name = _('Video Category') + verbose_name_plural = _('Video Categories') + ordering = ['order'] + + +class PodcastCollection(models.Model): + title = models.CharField(max_length=255, help_text="This title will not be displayed anywhere") + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + videos = models.ManyToManyField( + Video, + through='PodcastInCollection', + related_name='collections', + verbose_name=_('podcasts'), + ) + + def __str__(self): + return f'Collection #{self.id}/{self.title}' + + class Meta: + verbose_name = _('Podcast Collection') + verbose_name_plural = _('Podcasts Collections') + + +class PodcastInCollection(models.Model): + video_collection = models.ForeignKey( + VideoCollection, on_delete=models.CASCADE, related_name='podcasts_in_collection', verbose_name=_('podcast collection') + ) + podcast = models.ForeignKey( + Podcast, on_delete=models.CASCADE, related_name='collections_podcasts', verbose_name=_('podcasts') + ) + priority = models.PositiveIntegerField(default=0, verbose_name=_('priority')) + + def __str__(self): + return f"{self.podcast_collection.title} - {self.podcast.title} (Priority: {self.priority})" + + class Meta: + verbose_name = _('Podcast in Collection') + verbose_name_plural = _('Podcasts in Collection') + ordering = ['priority'] + + + +class Podcast(models.Model): + + title = models.CharField(max_length=255, null=True) + slug = models.SlugField(allow_unicode=True, unique=True) + thumbnail = models.ImageField(upload_to='book_thumbnails/', null=True, blank=True, help_text=_('image allowed')) + description = models.TextField(null=True) + categories = models.ManyToManyField(PodcastCategory, related_name='podcasts', verbose_name=_('categories')) + audio_file = models.FileField(upload_to='podcast/audio/', null=True, blank=True) + audio_url = models.CharField(max_length=655, null=True, blank=True) + audio_time = models.TimeField() + + view_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count')) + download_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count')) + + status = models.BooleanField(default=True, verbose_name=_('status')) + + 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 + + class Meta: + verbose_name = _('Podcast') + verbose_name_plural = _('Podcasts') + diff --git a/apps/podcast/tests.py b/apps/podcast/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/podcast/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/podcast/views.py b/apps/podcast/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/apps/podcast/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apps/quiz/__init__.py b/apps/quiz/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/quiz/admin/__init__.py b/apps/quiz/admin/__init__.py new file mode 100644 index 0000000..42092ad --- /dev/null +++ b/apps/quiz/admin/__init__.py @@ -0,0 +1,5 @@ +from .quiz import * +from .question import * +from .participant import * +# from .prize import * +# from .user_rank_quiz import * \ No newline at end of file diff --git a/apps/quiz/admin/participant.py b/apps/quiz/admin/participant.py new file mode 100644 index 0000000..b5fa008 --- /dev/null +++ b/apps/quiz/admin/participant.py @@ -0,0 +1,62 @@ +from django.contrib import admin +from django.db.models import F +from django.contrib.admin import SimpleListFilter +from django.utils.translation import gettext_lazy as _ + +from unfold.admin import ModelAdmin, StackedInline +from unfold.decorators import display + +from apps.quiz.models import QuizParticipant, ParticipantAnswer +from apps.account.models import User + +from utils.admin import project_admin_site + +class ParticipantAnswerInline(StackedInline): + model = ParticipantAnswer + readonly_fields = ( + 'correct_answer_display', 'question', 'at_time', 'answer_timing', + ) + + @display(description="Correct Answer") + def correct_answer_display(self, obj): + return obj.correct_answer + + def has_add_permission(self, request, obj): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def get_queryset(self, request): + return super().get_queryset(request).annotate(correct_answer=F('question__correct_answer')) + + +class UserEmailFilter(SimpleListFilter): + title = _('User Email') + parameter_name = 'user_email' + + def lookups(self, request, model_admin): + users = User.objects.all() + return [(user.email, user.email) for user in users] + + def queryset(self, request, queryset): + if self.value(): + email = self.value().replace('%40', '@') + return queryset.filter(user__email=email) + return queryset + + +class ParticipantAdmin(ModelAdmin): + inlines = [ParticipantAnswerInline] + search_fields = ['user__username', 'user__fullname'] + list_display = [ + 'quiz', 'user', 'started_at', 'ended_at', 'total_timing', + 'question_score', 'timing_score', 'total_score' + ] + list_filter = ['started_at', 'ended_at', 'quiz__status', UserEmailFilter] + + # Optional: Add these for better UI experience + date_hierarchy = 'started_at' + ordering = ['-started_at'] + +project_admin_site.register(QuizParticipant, ParticipantAdmin) diff --git a/apps/quiz/admin/question.py b/apps/quiz/admin/question.py new file mode 100644 index 0000000..2036866 --- /dev/null +++ b/apps/quiz/admin/question.py @@ -0,0 +1,85 @@ +from django import forms +from django.contrib import admin + +from unfold.admin import ModelAdmin, TabularInline, StackedInline +from unfold.forms import forms + +from apps.quiz.models import Question + +from utils.admin import project_admin_site + + + +# Uncomment if you want to register Question as a standalone admin +# @admin.register(Question) +# class QuestionAdmin(ModelAdmin): +# list_display = ('question', 'correct_answer', 'quiz', 'priority') +# form = QuestionAdminForm +# ordering = ("priority", "id",) +# fieldsets = ( +# ( +# None, { +# 'fields': ( +# 'question', +# ('option1', 'option2'), +# ('option3', 'option4'), +# 'correct_answer', +# ) +# }, +# ), +# ( +# None, { +# 'fields': ('priority',) +# } +# ) +# ) +@admin.register(Question) +class QuestionAdmin(ModelAdmin): + list_display = ('question', 'correct_answer', 'quiz', 'priority') + ordering = ("priority", "id",) + search_fields = ('question', 'quiz__title') + list_filter = ('quiz',) + + fieldsets = ( + ( + None, { + 'fields': ( + 'quiz', + 'question', + ('option1', 'option2'), + ('option3', 'option4'), + 'correct_answer', + ) + }, + ), + ( + None, { + 'fields': ('priority',) + } + ) + ) + +class QuestionAdminInline(StackedInline): + model = Question + ordering = ("priority", "id",) + extra = 1 + + fieldsets = ( + ( + None, { + 'fields': ( + 'question', + ('option1', 'option2'), + ('option3', 'option4'), + 'correct_answer', + ) + }, + ), + ( + None, { + 'fields': ('priority',) + } + ) + ) +project_admin_site.register(Question, QuestionAdmin) + diff --git a/apps/quiz/admin/quiz.py b/apps/quiz/admin/quiz.py new file mode 100644 index 0000000..24ce71d --- /dev/null +++ b/apps/quiz/admin/quiz.py @@ -0,0 +1,56 @@ +from django.contrib import admin +from django.db.models import Count +from django.utils.safestring import mark_safe +from django.urls import reverse + +from unfold.admin import ModelAdmin +from unfold.decorators import display + +from apps.course.models import Lesson +from apps.quiz.models import Quiz +from apps.quiz.admin.question import QuestionAdminInline +from utils.admin import project_admin_site + + + +class QuizAdmin(ModelAdmin): + search_fields = ['title', 'lesson__title'] + list_display = ['title', 'description', 'lesson', 'each_question_timing', 'status_display', 'questions_display'] + list_filter = ['each_question_timing', 'status'] + inlines = [QuestionAdminInline] + compressed_fields = True + + + def get_queryset(self, request): + queryset = super().get_queryset(request).annotate( + questions_count=Count('questions') + ) + + if request.user.groups.filter(name="Professor Group").exists(): + return queryset.filter(lesson__course__professor=request.user) + + return queryset + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + if obj is None: + form.base_fields['lesson'].queryset = ( + Lesson.objects.all() if request.user.is_staff + else Lesson.objects.filter(course__professor=request.user) + ) + form.base_fields['lesson'].widget.can_add_related = False + + return form + + @display(description='Status', ordering='status') + def status_display(self, obj): + if obj.status: + return mark_safe('Active') + return mark_safe('Inactive') + + @display(description='Questions', ordering='questions_count') + def questions_display(self, obj): + url = reverse('admin:quiz_question_changelist') + f'?quiz={obj.id}' + return mark_safe(f'Questions: {obj.questions_count}') + +project_admin_site.register(Quiz, QuizAdmin) diff --git a/apps/quiz/admin/user_rank_quiz.py b/apps/quiz/admin/user_rank_quiz.py new file mode 100644 index 0000000..e6b988d --- /dev/null +++ b/apps/quiz/admin/user_rank_quiz.py @@ -0,0 +1,132 @@ +# import calendar +# from django.utils import timezone + +# from ajaxdatatable.admin import AjaxDatatable +# from django.contrib import admin +# from django.utils.translation import gettext_lazy as _ +# from django.contrib.admin import SimpleListFilter +# from django.db.models.functions import Rank, Coalesce +# from django.db.models import Sum, F, Window, CharField +# from django.utils.html import format_html +# from apps.quiz.models import Quiz, QuizRankUser, Participant, QuizCategory +# from apps.account.models import User + + +# class QuizFilter(SimpleListFilter): +# title = _('Quiz') +# parameter_name = 'quiz' + +# def lookups(self, request, model_admin): +# quizzes = Quiz.objects.all() +# return [(quiz.id, quiz.video.title) for quiz in quizzes] + +# def queryset(self, request, queryset): +# if self.value(): +# return queryset.filter(uquizzes__quiz__id=self.value()) +# return queryset + +# class QuizCategoryFilter(SimpleListFilter): +# title = _('Quiz Category') +# parameter_name = 'quiz_category' + +# def lookups(self, request, model_admin): +# categories = QuizCategory.objects.all() +# return [(category.id, category.name) for category in categories] + +# def queryset(self, request, queryset): +# if self.value(): +# return queryset.filter(uquizzes__quiz__category__id=self.value()) +# return queryset + +# class MonthFilter(SimpleListFilter): +# title = _('Month') +# parameter_name = 'month' + +# def lookups(self, request, model_admin): +# return [(str(i), calendar.month_name[i]) for i in range(1, 13)] + +# def queryset(self, request, queryset): +# if self.value(): +# month = int(self.value()) +# year = timezone.now().year +# return queryset.filter(uquizzes__started_at__year=year, uquizzes__started_at__month=month) +# return queryset + + +# @admin.register(QuizRankUser) +# class QuizRankUserAdmin(AjaxDatatable): +# list_display = ('username_link', 'get_total_score', 'get_rank') +# list_filter = (QuizFilter, QuizCategoryFilter, MonthFilter) +# readonly_fields = ('date_joined', 'last_login') + + +# def get_queryset(self, request): +# queryset = super().get_queryset(request) + +# quiz_id = request.GET.get('quiz') +# category_id = request.GET.get('quiz_category') +# month = request.GET.get('month') + +# filters = {} +# if quiz_id: +# filters['uquizzes__quiz_id'] = quiz_id +# if category_id: +# filters['uquizzes__quiz__category_id'] = category_id +# if month: +# month = int(month) +# year = timezone.now().year +# filters['uquizzes__started_at__year'] = year +# filters['uquizzes__started_at__month'] = month + +# if filters: +# queryset = queryset.filter(**filters) + +# users_scores = Participant.objects.filter(**{k.replace('uquizzes__', ''): v for k, v in filters.items()}).select_related('user').values( +# username=Coalesce(F('user__username'), F('user__email'), output_field=CharField()) +# ).annotate( +# score=Sum('total_score') +# ).order_by('-score') + +# # Add rank to each user using window function +# users_scores = users_scores.annotate( +# rank=Window( +# expression=Rank(), +# order_by=F('score').desc() +# ) +# ).order_by("rank") + +# user_scores_dict = {user['username']: user for user in users_scores} +# for user in queryset: +# user.score = user_scores_dict.get(user.username, {}).get('score', 0) +# user.rank = user_scores_dict.get(user.username, {}).get('rank', 'N/A') +# self.queryset = queryset +# return queryset + +# def has_view_permission(self, request, obj=None): +# return True + +# def has_change_permission(self, request, obj=None): +# return False + +# def has_add_permission(self, request): +# return False + +# def has_delete_permission(self, request, obj=None): +# return False + +# def username_link(self, obj): +# return format_html('{}', obj.id, obj.username) +# username_link.short_description = 'Username' +# username_link.admin_order_field = 'username' + +# def get_total_score(self, obj): +# for user in self.queryset: +# if user.id == obj.id: +# return user.score +# get_total_score.short_description = 'Total Score' + +# def get_rank(self, obj): +# for user in self.queryset: +# if user.id == obj.id: +# return user.rank +# get_rank.short_description = 'Rank' diff --git a/apps/quiz/apps.py b/apps/quiz/apps.py new file mode 100644 index 0000000..519127d --- /dev/null +++ b/apps/quiz/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class QuizConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.quiz' diff --git a/apps/quiz/doc.py b/apps/quiz/doc.py new file mode 100644 index 0000000..7099d7e --- /dev/null +++ b/apps/quiz/doc.py @@ -0,0 +1,131 @@ +def doc_quiz_submit(): + return """ +# 📝 ارسال پاسخ‌های کوییز + +این API برای ثبت شرکت کاربر در کوییز و ارسال پاسخ‌های مربوطه استفاده می‌شود. زمانی که کاربر در کوییز شرکت می‌کند، باید به همراه پاسخ‌های خود، زمان پاسخ‌دهی و اطلاعات دیگر را ارسال نماید. در این API، کاربر نمی‌تواند دوباره در همان کوییز شرکت کند. + +--- + +## 📄 توضیحات مقادیر پاسخ + +| کلید | نوع داده | توضیحات | +|------------------------|-----------------|---------------------------------------------------------| +| `quiz` | Integer | شناسه کوییز که کاربر در آن شرکت کرده است. | +| `started_at` | DateTime | زمان شروع کوییز. | +| `ended_at` | DateTime | زمان پایان کوییز. | +| `total_timing` | Integer | مدت زمان کلی که کاربر برای پاسخ‌دهی به کوییز صرف کرده است.| +| `question_score` | Integer | امتیاز به‌دست‌آمده توسط کاربر در پاسخ به سوالات کوییز. | +| `timing_score` | Integer | امتیاز به‌دست‌آمده توسط کاربر بر اساس زمان پاسخ‌دهی. | +| `total_score` | Integer | امتیاز کلی کاربر در کوییز (ترکیب امتیاز سوالات و زمان).| +| `answers` | Array | لیستی از پاسخ‌های کاربر به سوالات. | +| `answers.question` | Integer | شناسه سوالی که کاربر به آن پاسخ داده است. | +| `answers.option_num` | Integer | شماره گزینه‌ای که کاربر انتخاب کرده است. | +| `answers.at_time` | DateTime | زمانی که کاربر پاسخ به سوال را ارسال کرده است. | +| `answers.answer_timing`| Integer | مدت زمان پاسخ‌دهی به سوال (در ثانیه). | + +--- +## errors: +# کاربر از قبل کوعیز را شرکت کرده است +```json +{ + "status": "error", + "code": "validation_error", + "status_code": 400, + "message": "There were validation errors.", + "errors": [ + { + "field": "quiz", + "message": "you have already participated in the quiz" + } + ] +} +‍``` + +--- +## پاسخ موفق (201 Created) + +در صورتی که ثبت‌نام موفقیت‌آمیز باشد و پاسخ‌ها ذخیره شوند، یک شیء JSON مشابه با نمونه زیر برگشت داده می‌شود: + +### پاسخ: +```json +{ + "quiz": 1, + "started_at": "2024-11-29T12:00:00Z", + "ended_at": "2024-11-29T12:30:00Z", + "total_timing": 1800, + "question_score": 80, + "timing_score": 10, + "total_score": 90, + "answers": [ + { + "question": 1, + "option_num": 3, + "at_time": "2024-11-29T12:05:00Z", + "answer_timing": 30 + }, + { + "question": 2, + "option_num": 1, + "at_time": "2024-11-29T12:15:00Z", + "answer_timing": 45 + } + ] +} +""" + +def doc_quiz_detail(): + return """ +# 📋 Quiz Detail API + +با ایدی درس میتواند وارد یک کوعیز شوید +این api +سوالات کوعیز و جزعیاتش را برمیگرداند + + +## URL +`GET /path//` + +## پارامترها + +- `lesson_id`: شناسه درس برای دریافت کوییز مرتبط. + +## پاسخ موفق (200 OK) + +در صورتی که درس دارای کوییز باشد، یک شیء JSON با اطلاعات کوییز برگشت داده می‌شود. + +### پاسخ: + +```json +{ + "id": 1, + "permission": true, + "lesson": 101, + "title": "Quiz on Python Basics", + "description": "A quiz on the basics of Python programming.", + "each_question_timing": 30, + "questions": [ + { + "id": 1, + "question": "What is the output of print(2 + 3)?", + "options": [ + {"id": 1, "title": "5"}, + {"id": 2, "title": "6"}, + {"id": 3, "title": "7"}, + {"id": 4, "title": "8"} + ], + "correct_answer": 1 + }, + { + "id": 2, + "question": "What is the result of 2 * 3?", + "options": [ + {"id": 1, "title": "6"}, + {"id": 2, "title": "5"}, + {"id": 3, "title": "7"}, + {"id": 4, "title": "8"} + ], + "correct_answer": 1 + } + ] +} +""" diff --git a/apps/quiz/migrations/0001_initial.py b/apps/quiz/migrations/0001_initial.py new file mode 100644 index 0000000..6f52d80 --- /dev/null +++ b/apps/quiz/migrations/0001_initial.py @@ -0,0 +1,103 @@ +# 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 = [ + ('account', '0001_initial'), + ('course', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='QuizRankUser', + fields=[ + ], + options={ + 'verbose_name': 'Rank Quiz', + 'verbose_name_plural': 'Rank Quizzes', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='Quiz', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(help_text='Quiz Title', max_length=255, verbose_name='title')), + ('description', models.CharField(blank=True, max_length=55, null=True, verbose_name='Description')), + ('each_question_timing', models.PositiveIntegerField()), + ('status', models.BooleanField(default=True)), + ('lesson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='quizzes', to='course.lesson', verbose_name='lesson')), + ], + options={ + 'verbose_name': 'Quiz', + 'verbose_name_plural': 'Quizzes', + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='Question', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('question', models.CharField(max_length=255)), + ('option1', models.CharField(max_length=255, verbose_name='option 1')), + ('option2', models.CharField(max_length=255, verbose_name='option 2')), + ('option3', models.CharField(max_length=255, verbose_name='option 3')), + ('option4', models.CharField(max_length=255, verbose_name='option 4')), + ('correct_answer', models.PositiveSmallIntegerField(choices=[(1, 'Option 1'), (2, 'Option 2'), (3, 'Option 3'), (4, 'Option 4')])), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('priority', models.IntegerField(blank=True, null=True)), + ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questions', to='quiz.quiz', verbose_name='quiz')), + ], + options={ + 'verbose_name': 'Question', + 'verbose_name_plural': 'Questions', + 'ordering': ('-priority', '-id'), + }, + ), + migrations.CreateModel( + name='QuizParticipant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('started_at', models.DateTimeField(verbose_name='started at')), + ('ended_at', models.DateTimeField(verbose_name='ended at')), + ('total_timing', models.PositiveIntegerField(help_text='Seconds take to finish the quiz')), + ('question_score', models.PositiveIntegerField()), + ('timing_score', models.PositiveIntegerField()), + ('total_score', models.PositiveIntegerField()), + ('quiz', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='quiz.quiz')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='uquizzes', to=settings.AUTH_USER_MODEL, verbose_name='user')), + ], + options={ + 'verbose_name': 'Participant', + 'verbose_name_plural': 'Participants', + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='ParticipantAnswer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('option_num', models.PositiveSmallIntegerField(choices=[(1, 'Option 1'), (2, 'Option 2'), (3, 'Option 3'), (4, 'Option 4')], verbose_name='selected option')), + ('at_time', models.DateTimeField()), + ('answer_timing', models.PositiveSmallIntegerField(default=0, verbose_name='seconds take to answer')), + ('question', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='quiz.question')), + ('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='answers', to='quiz.quizparticipant')), + ], + options={ + 'verbose_name': 'User Quiz Answer', + 'verbose_name_plural': 'User Quiz Answers', + 'ordering': ('-id',), + }, + ), + ] diff --git a/apps/quiz/migrations/__init__.py b/apps/quiz/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/quiz/models/__init__.py b/apps/quiz/models/__init__.py new file mode 100644 index 0000000..16a10f1 --- /dev/null +++ b/apps/quiz/models/__init__.py @@ -0,0 +1,2 @@ +from .quiz import * +from .participant import * \ No newline at end of file diff --git a/apps/quiz/models/participant.py b/apps/quiz/models/participant.py new file mode 100644 index 0000000..305af43 --- /dev/null +++ b/apps/quiz/models/participant.py @@ -0,0 +1,68 @@ +from django.db import models + +from apps.account.models import User + + + + +class QuizParticipant(models.Model): + quiz = models.ForeignKey('quiz.Quiz', on_delete=models.CASCADE, related_name='participants') + user = models.ForeignKey('account.User', on_delete=models.CASCADE, verbose_name='user', related_name='uquizzes') + started_at = models.DateTimeField(verbose_name='started at') + ended_at = models.DateTimeField(verbose_name='ended at') + total_timing = models.PositiveIntegerField(help_text='Seconds take to finish the quiz') + + question_score = models.PositiveIntegerField() + timing_score = models.PositiveIntegerField() + total_score = models.PositiveIntegerField() + + class Meta: + verbose_name = "Participant" + verbose_name_plural = "Participants" + ordering = ("-id",) + + def __str__(self): + return f"Participant: {self.id}, ParticipantName: {self.user}, Quiz: {self.quiz.id}" + + def __repr__(self): + return f"Participant(id={self.id})" + + + @staticmethod + def get_user_ranks(quiz_id): + return QuizParticipant.objects.filter(quiz_id=quiz_id).annotate( + rank=Window( + expression=Rank(), + order_by=F('total_score').desc() + ) + ) + + + +class ParticipantAnswer(models.Model): + CHOICES = [ + (1, 'Option 1'), + (2, 'Option 2'), + (3, 'Option 3'), + (4, 'Option 4'), + ] + + participant = models.ForeignKey(QuizParticipant, on_delete=models.CASCADE, related_name='answers') + question = models.ForeignKey("quiz.Question", on_delete=models.CASCADE) + option_num = models.PositiveSmallIntegerField(choices=CHOICES, verbose_name='selected option') + at_time = models.DateTimeField() + answer_timing = models.PositiveSmallIntegerField(default=0, verbose_name='seconds take to answer') + + + class Meta: + verbose_name = "User Quiz Answer" + verbose_name_plural = "User Quiz Answers" + ordering = ("-id",) + + def __str__(self): + return f"Participant Answer: {self.id}" + + def __repr__(self): + return f"ParticipantAnswer(id={self.id})" + + diff --git a/apps/quiz/models/quiz.py b/apps/quiz/models/quiz.py new file mode 100644 index 0000000..26bda6a --- /dev/null +++ b/apps/quiz/models/quiz.py @@ -0,0 +1,65 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from apps.account.models import User + + + +class Quiz(models.Model): + lesson = models.ForeignKey("course.Lesson", verbose_name=_('lesson'), related_name='quizzes', on_delete=models.CASCADE) + title = models.CharField(max_length=255, verbose_name=_('title'), help_text="Quiz Title") + description = models.CharField(max_length=55, blank=True, null=True, verbose_name="Description") + each_question_timing = models.PositiveIntegerField() + status = models.BooleanField(default=True) + + + class Meta: + verbose_name = "Quiz" + verbose_name_plural = "Quizzes" + ordering = ("-id",) + + def __str__(self): + return f"Quiz: {self.id}" + + def __repr__(self): + return f"Quiz(id={self.id})" + + + + +class Question(models.Model): + CHOICES = [ + (1, 'Option 1'), + (2, 'Option 2'), + (3, 'Option 3'), + (4, 'Option 4'), + ] + + quiz = models.ForeignKey(Quiz, verbose_name='quiz', on_delete=models.CASCADE, related_name='questions') + question = models.CharField(max_length=255) + option1 = models.CharField(max_length=255, verbose_name='option 1') + option2 = models.CharField(max_length=255, verbose_name='option 2') + option3 = models.CharField(max_length=255, verbose_name='option 3') + option4 = models.CharField(max_length=255, verbose_name='option 4') + correct_answer = models.PositiveSmallIntegerField(choices=CHOICES) + created_at = models.DateTimeField(auto_now_add=True, verbose_name='created at') + priority = models.IntegerField(null=True, blank=True) + + + class Meta: + verbose_name = "Question" + verbose_name_plural = "Questions" + ordering = ("-priority", "-id",) + + def __str__(self): + return self.question + + def __repr__(self): + return f"Question(id={self.id})" + + +class QuizRankUser(User): + class Meta: + proxy = True + verbose_name = 'Rank Quiz' + verbose_name_plural = 'Rank Quizzes' + diff --git a/apps/quiz/serializers/__init__.py b/apps/quiz/serializers/__init__.py new file mode 100644 index 0000000..16a10f1 --- /dev/null +++ b/apps/quiz/serializers/__init__.py @@ -0,0 +1,2 @@ +from .quiz import * +from .participant import * \ No newline at end of file diff --git a/apps/quiz/serializers/participant.py b/apps/quiz/serializers/participant.py new file mode 100644 index 0000000..2ec1280 --- /dev/null +++ b/apps/quiz/serializers/participant.py @@ -0,0 +1,45 @@ +from rest_framework import serializers + +from apps.quiz.models import QuizParticipant, ParticipantAnswer + + +class ParticipantAnswerSerializer(serializers.ModelSerializer): + class Meta: + model = ParticipantAnswer + fields = ['question', 'option_num', 'at_time', 'answer_timing'] + + + +class QuizParticipantSerializer(serializers.ModelSerializer): + user = serializers.HiddenField(default=serializers.CurrentUserDefault()) + answers = ParticipantAnswerSerializer(many=True) + + def validate_quiz(self, obj): + if QuizParticipant.objects.filter(quiz=obj, user=self.context['request'].user).exists(): + raise serializers.ValidationError('you have already participated in the quiz') + + return obj + + class Meta: + model = QuizParticipant + fields = [ + 'quiz', 'user', 'started_at', 'ended_at', 'total_timing', + 'question_score', 'timing_score', 'total_score', + 'answers', + ] + + def create(self, validated_data): + answers = validated_data.pop('answers', []) + obj = super().create(validated_data) + answers_objs = [] + for ans in answers: + answers_objs.append( + ParticipantAnswer( + participant=obj, + **ans, + ) + ) + + ParticipantAnswer.objects.bulk_create(answers_objs) + + return obj diff --git a/apps/quiz/serializers/quiz.py b/apps/quiz/serializers/quiz.py new file mode 100644 index 0000000..2a9a133 --- /dev/null +++ b/apps/quiz/serializers/quiz.py @@ -0,0 +1,82 @@ +from rest_framework import serializers + +from apps.quiz.models import Question, Quiz, QuizParticipant +from apps.course.models import Lesson, Participant + + + + + + + +class QuizListSerializer(serializers.ModelSerializer): + is_complated = serializers.SerializerMethodField() + permission = serializers.SerializerMethodField() + + class Meta: + model = Quiz + fields = ['id', 'title', 'description', 'permission', 'each_question_timing', 'is_complated'] + + def get_permission(self, obj): + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return False + # Check if the user has participated in this quiz + user = request.user + course = obj.lesson.course + + if not self._is_participant(user, course): + return False + + participated = QuizParticipant.objects.filter(user=user, quiz=obj).exists() + return not participated + + + 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 + return QuizParticipant.objects.filter(user=user, quiz=obj).exists() + + + + + +class QuestionSerializer(serializers.ModelSerializer): + options = serializers.SerializerMethodField() + + def get_options(self, obj) -> list: + return [ + { + 'id': i, + 'title': getattr(obj, f"option{i}") + } for i in range(1, 5) + ] + + class Meta: + model = Question + fields = ['id', 'question', 'options', 'correct_answer'] + + +class QuizSerializer(serializers.ModelSerializer): + lesson = serializers.PrimaryKeyRelatedField(read_only=True) + questions = QuestionSerializer(many=True) + permission = serializers.SerializerMethodField() + + class Meta: + model = Quiz + fields = ['id', 'permission', 'lesson', 'title', 'description', 'each_question_timing', 'questions'] + + def get_permission(self, obj): + request = self.context.get('request') + if not request or not request.user.is_authenticated: + return False + # Check if the user has participated in this quiz + user = request.user + participated = QuizParticipant.objects.filter(user=user, quiz=obj).exists() + return not participated diff --git a/apps/quiz/tests.py b/apps/quiz/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/quiz/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/quiz/urls.py b/apps/quiz/urls.py new file mode 100644 index 0000000..6ced27c --- /dev/null +++ b/apps/quiz/urls.py @@ -0,0 +1,13 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + # path('prizes/', PrizeListAPIView.as_view()), + # path('ranked-list/', RankedListAPIView.as_view()), + # path('self-rank/', SelfRankAPIView.as_view()), + # path('my-quizzes/', UserQuizScores.as_view()), + path('submit-quiz/', views.QuizParticipantCreateAPIView.as_view()), + path('/', views.QuizDetailAPIView.as_view()), + +] diff --git a/apps/quiz/views.py b/apps/quiz/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/apps/quiz/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/apps/quiz/views/__init__.py b/apps/quiz/views/__init__.py new file mode 100644 index 0000000..16a10f1 --- /dev/null +++ b/apps/quiz/views/__init__.py @@ -0,0 +1,2 @@ +from .quiz import * +from .participant import * \ No newline at end of file diff --git a/apps/quiz/views/participant.py b/apps/quiz/views/participant.py new file mode 100644 index 0000000..2a0504d --- /dev/null +++ b/apps/quiz/views/participant.py @@ -0,0 +1,22 @@ +from django.db.models import Value +from rest_framework.generics import RetrieveAPIView, CreateAPIView +from rest_framework.permissions import IsAuthenticated + +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from apps.quiz.serializers import QuizParticipantSerializer +from apps.quiz.doc import * + + + +class QuizParticipantCreateAPIView(CreateAPIView): + serializer_class = QuizParticipantSerializer + permission_classes = [IsAuthenticated] + + + @swagger_auto_schema( + operation_description=doc_quiz_submit(), + ) + def post(self, request, *args, **kwargs): + return super().post(request, *args, **kwargs) diff --git a/apps/quiz/views/quiz.py b/apps/quiz/views/quiz.py new file mode 100644 index 0000000..0f42c90 --- /dev/null +++ b/apps/quiz/views/quiz.py @@ -0,0 +1,33 @@ +from django.db.models import Value +from rest_framework.generics import RetrieveAPIView +from rest_framework.permissions import IsAuthenticated + +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from apps.quiz.models import Quiz +from apps.quiz.serializers.quiz import QuizSerializer +from apps.quiz.doc import * + + + +class QuizDetailAPIView(RetrieveAPIView): + serializer_class = QuizSerializer + permission_classes = [IsAuthenticated] + + + @swagger_auto_schema( + operation_description=doc_quiz_detail(), + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_object(self): + return Quiz.objects.filter( + id=self.kwargs['quiz_id'], + ).annotate( + lesson__has_quiz=Value(True) + ).select_related('lesson').first() + + + + diff --git a/apps/transaction/__init__.py b/apps/transaction/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/transaction/admin.py b/apps/transaction/admin.py new file mode 100644 index 0000000..e20d6c1 --- /dev/null +++ b/apps/transaction/admin.py @@ -0,0 +1,58 @@ +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, StackedInline +from unfold.decorators import display + +from apps.transaction.models import TransactionParticipant, ParticipantInfo + +from utils.admin import project_admin_site + +class ParticipantInfoInline(StackedInline): + model = ParticipantInfo + extra = 1 + fields = ['fullname', 'email', 'phone_number', 'gender', 'birthdate'] + # readonly_fields = ['email', 'phone_number'] + classes = ['collapse'] + tab = True + show_change_link = True + + +@admin.register(TransactionParticipant) +class TransactionParticipantAdmin(ModelAdmin): + list_display = ('user', 'course', 'payment_status', 'price_display', 'created_at', 'updated_at') + list_filter = ('is_paid', 'course', 'created_at') + search_fields = ('user__email', 'course__title') + readonly_fields = [ 'created_at', 'updated_at'] + inlines = [ParticipantInfoInline] + autocomplete_fields = ['user',] + show_change_link = True + ordering = ('-created_at',) + + fieldsets = ( + (None, { + 'fields': ('user', 'course', 'is_paid', 'price') + }), + (_('Timestamps'), { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + @display(description=_("Payment Status"), ordering="is_paid") + def payment_status(self, obj): + if obj.is_paid: + return format_html('Paid') + return format_html('Unpaid') + + @display(description=_("Price"), ordering="price") + def price_display(self, obj): + return format_html('${}', obj.price) + + def get_queryset(self, request): + queryset = super().get_queryset(request) + # Add any custom queryset modifications here if needed + return queryset + +project_admin_site.register(TransactionParticipant, TransactionParticipantAdmin) diff --git a/apps/transaction/apps.py b/apps/transaction/apps.py new file mode 100644 index 0000000..8cf8565 --- /dev/null +++ b/apps/transaction/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class TransactionConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.transaction' diff --git a/apps/transaction/migrations/0001_initial.py b/apps/transaction/migrations/0001_initial.py new file mode 100644 index 0000000..6fc1605 --- /dev/null +++ b/apps/transaction/migrations/0001_initial.py @@ -0,0 +1,44 @@ +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +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 = [ + ('course', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TransactionParticipant', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('is_paid', models.BooleanField(default=False, help_text='Indicates whether the payment has been completed or not', verbose_name='Payment Status')), + ('price', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, verbose_name='Transaction Price')), + ('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='course_transactions', to='course.course')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='transactions', to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='ParticipantInfo', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('fullname', models.CharField(help_text='Enter the full name of the user.', max_length=255, verbose_name='Full Name')), + ('email', models.EmailField(help_text="Enter the user's email address.", max_length=254, verbose_name='Email Address')), + ('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')), + ('birthdate', models.DateField(blank=True, null=True, verbose_name='birthdate')), + ('transaction_participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participant_infos', to='transaction.transactionparticipant', verbose_name='Transaction Participant')), + ], + ), + ] diff --git a/apps/transaction/migrations/__init__.py b/apps/transaction/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/transaction/models.py b/apps/transaction/models.py new file mode 100644 index 0000000..958ba77 --- /dev/null +++ b/apps/transaction/models.py @@ -0,0 +1,50 @@ +from django.db import models + +from django.utils.translation import gettext_lazy as _ + +from apps.account.models import StudentUser, User +from apps.course.models import Course +from phonenumber_field.modelfields import PhoneNumberField +from utils.validators import validate_possible_number + + + + + +class TransactionParticipant(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='transactions') + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='course_transactions') + is_paid = models.BooleanField(default=False, verbose_name='Payment Status', help_text='Indicates whether the payment has been completed or not') + price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name='Transaction Price') + + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated at")) + + + + +class ParticipantInfo(models.Model): + class GenderChoices(models.TextChoices): + MALE = 'male', 'Male' + FEMALE = 'female', 'Female' + + transaction_participant = models.ForeignKey( + TransactionParticipant, + on_delete=models.CASCADE, + related_name='participant_infos', + verbose_name="Transaction Participant" + ) + fullname = models.CharField(max_length=255, verbose_name="Full Name", help_text="Enter the full name of the user.") + email = models.EmailField(verbose_name="Email Address", help_text="Enter the user's email address.") + phone_number = PhoneNumberField(validators=[validate_possible_number], null=True, blank=True, verbose_name=_('phone')) + gender = models.CharField( + max_length=20, choices=GenderChoices.choices, null=True, blank=True, verbose_name=_('Gender'), help_text="Select the user's gender." + ) + birthdate = models.DateField(verbose_name=_('birthdate'), null=True, blank=True) + + + + + + + diff --git a/apps/transaction/serializers.py b/apps/transaction/serializers.py new file mode 100644 index 0000000..01b6c0a --- /dev/null +++ b/apps/transaction/serializers.py @@ -0,0 +1,49 @@ + +from rest_framework import serializers + +from apps.transaction.models import TransactionParticipant, ParticipantInfo +from apps.course.serializers import CourseDetailSerializer + + + + +class ParticipantInfoSerializer(serializers.ModelSerializer): + phone_number = serializers.CharField(max_length=30) + + class Meta: + model = ParticipantInfo + fields = ['fullname', 'email', 'phone_number', 'gender', 'birthdate'] + + def validate_phone_number(self, value): + return value + + +class TransactionParticipantSerializer(serializers.ModelSerializer): + participant_infos = ParticipantInfoSerializer(many=True) + + class Meta: + model = TransactionParticipant + fields = ['participant_infos'] + + + def create(self, validated_data): + participant_infos_data = validated_data.pop('participant_infos', []) + transaction_participant = TransactionParticipant.objects.create(**validated_data) + + for participant_info_data in participant_infos_data: + ParticipantInfo.objects.create(transaction_participant=transaction_participant, **participant_info_data) + + return transaction_participant + + + +class TransactionListSerializer(serializers.ModelSerializer): + course = serializers.SerializerMethodField() + + class Meta: + model = TransactionParticipant + fields = ['course', 'is_paid', 'price', 'created_at', 'updated_at'] + + def get_course(self, obj): + return CourseDetailSerializer(obj.course, context=self.context).data + \ No newline at end of file diff --git a/apps/transaction/tests.py b/apps/transaction/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/transaction/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/transaction/urls.py b/apps/transaction/urls.py new file mode 100644 index 0000000..01f8804 --- /dev/null +++ b/apps/transaction/urls.py @@ -0,0 +1,13 @@ + +from django.urls import path + +from . import views + + + +urlpatterns = [ + path('/join/', views.TransactionParticipantCreateView.as_view(), name='transaction-participant-create'), + path('list/', views.TransactiontListView.as_view(), name='transaction-list'), + + +] diff --git a/apps/transaction/views.py b/apps/transaction/views.py new file mode 100644 index 0000000..4801f09 --- /dev/null +++ b/apps/transaction/views.py @@ -0,0 +1,77 @@ +from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from apps.course.models import Participant, Course +from apps.transaction.models import TransactionParticipant +from apps.transaction.serializers import TransactionParticipantSerializer, TransactionListSerializer +from utils.exceptions import AppAPIException +from apps.account.models import User + + + +class TransactionParticipantCreateView(generics.CreateAPIView): + queryset = TransactionParticipant.objects.all() + serializer_class = TransactionParticipantSerializer + 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=course_slug) # Retrieve the Course object + except Course.DoesNotExist: + raise AppAPIException({'message': "Course not found"}) # Handle course not found + + participant_infos = request.data.get('participant_infos', []) + print(f'1---> {participant_infos}') + print(f'2---> {len(participant_infos)}') + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + if len(participant_infos) == 1 and (course.final_price == 0 or course.is_free): + participant = participant_infos[0] + if participant.get('email') != user.email: + raise AppAPIException({'message': "The email must be for the requesting user"}) + + if user.user_type != User.UserType.STUDENT: + user = User.objects.change_user_type(user, User.UserType.STUDENT) + + participant, created = Participant.objects.get_or_create( + student=user, + course=course + ) + return Response({ + 'message': 'Transaction Participant created successfully.', + 'participant_id': participant.id, + 'participant_infos': serializer.data['participant_infos'] + }, status=status.HTTP_201_CREATED) + + + + transaction_participant = serializer.save(user=user, course=course, price=course.final_price) + print(f'---> {type(transaction_participant)}/ {transaction_participant}') + return Response({ + 'message': 'Transaction Participant created successfully.', + 'transaction_id': transaction_participant.id, + 'participant_infos': serializer.data['participant_infos'] + }, status=status.HTTP_201_CREATED) + + + + + + + +class TransactiontListView(generics.ListAPIView): + queryset = TransactionParticipant.objects.all() # یا هر فیلتر که بخواهید اضافه کنید + serializer_class = TransactionListSerializer + permission_classes = [IsAuthenticated] # برای دسترسی کاربران احراز هویت شده + + + def get_queryset(self): + queryset = super().get_queryset() + queryset = queryset.filter(user=self.request.user) + return queryset \ No newline at end of file diff --git a/apps/video/__init__.py b/apps/video/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/video/admin.py b/apps/video/admin.py new file mode 100644 index 0000000..5c23d54 --- /dev/null +++ b/apps/video/admin.py @@ -0,0 +1,85 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ +from django.urls import reverse +from django.utils.html import format_html +from ajaxdatatable.admin import AjaxDatatable + +from apps.video.models import * + + +class VideoInCollectionInline(admin.TabularInline): + model = VideoInCollection + extra = 1 + autocomplete_fields = ('video',) + ordering = ('priority',) + + +class VideoCollectionAdminBase(AjaxDatatable): + """Base admin class for all video collection types""" + list_display = ('title', 'status', 'order', 'count_videos', 'created_at') + list_filter = ('status', 'created_at', 'updated_at') + search_fields = ('title',) + inlines = [VideoInCollectionInline] + + fieldsets = ( + (None, { + 'fields': ('title', 'status', 'order') + }), + ) + + + @admin.display(description=_('Number of Videos')) + def count_videos(self, obj): + count = obj.videos.count() + if count > 0: + url = reverse('admin:video_video_changelist') + f'?collections__id__exact={obj.id}' + return format_html('{}', url, count) + return count + + +# @admin.register(VideoCollection) +# class VideoCollectionAdmin(VideoCollectionAdminBase): +# """Admin for all video collections""" +# list_display = ('title', 'status', 'count_videos', 'created_at') +# list_filter = ('status', 'created_at', 'updated_at') + + + +@admin.register(VideoCategory) +class VideoCategoryAdmin(AjaxDatatable): + list_display = ('title', 'slug', 'status', 'order', 'count_videos', 'created_at') + list_filter = ('status', 'created_at', 'updated_at') + search_fields = ('title', 'slug') + + + @admin.display(description=_('Number of Videos')) + def count_videos(self, obj): + count = obj.videos.count() + if count > 0: + url = reverse('admin:video_video_changelist') + f'?category__id__exact={obj.id}' + return format_html('{}', url, count) + return count + + +@admin.register(Video) +class VideoAdmin(AjaxDatatable): + list_display = ('title', 'slug', 'video_type', 'status', 'view_count', 'created_at') + list_filter = ('status', 'video_type', 'created_at', 'updated_at') + search_fields = ('title', 'slug', 'description') + autocomplete_fields = ('categories',) + + fieldsets = ( + (None, { + 'fields': ('title', 'slug', 'description', 'thumbnail', 'categories') + }), + (_('Video Information'), { + 'fields': ('video_type', 'video_file', 'video_url', 'video_time') + }), + (_('Status'), { + 'fields': ('status',) + }), + (_('Statistics'), { + 'fields': ('view_count',) + }), + ) + diff --git a/apps/video/apps.py b/apps/video/apps.py new file mode 100644 index 0000000..c091c61 --- /dev/null +++ b/apps/video/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class VideoConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.video' diff --git a/apps/video/migrations/0001_initial.py b/apps/video/migrations/0001_initial.py new file mode 100644 index 0000000..bb00edc --- /dev/null +++ b/apps/video/migrations/0001_initial.py @@ -0,0 +1,91 @@ +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +import django.db.models.deletion +import filer.fields.image +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='VideoCategory', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('slug', models.SlugField(allow_unicode=True, unique=True, verbose_name='slug')), + ('status', models.BooleanField(default=True, verbose_name='status')), + ('order', models.PositiveIntegerField(default=0, verbose_name='order')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ], + options={ + 'verbose_name': 'Video Category', + 'verbose_name_plural': 'Video Categories', + 'ordering': ['order'], + }, + ), + migrations.CreateModel( + name='VideoCollection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(help_text='This title will not be displayed anywhere', max_length=255)), + ('status', models.BooleanField(default=True, verbose_name='status')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ], + options={ + 'verbose_name': 'Video Collection', + 'verbose_name_plural': 'Video Collections', + }, + ), + migrations.CreateModel( + name='Video', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, null=True)), + ('slug', models.SlugField(allow_unicode=True, unique=True)), + ('description', models.TextField(null=True)), + ('video_type', models.CharField(choices=[('file', 'File'), ('youtube', 'Youtube')], default='file', max_length=255)), + ('video_file', models.FileField(blank=True, null=True, upload_to='video/videos/')), + ('video_url', models.CharField(blank=True, max_length=655, null=True)), + ('video_time', models.TimeField()), + ('view_count', models.PositiveBigIntegerField(default=0, verbose_name='view count')), + ('status', models.BooleanField(default=True, verbose_name='status')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('thumbnail', filer.fields.image.FilerImageField(blank=True, help_text='image allowed', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.FILER_IMAGE_MODEL)), + ('categories', models.ManyToManyField(blank=True, related_name='videos', to='video.videocategory', verbose_name='categories')), + ], + options={ + 'verbose_name': 'Video', + 'verbose_name_plural': 'Videos', + }, + ), + migrations.CreateModel( + name='VideoInCollection', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('priority', models.PositiveIntegerField(default=0, verbose_name='priority')), + ('video', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='collections_videos', to='video.video', verbose_name='video')), + ('video_collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='videos_in_collection', to='video.videocollection', verbose_name='video collection')), + ], + options={ + 'verbose_name': 'Video in Collection', + 'verbose_name_plural': 'Videos in Collection', + 'ordering': ['priority'], + }, + ), + migrations.AddField( + model_name='videocollection', + name='videos', + field=models.ManyToManyField(related_name='collections', through='video.VideoInCollection', to='video.video', verbose_name='videos'), + ), + ] diff --git a/apps/video/migrations/0002_alter_video_thumbnail.py b/apps/video/migrations/0002_alter_video_thumbnail.py new file mode 100644 index 0000000..7059e88 --- /dev/null +++ b/apps/video/migrations/0002_alter_video_thumbnail.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.8 on 2025-04-03 01:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('video', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='video', + name='thumbnail', + field=models.ImageField(blank=True, help_text='image allowed', null=True, upload_to='book_thumbnails/'), + ), + ] diff --git a/apps/video/migrations/__init__.py b/apps/video/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/video/models.py b/apps/video/models.py new file mode 100644 index 0000000..4964bb4 --- /dev/null +++ b/apps/video/models.py @@ -0,0 +1,101 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from filer.fields.image import FilerImageField + + +class VideoCategory(models.Model): + title = models.CharField(max_length=255, verbose_name=_('title')) + slug = models.SlugField(allow_unicode=True, unique=True, verbose_name=_('slug')) + + status = models.BooleanField(default=True, verbose_name=_('status')) + order = models.PositiveIntegerField(default=0, verbose_name=_('order')) + 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 + + class Meta: + verbose_name = _('Video Category') + verbose_name_plural = _('Video Categories') + ordering = ['order'] + + +class VideoCollection(models.Model): + title = models.CharField(max_length=255, help_text="This title will not be displayed anywhere") + status = models.BooleanField(default=True, verbose_name=_('status')) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_('created at')) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_('updated at')) + videos = models.ManyToManyField( + "Video", + through='VideoInCollection', + related_name='collections', + verbose_name=_('videos'), + ) + def __str__(self): + return f'Collection #{self.id}/{self.title}' + + class Meta: + verbose_name = _('Video Collection') + verbose_name_plural = _('Video Collections') + + +class VideoInCollection(models.Model): + video_collection = models.ForeignKey( + "VideoCollection", on_delete=models.CASCADE, related_name='videos_in_collection', verbose_name=_('video collection') + ) + video = models.ForeignKey( + "Video", on_delete=models.CASCADE, related_name='collections_videos', verbose_name=_('video') + ) + priority = models.PositiveIntegerField(default=0, verbose_name=_('priority')) + + def __str__(self): + return f"{self.video_collection.title} - {self.video.title} (Priority: {self.priority})" + + class Meta: + verbose_name = _('Video in Collection') + verbose_name_plural = _('Videos in Collection') + ordering = ['priority'] + + +class Video(models.Model): + class vdeo_type(models.TextChoices): + FILE = 'file' + YOUTUBE = 'youtube' + + title = models.CharField(max_length=255, null=True) + slug = models.SlugField(allow_unicode=True, unique=True) + thumbnail = models.ImageField(upload_to='book_thumbnails/', null=True, blank=True, help_text=_('image allowed')) + description = models.TextField(null=True) + categories = models.ManyToManyField( + VideoCategory, + related_name='videos', + verbose_name=_('categories'), + blank=True, + ) + video_type = models.CharField(max_length=255, choices=vdeo_type.choices, default=vdeo_type.FILE) + video_file = models.FileField(upload_to='video/videos/', null=True, blank=True) + video_url = models.CharField(max_length=655, null=True, blank=True) + video_time = models.TimeField() + + view_count = models.PositiveBigIntegerField(default=0, verbose_name=_('view count')) + + status = models.BooleanField(default=True, verbose_name=_('status')) + + 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 increment_view_count(self): + """Increment the view count for this video""" + self.view_count += 1 + self.save(update_fields=['view_count']) + return self.view_count + + + class Meta: + verbose_name = _('Video') + verbose_name_plural = _('Videos') + diff --git a/apps/video/serializers.py b/apps/video/serializers.py new file mode 100644 index 0000000..85d95a9 --- /dev/null +++ b/apps/video/serializers.py @@ -0,0 +1,70 @@ +from rest_framework import serializers +from .models import VideoCategory, Video, VideoCollection, VideoInCollection + + +class VideoCategoryListSerializer(serializers.ModelSerializer): + video_count = serializers.SerializerMethodField() + + class Meta: + model = VideoCategory + fields = ['id', 'title', 'slug', 'video_count'] + + def get_video_count(self, obj): + return obj.videos.filter(status=True).count() + + +class VideoListSerializer(serializers.ModelSerializer): + categories = VideoCategoryListSerializer(many=True, read_only=True) + + class Meta: + model = Video + fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_time', + 'view_count', 'categories', 'created_at'] + + + + + +class VideoDetailSerializer(serializers.ModelSerializer): + related_videos = serializers.SerializerMethodField() + categories = VideoCategoryListSerializer(many=True, read_only=True) + + class Meta: + model = Video + fields = ['id', 'title', 'slug', 'thumbnail', 'description', 'video_type', + 'video_file', 'video_url', 'video_time', 'view_count', + 'categories', 'created_at', 'related_videos'] + + + def get_related_videos(self, obj): + # Get all collections that contain this video + collections = obj.collections.all() + + if collections.exists(): + # Get all videos from all collections that contain this video + related_videos = [] + video_ids = set() # To track unique videos + + for collection in collections: + # Get all videos in this collection ordered by priority + videos_in_collection = VideoInCollection.objects.filter( + video_collection=collection + ).exclude(video=obj).order_by('priority') + + # Add videos to our list if not already added + for vic in videos_in_collection: + if vic.video.id not in video_ids: + related_videos.append(vic.video) + video_ids.add(vic.video.id) + + # Return the related videos using VideoListSerializer + return VideoListSerializer(related_videos, many=True).data + + # # If not in a collection, return videos from the same category + # elif obj.category: + # related = Video.objects.filter( + # category=obj.category, + # status=True + # ).exclude(id=obj.id)[:5] + # return VideoListSerializer(related, many=True).data + return [] \ No newline at end of file diff --git a/apps/video/tests.py b/apps/video/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/video/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/video/urls.py b/apps/video/urls.py new file mode 100644 index 0000000..e42154f --- /dev/null +++ b/apps/video/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from .views import VideoCategoryListAPIView, VideoListAPIView, VideoDetailAPIView + +app_name = 'video' + +urlpatterns = [ + path('categories/', VideoCategoryListAPIView.as_view(), name='category-list'), + + path('list/', VideoListAPIView.as_view(), name='video-list'), + + path('detail//', VideoDetailAPIView.as_view(), name='video-detail'), +] \ No newline at end of file diff --git a/apps/video/views.py b/apps/video/views.py new file mode 100644 index 0000000..3e90c7e --- /dev/null +++ b/apps/video/views.py @@ -0,0 +1,49 @@ +from rest_framework import generics, status +from rest_framework.response import Response +from .models import VideoCategory, Video +from .serializers import VideoCategoryListSerializer, VideoListSerializer, VideoDetailSerializer + + +class VideoCategoryListAPIView(generics.ListAPIView): + """ + API view to list all video categories with their video counts + """ + serializer_class = VideoCategoryListSerializer + + def get_queryset(self): + return VideoCategory.objects.filter(status=True).order_by('order') + + +class VideoListAPIView(generics.ListAPIView): + """ + API view to list all videos, with optional category filtering + """ + serializer_class = VideoListSerializer + + def get_queryset(self): + queryset = Video.objects.filter(status=True).order_by('-created_at') + + # Filter by category if provided + category_slug = self.request.query_params.get('category', None) + if category_slug: + queryset = queryset.filter(category__slug=category_slug) + + return queryset + + +class VideoDetailAPIView(generics.RetrieveAPIView): + """ + API view to get video details, including related videos from the same collection + """ + serializer_class = VideoDetailSerializer + lookup_field = 'slug' + + def get_queryset(self): + return Video.objects.filter(status=True) + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + # Increment view count + instance.increment_view_count() + serializer = self.get_serializer(instance) + return Response(serializer.data) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..a9ef1ea --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,8 @@ +# __init__.py +from __future__ import absolute_import, unicode_literals + +# This will make sure the app is always imported when +# Django starts so that shared_task will use this app. +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..d436e9d --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for backend project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..3becb73 --- /dev/null +++ b/config/celery.py @@ -0,0 +1,22 @@ +import os + +import environ + +from celery import Celery + +env = environ.Env() +environ.Env.read_env(os.path.join(os.path.dirname(os.path.dirname(__file__)), '.env')) + +# Set the default Django settings module for the 'celery' program. +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') + +app = Celery('config') +# Using a string here means the worker doesn't have to serialize +# the configuration object to child processes. +# - namespace='CELERY' means all celery-related configuration keys +# should have a `CELERY_` prefix. +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Load task modules from all registered Django apps. +app.autodiscover_tasks() + diff --git a/config/language_code_middleware.py b/config/language_code_middleware.py new file mode 100644 index 0000000..d7cd30f --- /dev/null +++ b/config/language_code_middleware.py @@ -0,0 +1,19 @@ +from django.http import HttpResponse +from apps.account.models import User + +ALLOWED_URLS = [ + "/login", "/admin", "telegram-sentry", 'bot-runner', "auth/google/", "/elalhabib/submit/", '/pay', 'paypal', + 'robots.txt', "/.well-known/", "about", "/download", 'dont-kill/' +] + + + +def language_middleware(get_response): + def middleware(request): + request.LANGUAGE_CODE = request.GET.get('language_code') or request.LANGUAGE_CODE + + response = get_response(request) + + return response + + return middleware diff --git a/config/redis_config.py b/config/redis_config.py new file mode 100644 index 0000000..def87a3 --- /dev/null +++ b/config/redis_config.py @@ -0,0 +1,15 @@ +from redis import Redis, ConnectionPool + +from config.settings import base as settings + + + +pool = ConnectionPool.from_url(url= settings.REDIS_URL,max_connections=100) + + +class RedisConfig: + + def __init__(self): + self.redis = Redis(connection_pool=pool, decode_responses=True) + + \ No newline at end of file diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..b869768 --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,613 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 5.0.4. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.0/ref/settings/ +""" +import os +from pathlib import Path +from django.templatetags.static import static +from django.urls import reverse_lazy + +import environ +from django.utils.translation import gettext_lazy as _ + +env = environ.Env( + # set casting, default value + # DEBUG=(bool, False) +) +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +environ.Env.read_env(os.path.join(BASE_DIR, '.env')) + +ALLOWED_HOSTS = env('DJANGO_ALLOWED_HOSTS').split(',') + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-7=3it+m^28^+0c1*9-*c*6g3ej63sz(97rq1^mp=!6e(mhmysh' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +X_FRAME_OPTIONS = 'SAMEORIGIN' + +LOCAL_APPS = [ + 'apps.account.apps.AccountConfig', + 'apps.api.apps.ApiConfig', + 'apps.course.apps.CourseConfig', + 'apps.chat.apps.ChatConfig', + 'apps.quiz.apps.QuizConfig', + 'apps.transaction.apps.TransactionConfig', + 'apps.certificate.apps.CertificateConfig', + 'apps.hadis.apps.HadisConfig', + 'apps.library.apps.LibraryConfig', + 'apps.video.apps.VideoConfig', + 'dynamic_preferences', + +] + +THIRD_PARTY_APPS = [ + 'rest_framework', + 'rest_framework.authtoken', + 'drf_yasg', + 'rosetta', + 'easy_thumbnails', + 'phonenumber_field', + 'dj_language', + 'dj_filer', + 'ajaxdatatable', + 'dj_category', + 'corsheaders', + 'django_filters', + +] +INSTALLED_APPS = [ + "unfold", + "unfold.contrib.filters", + "unfold.contrib.import_export", + "unfold.contrib.guardian", + "unfold.contrib.simple_history", + "unfold.contrib.forms", + "unfold.contrib.inlines", + "whitenoise.runserver_nostatic", + # 'limitless_dashboard.apps.DashboardConfig', + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.humanize', # Added for humanize template tags + *THIRD_PARTY_APPS, + *LOCAL_APPS, + +] +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', # این خط را نگه دارید تا احراز هویت پیش‌فرض کار کند + 'apps.account.custom_user_login.CustomLoginBackend', # مسیر به کلاس سفارشی خود +] + +REDIS_URL = env('REDIS_URL') + + + +OTP_SERIVCE_KEY = "33213d78f1234e99b81f94eefda77e45" + + +PHONENUMBER_DEFAULT_REGION = "IR" +PHONENUMBER_DB_FORMAT = 'INTERNATIONAL' +PHONENUMBER_DEFAULT_FORMAT = 'INTERNATIONAL' + +AUTH_USER_MODEL = "account.User" +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + "whitenoise.middleware.WhiteNoiseMiddleware", + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + "django.contrib.auth.middleware.LoginRequiredMiddleware", + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'config.language_code_middleware.language_middleware', + 'config.test_auth_middleware.test_auth_middleware', +] +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + BASE_DIR / 'templates', + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'django.template.context_processors.i18n', + "utils.admin.variables", + + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + + +# django google recaptcha default keys +RECAPTCHA_PUBLIC_KEY = env('captcha_public_key') +RECAPTCHA_PRIVATE_KEY = env('captcha_private_key') + +# custom settings +APPS_REORDER = { + 'auth': { + 'icon': 'icon-shield-check', + 'name': 'Authentication' + }, + 'account': { + # 'icon': 'icon-', + 'name': 'account' + + } +} +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': env('POSTGRES_DB'), + 'USER': env('POSTGRES_USER'), + 'PASSWORD': env('POSTGRES_PASSWORD'), + 'HOST': env('POSTGRES_HOST'), + 'PORT': env('POSTGRES_PORT'), + 'ATOMIC_REQUESTS': True, + }, +} + + +CORS_ALLOW_ALL_ORIGINS = True + +THUMBNAIL_ALIASES = { + '': { + 'icon': {'size': (50, 50), 'crop': True}, + 'large': {'size': (1200, 620), 'crop': False}, + 'medium': {'size': (545, 545), 'crop': False}, + 'small': {'size': (150, 150), 'crop': False}, + }, +} + + + +LANGUAGES = [ + ('en', _('English')), + ('fa', _('Persian')), + ('ru', _('Russia')), +] +LOCALE_PATHS = [ + os.path.join(BASE_DIR, 'locale'), +] + +CELERY_BROKER_URL = env("REDIS_URL") +CELERY_RESULT_BACKEND = env("REDIS_URL") +CELERY_ACCEPT_CONTENT = ['application/json'] +CELERY_TIMEZONE = 'Asia/Tehran' +CELERY_BROKER_TRANSPORT = 'redis' +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators + + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 6, + } + }, +] + + +REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 16, + # 'DEFAULT_AUTHENTICATION_CLASSES': [ + # 'apps.account.auth_back.TokenAuthentication2', + # ], + 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + # 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema', + 'EXCEPTION_HANDLER': 'utils.exceptions.exception_handler', + + +} +# Internationalization +# https://docs.djangoproject.com/en/5.0/topics/i18n/ + +LANGUAGE_CODE = 'en' + +TIME_ZONE = 'Asia/Tehran' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = False + +STATIC_URL = '/static/' +MEDIA_URL = '/media/' + +STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] +STATIC_ROOT = os.path.join(BASE_DIR, 'static', 'static') +MEDIA_ROOT = os.path.join(BASE_DIR, 'static', 'media') + +# FILER_ADMIN_ICON_SIZES = ('32', '48') + +FILER_ENABLE_LOGGING = True +FILER_DEBUG = True +ADMIN_TITLE = 'Imam Javad App' +ADMIN_INDEX_TITLE = 'Imam Javad Administration' + + +# Dictionary with phone number ranges and corresponding countries +# If a country is in this dictionary, it indicates that the project's OTP service supports that country +SERVICE_OTP_COUNTRU_API_KEY = { + "Iran": "https://console.melipayamak.com/api/send/simple/33213d78f1234e99b81f94eefda77e45" +} +SERVICE_OTP_COUNTRY_PHONE_RANGE = { + "98": "Iran", + "+98": "Iran" +} + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.0/howto/static-files/ + + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_SHOW_CITY_GUIDE_CITY = 'mashhad' +FILE_UPLOAD_HANDLERS = [ + 'django.core.files.uploadhandler.TemporaryFileUploadHandler', +] + + + +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.gmail.com' +EMAIL_PORT = 587 +EMAIL_USE_TLS = True +EMAIL_HOST_USER = 'aliabdolahi.171@gmail.com' +EMAIL_HOST_PASSWORD = 'rkxb nnhx iave fxxt' +###################################################################### +# Sessions +###################################################################### +SESSION_ENGINE = "django.contrib.sessions.backends.signed_cookies" +LOGIN_URL = "admin:login" +LOGIN_REDIRECT_URL = reverse_lazy("admin:index") +# STORAGES = { +# "default": { +# "BACKEND": "django.core.files.storage.FileSystemStorage", +# }, +# "staticfiles": { +# "BACKEND": "whitenoise.storage.CompressedStaticFilesStorage", +# }, +# } +###################################################################### +# Unfold +###################################################################### +UNFOLD = { + "SITE_TITLE": _("Imam Jawad Admin"), + "SITE_HEADER": _("Imam Jawad Admin"), + "SITE_SUBHEADER": _("Imam Jawad Online School"), + "SITE_DROPDOWN": [ + { + "icon": "diamond", + "title": _("Imam Javad Site"), + "link": "https://habibapp.com", + }, + ], + "SITE_SYMBOL": "settings", + "SHOW_HISTORY": True, + "SHOW_LANGUAGES": True, + "ENVIRONMENT": "utils.environment_callback", + "DASHBOARD_CALLBACK": "utils.admin.dashboard_callback", + "SITE_ICON": { + "light": lambda request: static("images/logo1.svg"), # light mode + "dark": lambda request: static("images/logo1.svg"), # dark mode + }, + "SITE_SYMBOL": "speed", + "SHOW_BACK_BUTTON": True, # show/hide "Back" button on changeform in header, default: False + "THEME": "dark", + "LOGIN": { + "image": lambda request: static("images/image1.jpg"), + }, + "COLORS": { + "base": { + "50": "249 250 251", + "100": "243 244 246", + "200": "229 231 235", + "300": "209 213 219", + "400": "156 163 175", + "500": "107 114 128", + "600": "75 85 99", + "700": "55 65 81", + "800": "31 41 55", + "900": "17 24 39", + "950": "3 7 18", + }, + "primary": { + "50": "234 253 243", + "100": "208 251 232", + "200": "167 247 216", + "300": "110 240 189", + "400": "37 213 152", + "500": "37 208 118", # #25D076 - رنگ دکمه اصلی + "600": "29 166 94", + "700": "25 136 80", + "800": "22 108 66", + "900": "20 89 57", + "950": "10 53 34", + }, + "secondary": { + "50": "240 253 250", + "100": "204 251 241", + "200": "153 246 228", + "300": "94 234 212", + "400": "45 212 191", + "500": "1 53 59", # #01353B - رنگ پس‌زمینه + "600": "1 43 48", + "700": "1 36 40", + "800": "1 30 34", + "900": "0 26 29", + "950": "0 13 15", + }, + "font": { + "subtle-light": "var(--color-base-500)", + "subtle-dark": "var(--color-base-400)", + "default-light": "var(--color-secondary-500)", # استفاده از رنگ ثانویه برای متن + "default-dark": "var(--color-base-300)", + "important-light": "var(--color-base-900)", + "important-dark": "255 255 255", # #FFFFFF - برای متن سفید در دکمه‌ها + }, + }, + "STYLES": [ + # lambda request: static("css/styles.css"), + ], + "SCRIPTS": [ + # lambda request: static("js/chart.min.js"), + ], + "TABS": [ + { + "page": "accounts", + "models": ["account.user", 'auth.group'], + "items": [ + { + "title": _("Users"), + "icon": "sports_motorsports", + "link": reverse_lazy("admin:account_user_changelist"), + "active": lambda request: request.path + == reverse_lazy("admin:account_user_changelist") + and "email__isnull" not in request.GET, + }, + { + "title": _("Guest Users"), + "icon": "sports_motorsports", + "link": lambda request: f"{reverse_lazy('admin:account_user_changelist')}?email__isnull=true", + }, + ], + }, + { + "page": "authentication", + "models": ["auth.group", "auth.permission"], + "permission": lambda request: request.user.is_staff, + "items": [ + { + "title": _("Groups"), + "icon": "shield", + "link": reverse_lazy("admin:auth_group_changelist"), + }, + ], + }, + { + "page": "courses", + "models": [ + "course.course", + "course.coursecategory", + "course.lesson", + "course.glossary", + "course.attachment", + "quiz.quiz", + ], + "items": [ + { + "title": _("Courses"), + "icon": "school", + "link": reverse_lazy("admin:course_course_changelist"), + "active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_course_changelist"))), + }, + { + "title": _("Lessons"), + "icon": "menu_book", + "link": reverse_lazy("admin:course_lesson_changelist"), + "active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_lesson_changelist"))), + }, + { + "title": _("Attachments"), + "icon": "attach_file", + "link": reverse_lazy("admin:course_attachment_changelist"), + "active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_attachment_changelist"))), + }, + { + "title": _("Glossary"), + "icon": "book", + "link": reverse_lazy("admin:course_glossary_changelist"), + "active": lambda request: request.path.startswith(str(reverse_lazy("admin:course_glossary_changelist"))), + }, + { + "title": _("Quizzes"), + "icon": "quiz", + "link": reverse_lazy("admin:quiz_quiz_changelist"), + "active": lambda request: request.path.startswith(str(reverse_lazy("admin:quiz_quiz_changelist"))), + }, + + ], + }, + ], + "SIDEBAR": { + "show_search": True, + "show_all_applications": True, + "navigation": [ + { + "title": _(""), + "separator": True, + "items": [ + { + "title": _("Dashboard"), + "icon": "dashboard", + "link": reverse_lazy("admin:index"), + }, + ], + }, + { + "title": _(""), + "items": [ + { + "title": _("Authentication"), + "icon": "shield", + "link": reverse_lazy("admin:auth_group_changelist"), + "permission": lambda request: request.user.is_staff, + }, + ], + }, + { + "title": _(""), + "items": [ + { + "title": _("Users"), + "icon": "person", + "link": reverse_lazy("admin:account_user_changelist"), + "permission": lambda request: request.user.is_staff, + }, + ], + }, + { + "title": _(""), + "items": [ + { + "title": _("Students"), + "icon": "school", + "link": reverse_lazy("admin:account_studentuser_changelist"), + "permission": lambda request: request.user.is_staff, + }, + + ] + }, + { + "title": _(""), + "items": [ + { + "title": _("Professors"), + "icon": "person_book", + "link": reverse_lazy("admin:account_professoruser_changelist"), + "permission": lambda request: request.user.is_staff, + }, + + ] + }, + { + "title": _("Courses"), + "collapsible": True, + "separator": True, + "items": [ + { + "title": _("Courses"), + "icon": "school", + "link": reverse_lazy("admin:course_course_changelist"), + }, + { + "title": _("Categories"), + "icon": "category", + "link": reverse_lazy("admin:course_coursecategory_changelist"), + }, + { + "title": _("Certificates"), + "icon": "workspace_premium", + "link": reverse_lazy("admin:certificate_certificate_changelist"), + }, + # { + # "title": _("Lessons"), + # "icon": "menu_book", + # "link": reverse_lazy("admin:course_lesson_changelist"), + # }, + # { + # "title": _("Attachments"), + # "icon": "attach_file", + # "link": reverse_lazy("admin:course_attachment_changelist"), + # }, + # { + # "title": _("Glossary"), + # "icon": "book", + # "link": reverse_lazy("admin:course_glossary_changelist"), + # }, + ] + }, + { + "title": _("Transactions"), + "collapsible": True, + "separator": True, + "items": [ + { + "title": _("Transactions"), + "icon": "payments", + "link": reverse_lazy("admin:transaction_transactionparticipant_changelist"), + }, + ] + }, + # { + # "title": _("Hadis"), + # "collapsible": True, + # "separator": True, + # "items": [ + # { + # "title": _("Hadis Categories"), + # "icon": "category", + # "link": reverse_lazy("admin:hadis_hadiscategory_changelist"), + # }, + # # { + # # "title": _("Hadis"), + # # "icon": "format_quote", + # # "link": reverse_lazy("admin:hadis_hadis_changelist"), + # # }, + # ] + # }, + ], + }, +} +UNFOLD_STUDIO_DEFAULT_FRAGMENT = "color-schemes" +UNFOLD_STUDIO_PERMISSION = lambda request: request.user.is_authenticated + +PLAUSIBLE_DOMAIN = env("PLAUSIBLE_DOMAIN") diff --git a/config/settings/develop.py b/config/settings/develop.py new file mode 100644 index 0000000..b41e666 --- /dev/null +++ b/config/settings/develop.py @@ -0,0 +1,24 @@ +from .base import * + +# DJANGO_REDIS_IGNORE_EXCEPTIONS = True +DEBUG = True + +CORS_ALLOW_ALL_ORIGINS = True + +# Explicitly enable Unfold Studio in development mode +UNFOLD_STUDIO_ENABLE_SAVE = True +UNFOLD_STUDIO_ENABLE_FILEUPLOAD = True +UNFOLD_STUDIO_ALWAYS_OPEN = True +# Allow all authenticated users to access the studio in development mode +UNFOLD_STUDIO_PERMISSION = lambda request: request.user.is_authenticated + +# CACHES = { +# 'default': { +# "BACKEND": "django.core.cache.backends.dummy.DummyCache", +# }, +# 'memory': { +# 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', +# 'LOCATION': 'unique-snowflake', +# 'TIMEOUT': 5000, +# }, +# } \ No newline at end of file diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..bf90107 --- /dev/null +++ b/config/settings/production.py @@ -0,0 +1,120 @@ +# import sentry_sdk + +from .base import * +from celery.schedules import crontab + +DEBUG = False + +#It is currently active +CORS_ALLOW_ALL_ORIGINS = True + +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + + + +CELERY_BROKER_URL = env("REDIS_URL") +CELERY_RESULT_BACKEND = env("REDIS_URL") +CELERY_ACCEPT_CONTENT = ['application/json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = 'Asia/Tehran' +CELERY_BROKER_TRANSPORT = 'redis' + +# زمان‌بندی Celery Beat + +CELERY_BEAT_SCHEDULE = { + 'crawler_website_bonbast_rate_usd_every_half_hour': { + 'task': 'apps.tasrif.tasks.crawler_website_bonbast_rate_usd', + 'schedule': crontab(minute=0, hour='*/1'), # اجرای هر ساعت یک‌بار + }, +} + +# CORS_ALLOWED_ORIGINS = [ +# 'https://aqila.nwhco.ir', +# 'http://aqila.nwhco.ir', +# 'https://aqila.com', +# 'https://pay.aqila.com', +# 'http://pay.aqila.com', +# 'https://qa.aqila.com', +# 'http://aqila.com', +# 'http://aqila.app', +# 'https://aqila.app', +# ] + +CACHES = { + 'default': { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": env("REDIS_URL"), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + } + }, + 'memory': { + 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', + 'LOCATION': 'unique-snowflake', + 'TIMEOUT': 5000, + }, +} + +# sentry_sdk.init( +# dsn="https://4d54a16a5ea997f6dd4859a4d34da230@us.sentry.io/4506682167525376", +# # Set traces_sample_rate to 1.0 to capture 100% +# # of transactions for performance monitoring. +# traces_sample_rate=1.0, +# # Set profiles_sample_rate to 1.0 to profile 100% +# # of sampled transactions. +# # We recommend adjusting this value in production. +# profiles_sample_rate=1.0, +# ) + +REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] = [ + 'rest_framework.renderers.JSONRenderer', +] + + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "filters": { + "require_debug_false": { + "()": "django.utils.log.RequireDebugFalse", + }, + "require_debug_true": { + "()": "django.utils.log.RequireDebugTrue", + }, + }, + "formatters": { + "django.server": { + "()": "django.utils.log.ServerFormatter", + "format": "[{server_time}] {message}", + "style": "{", + } + }, + "handlers": { + "console": { + "level": "INFO", + "class": "logging.StreamHandler", + }, + "django.server": { + "level": "INFO", + "class": "logging.StreamHandler", + "formatter": "django.server", + }, + "mail_admins": { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "django.utils.log.AdminEmailHandler", + }, + }, + "loggers": { + "django": { + "handlers": ["console", "mail_admins"], + "level": "INFO", + }, + "django.server": { + "handlers": ["django.server"], + "level": "INFO", + "propagate": False, + }, + }, +} \ No newline at end of file diff --git a/config/test_auth_middleware.py b/config/test_auth_middleware.py new file mode 100644 index 0000000..c1bbfd4 --- /dev/null +++ b/config/test_auth_middleware.py @@ -0,0 +1,33 @@ +from django.core.exceptions import PermissionDenied +from rest_framework.authtoken.models import Token +from apps.account.models import User + + + +def test_auth_middleware(get_response): + """ + give access to swagger and api if admin is logged in + """ + + def middleware(request): + if "/admin/" not in request.path and request.META.get('HTTP_AUTHORIZATION') is None: + if request.user.is_authenticated and request.user.is_staff: + token, _ = Token.objects.get_or_create(user=request.user) + request.META['HTTP_AUTHORIZATION'] = "Token " + token.key + + + if "/swagger" in request.path or "/redoc" in request.path: + if not request.META.get('HTTP_AUTHORIZATION'): + user = User.objects.filter(is_staff=True, email="admin@gmail.com").first() + if user: + t, _ = Token.objects.get_or_create(user=user) + request.META['HTTP_AUTHORIZATION'] = f"Token {t}" + + # user = User.objects.filter(email="mortezaei2324@gmail.com").first() + # if user: + # t, _ = Token.objects.get_or_create(user=user) + # request.META['HTTP_AUTHORIZATION'] = f"Token {t}" + + return get_response(request) + + return middleware diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..be4bbf4 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,108 @@ +""" +URL configuration for backend project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include, re_path +from django.conf import settings +from django.conf.urls.static import static +from django.conf.urls.i18n import i18n_patterns +from utils import UploadTmpMedia +from django.http import JsonResponse +from django.shortcuts import render +from django.views.decorators.csrf import csrf_exempt +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from utils import absolute_url + +from utils.admin import project_admin_site, HomeView + +from drf_yasg.views import get_schema_view +from drf_yasg import openapi +from rest_framework import permissions +import requests +from filer import views +schema_view = get_schema_view( + openapi.Info( + title="Snippets API", + default_version='v1', + description="Project API Documentation", + terms_of_service="https://www.google.com/policies/terms/", + contact=openapi.Contact(email="nwhco.com"), + license=openapi.License(name="BSD License"), + ), + public=True, + permission_classes=(permissions.AllowAny,), +) + +def oneapi_translate(request): + dist_lang = request.GET.get('dist_lang') + q = request.GET.get('q') + url = f"https://one-api.ir/translate/?token=169700:6485a38c34b00&action=google&lang={dist_lang}&q={q}" + try: + data = requests.get(url).json() + except Exception as e: + data = {} + + return JsonResponse(data) + + +api_patterns = [ + path('test/', include('apps.api.urls')), + + path('account/', include('apps.account.urls')), + path('courses/', include('apps.course.urls')), + path('quiz/', include('apps.quiz.urls')), + path('transaction/', include('apps.transaction.urls')), + path('certificates/', include('apps.certificate.urls')), + path('hadis/', include('apps.hadis.urls')), + path('library/', include('apps.library.urls')), + + path('videos/', include('apps.video.urls')), + + path('settings/', include('dynamic_preferences.urls')), + + path('upload-tmp-media/', UploadTmpMedia.as_view()), + +] + + +urlpatterns = [ + path("admin/", HomeView.as_view(), name="home"), + path("i18n/", include("django.conf.urls.i18n")), + + # path('admin/', admin.site.urls), + path('api/', include(api_patterns)), + # path('test/', include('apps.api.urls')) + path('oneapi-translation/', oneapi_translate), + path('admin/filer/', include('filer.urls')), + +] +urlpatterns+= i18n_patterns( + path("admin/", project_admin_site.urls), + re_path(r'^swagger(?P\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'), + re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), + re_path(r'^redoc/$', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'), + path('admin/filer/', include('filer.urls')), +) + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + + + + + diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..0b0ba3a --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for backend project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.production') + +application = get_wsgi_application() diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..fd8c018 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,85 @@ +version: '3.8' + +services: + web: + container_name: imam-javad_web + restart: unless-stopped + build: + context: . + dockerfile: Dockerfile.prod + command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers=32 --timeout 560 + volumes: + - static_volume:/usr/src/app/static + ports: + - "8010:8000" + env_file: + - .env.prod + depends_on: + - postgres + links: + - postgres + networks: + - imam-javad + + postgres: + container_name: imam-javad_db + ports: + - "5575:5432" + restart: unless-stopped + image: postgres:14.0 + volumes: + - postgres_data:/var/lib/postgresql/data/ + env_file: + - .env.prod + networks: + - imam-javad + imam-javad_redis: + container_name: imam-javad_redis + image: redis:alpine + env_file: .env.prod + volumes: + - redis_data:/data + networks: + - imam-javad + + imam-javad_celery: + container_name: imam-javad_celery + build: + context: . + dockerfile: Dockerfile.prod + env_file: .env.prod + command: celery -A config worker -l info + volumes: + - .:/usr/src/app/ + - static_volume:/usr/src/app/static + + depends_on: + - imam-javad_redis + networks: + - imam-javad + + + imam-javad_celery-beat: + container_name: imam-javad_celery_beat + build: + context: . + dockerfile: Dockerfile.prod + env_file: .env.prod + command: celery -A config beat -l info + volumes: + - .:/usr/src/app/ + depends_on: + - imam-javad_redis + networks: + - imam-javad + + + +volumes: + postgres_data: + static_volume: + redis_data: + +networks: + imam-javad: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fe820ec --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +version: '3.8' + + +services: + web: + build: . + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - .:/usr/src/app + - ./volumes/static_data:/usr/src/app/static/ + ports: + - "9000:8000" + env_file: + - .env.dev + depends_on: + - postgres + networks: + - imam-javad + + postgres: + ports: + - "5444:5432" + image: postgres:13.7 + + volumes: + - ./volumes/postgres_data:/var/lib/postgresql/data + env_file: + - .env.dev + networks: + - imam-javad + + +volumes: + postgres_data: + staticfiles: +networks: + imam-javad: diff --git a/dynamic_preferences/__init__.py b/dynamic_preferences/__init__.py new file mode 100644 index 0000000..71049d9 --- /dev/null +++ b/dynamic_preferences/__init__.py @@ -0,0 +1,2 @@ +__version__ = "1.14.0" +default_app_config = "dynamic_preferences.apps.DynamicPreferencesConfig" diff --git a/dynamic_preferences/admin.py b/dynamic_preferences/admin.py new file mode 100644 index 0000000..496ae81 --- /dev/null +++ b/dynamic_preferences/admin.py @@ -0,0 +1,114 @@ +from ajaxdatatable.admin import AjaxDatatable +from django.contrib import admin +from django import forms + +from .settings import preferences_settings +from .registries import global_preferences_registry +from .models import GlobalPreferenceModel +from .forms import GlobalSinglePreferenceForm, SinglePerInstancePreferenceForm +from django.utils.translation import gettext_lazy as _ + + +class SectionFilter(admin.AllValuesFieldListFilter): + def __init__(self, field, request, params, model, model_admin, field_path): + super(SectionFilter, self).__init__( + field, request, params, model, model_admin, field_path + ) + parent_model, reverse_path = admin.utils.reverse_field_path(model, field_path) + if model == parent_model: + queryset = model_admin.get_queryset + else: + queryset = parent_model._default_manager.all() + self.registries = [] + registry_name_set = set() + for preferenceModel in queryset.distinct(): + l = len(registry_name_set) + registry_name_set.add(preferenceModel.registry.__class__.__name__) + if len(registry_name_set) != l: + self.registries.append(preferenceModel.registry) + + def choices(self, changelist): + choices = super(SectionFilter, self).choices(changelist) + for choice in choices: + display = choice["display"] + try: + for registry in self.registries: + display = registry.section_objects[display].verbose_name + choice["display"] = display + except (KeyError): + pass + yield choice + + +class DynamicPreferenceAdmin(AjaxDatatable): + list_display = ( + "verbose_name", + "help_text", + ) + fields = ("raw_value", "default_value",) + readonly_fields = ("default_value",) + change_form_template = "dynamic_preferences/dyna_change_form.html" + + @admin.display(description=_('Verbose name')) + def verbose_name(self, obj): + return obj.verbose_name + + @admin.display(description=_('Help text')) + def help_text(self, obj): + return obj.help_text + + def has_add_permission(self, request): + # if "root@admin" in request.user.username: + # return True + return False + + def has_delete_permission(self, request, obj=None): + if "root@admin" in request.user.email: + return True + return False + + if preferences_settings.ADMIN_ENABLE_CHANGELIST_FORM: + def get_changelist_form(self, request, **kwargs): + return self.changelist_form + + def default_value(self, obj): + return obj.preference.default + + default_value.short_description = _("Default Value") + + def section_name(self, obj): + try: + return obj.registry.section_objects[obj.section].verbose_name + except KeyError: + pass + return obj.section + + section_name.short_description = _("Section Name") + + def save_model(self, request, obj, form, change): + pref = form.instance + manager = pref.registry.manager() + manager.update_db_pref(pref.section, pref.name, form.cleaned_data["raw_value"]) + + +class GlobalPreferenceAdmin(DynamicPreferenceAdmin): + form = GlobalSinglePreferenceForm + changelist_form = GlobalSinglePreferenceForm + + def get_queryset(self, *args, **kwargs): + # Instanciate default prefs + manager = global_preferences_registry.manager() + manager.all() + return super(GlobalPreferenceAdmin, self).get_queryset(*args, **kwargs) + + +admin.site.register(GlobalPreferenceModel, GlobalPreferenceAdmin) + + +class PerInstancePreferenceAdmin(DynamicPreferenceAdmin): + list_display = ("instance",) + DynamicPreferenceAdmin.list_display + fields = ("instance",) + DynamicPreferenceAdmin.fields + raw_id_fields = ("instance",) + form = SinglePerInstancePreferenceForm + changelist_form = SinglePerInstancePreferenceForm + list_select_related = True diff --git a/dynamic_preferences/api/__init__.py b/dynamic_preferences/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dynamic_preferences/api/serializers.py b/dynamic_preferences/api/serializers.py new file mode 100644 index 0000000..b3c7c56 --- /dev/null +++ b/dynamic_preferences/api/serializers.py @@ -0,0 +1,71 @@ +from rest_framework import serializers +from dynamic_preferences.models import GlobalPreferenceModel + + +class PreferenceValueField(serializers.Field): + def get_attribute(self, o): + return o + + def to_representation(self, o): + return o.preference.api_repr(o.value) + + def to_internal_value(self, data): + return data + + +class PreferenceSerializer(serializers.Serializer): + + section = serializers.CharField(read_only=True) + name = serializers.CharField(read_only=True) + identifier = serializers.SerializerMethodField() + default = serializers.SerializerMethodField() + value = PreferenceValueField() + verbose_name = serializers.SerializerMethodField() + help_text = serializers.SerializerMethodField() + additional_data = serializers.SerializerMethodField() + field = serializers.SerializerMethodField() + + class Meta: + fields = [ + "default", + "value", + "verbose_name", + "help_text", + ] + + def get_default(self, o): + return o.preference.api_repr(o.preference.get("default")) + + def get_verbose_name(self, o): + return o.preference.get("verbose_name") + + def get_identifier(self, o): + return o.preference.identifier() + + def get_help_text(self, o): + return o.preference.get("help_text") + + def get_additional_data(self, o): + return o.preference.get_api_additional_data() + + def get_field(self, o): + return o.preference.get_api_field_data() + + def validate_value(self, value): + """ + We call validation from the underlying form field + """ + field = self.instance.preference.setup_field() + value = field.to_python(value) + field.validate(value) + field.run_validators(value) + return value + + def update(self, instance, validated_data): + instance.value = validated_data["value"] + instance.save() + return instance + + +class GlobalPreferenceSerializer(PreferenceSerializer): + pass diff --git a/dynamic_preferences/api/viewsets.py b/dynamic_preferences/api/viewsets.py new file mode 100644 index 0000000..8c3b822 --- /dev/null +++ b/dynamic_preferences/api/viewsets.py @@ -0,0 +1,179 @@ +from django.db import transaction +from django.db.models import Q + +from rest_framework import mixins +from rest_framework import viewsets +from rest_framework import permissions +from rest_framework.response import Response +from rest_framework.decorators import action +from rest_framework.generics import get_object_or_404 + +from dynamic_preferences import models +from dynamic_preferences import exceptions +from dynamic_preferences.settings import preferences_settings + +from . import serializers + + +class PreferenceViewSet( + mixins.UpdateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + """ + - list preferences + - detail given preference + - batch update preferences + - update a single preference + """ + + def get_queryset(self): + """ + We just ensure preferences are actually populated before fetching + from db + """ + self.init_preferences() + queryset = super(PreferenceViewSet, self).get_queryset() + + section = self.request.query_params.get("section") + if section: + queryset = queryset.filter(section=section) + + return queryset + + def get_manager(self): + return self.queryset.model.registry.manager() + + def init_preferences(self): + manager = self.get_manager() + manager.all() + + def get_object(self): + """ + Returns the object the view is displaying. + You may want to override this if you need to provide non-standard + queryset lookups. Eg if objects are referenced using multiple + keyword arguments in the url conf. + """ + queryset = self.filter_queryset(self.get_queryset()) + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + identifier = self.kwargs[lookup_url_kwarg] + section, name = self.get_section_and_name(identifier) + filter_kwargs = {"section": section, "name": name} + obj = get_object_or_404(queryset, **filter_kwargs) + + # May raise a permission denied + self.check_object_permissions(self.request, obj) + + return obj + + def get_section_and_name(self, identifier): + try: + section, name = identifier.split(preferences_settings.SECTION_KEY_SEPARATOR) + except ValueError: + # no section given + section, name = None, identifier + + return section, name + + @action(detail=False, methods=["post"]) + @transaction.atomic + def bulk(self, request, *args, **kwargs): + """ + Update multiple preferences at once + + this is a long method because we ensure everything is valid + before actually persisting the changes + """ + manager = self.get_manager() + errors = {} + preferences = [] + payload = request.data + + # first, we check updated preferences actually exists in the registry + try: + for identifier, value in payload.items(): + try: + preferences.append(self.queryset.model.registry.get(identifier)) + except exceptions.NotFoundInRegistry: + errors[identifier] = "invalid preference" + except (TypeError, AttributeError): + return Response("invalid payload", status=400) + + if errors: + return Response(errors, status=400) + + # now, we generate an optimized Q objects to retrieve all matching + # preferences at once from database + queries = [Q(section=p.section.name, name=p.name) for p in preferences] + + query = queries[0] + for q in queries[1:]: + query |= q + preferences_qs = self.get_queryset().filter(query) + + # next, we generate a serializer for each database preference + serializer_objects = [] + for p in preferences_qs: + s = self.get_serializer_class()( + p, data={"value": payload[p.preference.identifier()]} + ) + serializer_objects.append(s) + + validation_errors = {} + + # we check if any serializer is invalid + for s in serializer_objects: + if s.is_valid(): + continue + validation_errors[s.instance.preference.identifier()] = s.errors + + if validation_errors: + return Response(validation_errors, status=400) + + for s in serializer_objects: + s.save() + + return Response( + [s.data for s in serializer_objects], + status=200, + ) + + +class GlobalPreferencePermission(permissions.DjangoModelPermissions): + perms_map = { + "GET": ["%(app_label)s.change_%(model_name)s"], + "OPTIONS": ["%(app_label)s.change_%(model_name)s"], + "HEAD": ["%(app_label)s.change_%(model_name)s"], + "POST": ["%(app_label)s.change_%(model_name)s"], + "PUT": ["%(app_label)s.change_%(model_name)s"], + "PATCH": ["%(app_label)s.change_%(model_name)s"], + "DELETE": ["%(app_label)s.change_%(model_name)s"], + } + + +class GlobalPreferencesViewSet(PreferenceViewSet): + queryset = models.GlobalPreferenceModel.objects.all() + serializer_class = serializers.GlobalPreferenceSerializer + permission_classes = [GlobalPreferencePermission] + + +class PerInstancePreferenceViewSet(PreferenceViewSet): + def get_manager(self): + return self.queryset.model.registry.manager( + instance=self.get_related_instance() + ) + + def get_queryset(self): + return ( + super(PerInstancePreferenceViewSet, self) + .get_queryset() + .filter(instance=self.get_related_instance()) + ) + + def get_related_instance(self): + """ + Override this to the instance bound to the preferences + """ + raise NotImplementedError diff --git a/dynamic_preferences/apps.py b/dynamic_preferences/apps.py new file mode 100644 index 0000000..dabbca8 --- /dev/null +++ b/dynamic_preferences/apps.py @@ -0,0 +1,25 @@ +from django.apps import AppConfig, apps +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from .registries import preference_models, global_preferences_registry +from .settings import preferences_settings + + +class DynamicPreferencesConfig(AppConfig): + name = "dynamic_preferences" + verbose_name = _("Settings") + default_auto_field = "django.db.models.AutoField" + icon = 'mi-settings' + + def ready(self): + if preferences_settings.ENABLE_GLOBAL_MODEL_AUTO_REGISTRATION: + GlobalPreferenceModel = self.get_model("GlobalPreferenceModel") + + preference_models.register( + GlobalPreferenceModel, global_preferences_registry + ) + + # This will load all dynamic_preferences_registry.py files under + # installed apps + app_names = [app.name for app in apps.app_configs.values()] + global_preferences_registry.autodiscover(app_names) diff --git a/dynamic_preferences/dynamic_preferences_registry.py b/dynamic_preferences/dynamic_preferences_registry.py new file mode 100644 index 0000000..edc1dad --- /dev/null +++ b/dynamic_preferences/dynamic_preferences_registry.py @@ -0,0 +1,182 @@ +import json + +from django import forms + +from limitless_dashboard.fields.tinyeditor import TinyWidget +# from limitless_dashboard.fields.summernote import + +from dynamic_preferences.preferences import Section +from dynamic_preferences.registries import global_preferences_registry +from dynamic_preferences.types import BasePreferenceType, BaseSerializer, LongStringPreference, StringPreference, \ + FilePreference +from utils.json_editor_field import JsonEditorWidget + + +class EditorPreferences(LongStringPreference): + widget = TinyWidget(attrs={'class': 'editor-field'}) + + +@global_preferences_registry.register +class AboutUsConfig(EditorPreferences): + section = Section('aboutus', verbose_name='AboutUsConfig') + name = 'aboutus' + required = False + verbose_name = 'About Us' + default = '' + + +class JsonSerializer(BaseSerializer): + + @classmethod + def serialize(cls, value, **kwargs): + return json.dumps(value, ensure_ascii=False) + + @classmethod + def to_python(cls, value, **kwargs): + if isinstance(value, str) and len(value.strip()) > 0: + try: + return json.loads(value) + except json.JSONDecodeError as e: + try: + value_replaced = value.replace("'", '"') + return json.loads(value_replaced) + except json.JSONDecodeError as e2: + return {} + return value + + + +get_fqa_courses_schema = { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str('Questions about courses'), + 'properties': { + 'question': {'type': 'string', "format": "textarea", 'title': str('Question')}, + 'answer': { + 'type': "string", + "format": "textarea", + 'title': str('Answer') + } + } + } + } + + +class JsonFieldFAQCourse(BasePreferenceType): + field_class = forms.JSONField + serializer = JsonSerializer + widget = JsonEditorWidget(attrs={'schema': get_fqa_courses_schema}) + + +@global_preferences_registry.register +class FAQCourseConfig(JsonFieldFAQCourse): + widget = JsonEditorWidget(attrs={'schema': get_fqa_courses_schema}) + section = Section('FAQ_Course', verbose_name='Questions about courses') + name = 'FAQ_Course' + required = False + verbose_name = 'FAQ Course' + default = {} + + + +get_fqa_general_schema = { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str('Questions General'), + 'properties': { + 'question': {'type': 'string', "format": "textarea", 'title': str('Question')}, + 'answer': { + 'type': "string", + "format": "textarea", + 'title': str('Answer') + } + } + } + } + +class JsonFieldFAQGeneral(BasePreferenceType): + field_class = forms.JSONField + serializer = JsonSerializer + widget = JsonEditorWidget(attrs={'schema': get_fqa_general_schema}) + + + +@global_preferences_registry.register +class FAQGeneralConfig(JsonFieldFAQGeneral): + widget = JsonEditorWidget(attrs={'schema': get_fqa_general_schema}) + section = Section('FAQ_General', verbose_name='Questions General') + name = 'FAQ_General' + required = False + verbose_name = 'FAQ General' + default = {} + + + + + +support_fields = { + "type": "object", + "format": "table", + "title": "", + "required_by_default": 1, + "required": ['telegram_number', "whatsapp_number"], + "properties": { + "telegram_number": {"type": "string", "title": "Telegram Number"}, + "whatsapp_number": {"type": "string", "title": "Whatsapp Number"}, + } +} + + + +class JsonFieldSupport(BasePreferenceType): + field_class = forms.JSONField + serializer = JsonSerializer + widget = JsonEditorWidget(attrs={'schema': support_fields}) + +@global_preferences_registry.register +class SupportConfig(JsonFieldSupport): + section = Section('support', verbose_name='Support Detail') + name = 'support' + required = False + verbose_name = 'Support Detail' + default = {} + + + + + + +card_fields = { + "type": "object", + "format": "table", + "title": "", + "required_by_default": 1, + "required": ['card_number', 'card_name',"whatsapp_number"], + "properties": { + "card_number": {"type": "string", "title": "Card Number"}, + "card_name": {"type": "string", "title": "Card Name"}, + "whatsapp_number": {"type": "string", "title": "Whatsapp Number"}, + } +} + + + +class JsonFieldCard(BasePreferenceType): + field_class = forms.JSONField + serializer = JsonSerializer + widget = JsonEditorWidget(attrs={'schema': card_fields}) + +@global_preferences_registry.register +class SupportConfig(JsonFieldCard): + section = Section('card', verbose_name='Card Detail') + name = 'card' + required = False + verbose_name = 'Card Detail' + default = {} + diff --git a/dynamic_preferences/exceptions.py b/dynamic_preferences/exceptions.py new file mode 100644 index 0000000..e08fcd5 --- /dev/null +++ b/dynamic_preferences/exceptions.py @@ -0,0 +1,32 @@ +class DynamicPreferencesException(Exception): + detail_default = "An exception occurred with django-dynamic-preferences" + + def __init__(self, detail=None): + if detail is not None: + self.detail = str(detail) + else: + self.detail = str(self.detail_default) + + def __str__(self): + return self.detail + + +class MissingDefault(DynamicPreferencesException): + detail_default = "You must provide a default value for all preferences" + + +class NotFoundInRegistry(DynamicPreferencesException, KeyError): + detail_default = "Preference with this name/section not found in registry" + + +class DoesNotExist(DynamicPreferencesException): + detail_default = "Cannot retrieve preference value, ensure the preference is correctly registered and database is synced" + + +class CachedValueNotFound(DynamicPreferencesException): + detail_default = "Cached value not found" + + +class MissingModel(DynamicPreferencesException): + detail_default = 'You must define a model choice through "model" \ + or "queryset" attribute' diff --git a/dynamic_preferences/forms.py b/dynamic_preferences/forms.py new file mode 100644 index 0000000..4810376 --- /dev/null +++ b/dynamic_preferences/forms.py @@ -0,0 +1,152 @@ +from six import string_types +from django import forms +from django.core.exceptions import ValidationError +from collections import OrderedDict + +from .registries import global_preferences_registry +from .models import GlobalPreferenceModel +from .exceptions import NotFoundInRegistry + + +class AbstractSinglePreferenceForm(forms.ModelForm): + class Meta: + fields = ("section", "name", "raw_value") + + def __init__(self, *args, **kwargs): + + self.instance = kwargs.get("instance") + initial = {} + if self.instance: + initial["raw_value"] = self.instance.value + kwargs["initial"] = initial + super(AbstractSinglePreferenceForm, self).__init__(*args, **kwargs) + + if self.instance.name: + self.fields["raw_value"] = self.instance.preference.setup_field() + + def clean(self): + cleaned_data = super(AbstractSinglePreferenceForm, self).clean() + try: + self.instance.name, self.instance.section = ( + cleaned_data["name"], + cleaned_data["section"], + ) + except KeyError: # changelist form + pass + try: + self.instance.preference + except NotFoundInRegistry: + raise ValidationError(NotFoundInRegistry.detail_default) + return self.cleaned_data + + def save(self, *args, **kwargs): + self.instance.value = self.cleaned_data["raw_value"] + return super(AbstractSinglePreferenceForm, self).save(*args, **kwargs) + + +class SinglePerInstancePreferenceForm(AbstractSinglePreferenceForm): + class Meta: + fields = ("instance",) + AbstractSinglePreferenceForm.Meta.fields + + def clean(self): + cleaned_data = super(AbstractSinglePreferenceForm, self).clean() + try: + self.instance.name, self.instance.section = ( + cleaned_data["name"], + cleaned_data["section"], + ) + except KeyError: # changelist form + pass + i = cleaned_data.get("instance") + if i: + self.instance.instance = i + try: + self.instance.preference + except NotFoundInRegistry: + raise ValidationError(NotFoundInRegistry.detail_default) + return self.cleaned_data + + +class GlobalSinglePreferenceForm(AbstractSinglePreferenceForm): + class Meta: + model = GlobalPreferenceModel + fields = AbstractSinglePreferenceForm.Meta.fields + + +def preference_form_builder(form_base_class, preferences=[], **kwargs): + """ + Return a form class for updating preferences + :param form_base_class: a Form class used as the base. Must have a ``registry` attribute + :param preferences: a list of :py:class: + :param section: a section where the form builder will load preferences + """ + registry = form_base_class.registry + preferences_obj = [] + if len(preferences) > 0: + # Preferences have been selected explicitly + for pref in preferences: + if isinstance(pref, string_types): + preferences_obj.append(registry.get(name=pref)) + elif type(pref) == tuple: + preferences_obj.append(registry.get(name=pref[0], section=pref[1])) + else: + raise NotImplementedError( + "The data you provide can't be converted to a Preference object" + ) + elif kwargs.get("section", None): + # Try to use section param + preferences_obj = registry.preferences(section=kwargs.get("section", None)) + + else: + # display all preferences in the form + preferences_obj = registry.preferences() + + fields = OrderedDict() + instances = [] + if "model" in kwargs: + # backward compat, see #212 + manager_kwargs = kwargs.get("model") + else: + manager_kwargs = {"instance": kwargs.get("instance", None)} + manager = registry.manager(**manager_kwargs) + + for preference in preferences_obj: + f = preference.field + instance = manager.get_db_pref( + section=preference.section.name, name=preference.name + ) + f.initial = instance.value + fields[preference.identifier()] = f + instances.append(instance) + + form_class = type("Custom" + form_base_class.__name__, (form_base_class,), {}) + form_class.base_fields = fields + form_class.preferences = preferences_obj + form_class.instances = instances + form_class.manager = manager + return form_class + + +def global_preference_form_builder(preferences=[], **kwargs): + """ + A shortcut :py:func:`preference_form_builder(GlobalPreferenceForm, preferences, **kwargs)` + """ + return preference_form_builder(GlobalPreferenceForm, preferences, **kwargs) + + +class PreferenceForm(forms.Form): + + registry = None + + def update_preferences(self, **kwargs): + for instance in self.instances: + self.manager.update_db_pref( + instance.preference.section.name, + instance.preference.name, + self.cleaned_data[instance.preference.identifier()], + ) + + +class GlobalPreferenceForm(PreferenceForm): + + registry = global_preferences_registry diff --git a/dynamic_preferences/locale/ar/LC_MESSAGES/django.po b/dynamic_preferences/locale/ar/LC_MESSAGES/django.po new file mode 100644 index 0000000..fcc026d --- /dev/null +++ b/dynamic_preferences/locale/ar/LC_MESSAGES/django.po @@ -0,0 +1,60 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2018-11-08 10:37+0100\n" +"PO-Revision-Date: 2018-11-09 17:15+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 " +"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n" +"Language: ar\n" +"X-Generator: Poedit 2.1.1\n" + +#: .\admin.py:56 +msgid "Default Value" +msgstr "القيمة الافتراضية" + +#: .\admin.py:65 +msgid "Section Name" +msgstr "إسم القسم" + +#: .\apps.py:9 +msgid "Dynamic Preferences" +msgstr "التفضيلات الديناميكية" + +#: .\models.py:25 +msgid "Name" +msgstr "الاسم" + +#: .\models.py:28 +msgid "Raw Value" +msgstr "القيمة الأولية" + +#: .\models.py:42 +msgid "Verbose Name" +msgstr "اسم مطول" + +#: .\models.py:47 +msgid "Help Text" +msgstr "نص المساعدة" + +#: .\models.py:84 +msgid "Global preference" +msgstr "التفضيل العام" + +#: .\models.py:85 +msgid "Global preferences" +msgstr "التفضيل العام" + +#: .\templates\dynamic_preferences\form.html:11 +msgid "Submit" +msgstr "إرسال" diff --git a/dynamic_preferences/locale/de/LC_MESSAGES/django.po b/dynamic_preferences/locale/de/LC_MESSAGES/django.po new file mode 100644 index 0000000..76a18f2 --- /dev/null +++ b/dynamic_preferences/locale/de/LC_MESSAGES/django.po @@ -0,0 +1,71 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-04-15 13:48+0200\n" +"PO-Revision-Date: 2018-11-09 17:14+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: fr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Generator: Poedit 2.1.1\n" + +#: .\admin.py:56 +msgid "Default Value" +msgstr "Standardwert" + +#: .\admin.py:65 .\models.py:22 +msgid "Section Name" +msgstr "Abschnitt" + +#: .\apps.py:9 +msgid "Dynamic Preferences" +msgstr "Dynamische Einstellungen" + +#: .\models.py:25 +msgid "Name" +msgstr "Name" + +#: .\models.py:28 +msgid "Raw Value" +msgstr "Wert" + +#: .\models.py:42 +msgid "Verbose Name" +msgstr "Bezeichnung" + +#: .\models.py:47 +msgid "Help Text" +msgstr "Hilfetext" + +#: .\models.py:84 +msgid "Global preference" +msgstr "Globale Einstellung" + +#: .\models.py:85 +msgid "Global preferences" +msgstr "Globale Einstellungen" + +#: .\templates\dynamic_preferences\form.html:11 +msgid "Submit" +msgstr "Absenden" + +#: .\users\apps.py:11 +msgid "Preferences - Users" +msgstr "Einstellungen - Benutzer" + +#: .\users\models.py:15 +msgid "user preference" +msgstr "Benutzer Einstellung" + +#: .\users\models.py:16 +msgid "user preferences" +msgstr "Benutzer Einstellungen" diff --git a/dynamic_preferences/locale/fa/LC_MESSAGES/django.po b/dynamic_preferences/locale/fa/LC_MESSAGES/django.po new file mode 100644 index 0000000..48365ed --- /dev/null +++ b/dynamic_preferences/locale/fa/LC_MESSAGES/django.po @@ -0,0 +1,86 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-04-04 06:00+0330\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: dynamic_preferences/admin.py:52 +#, fuzzy +#| msgid "Verbose Name" +msgid "Verbose name" +msgstr "نام" + +#: dynamic_preferences/admin.py:56 +#, fuzzy +#| msgid "Help Text" +msgid "Help text" +msgstr "متن راهنما" + +#: dynamic_preferences/admin.py:77 +msgid "Default Value" +msgstr "مقدار پیشفرض" + +#: dynamic_preferences/admin.py:86 dynamic_preferences/models.py:30 +msgid "Section Name" +msgstr "عنوان بخش" + +#: dynamic_preferences/apps.py:10 +msgid "Settings" +msgstr "" + +#: dynamic_preferences/models.py:34 +msgid "Name" +msgstr "نام" + +#: dynamic_preferences/models.py:37 +msgid "Raw Value" +msgstr "مقدار" + +#: dynamic_preferences/models.py:51 +msgid "Verbose Name" +msgstr "نام" + +#: dynamic_preferences/models.py:57 +msgid "Help Text" +msgstr "متن راهنما" + +#: dynamic_preferences/models.py:94 +msgid "Global preference" +msgstr "تنطیمات عمومی" + +#: dynamic_preferences/models.py:95 +msgid "Global preferences" +msgstr "تنطیمات عمومی" + +#: dynamic_preferences/templates/dynamic_preferences/form.html:11 +msgid "Submit" +msgstr "ثبت" + +#: dynamic_preferences/users/apps.py:11 +msgid "Preferences - Users" +msgstr "" + +#: dynamic_preferences/users/models.py:14 +msgid "user preference" +msgstr "" + +#: dynamic_preferences/users/models.py:15 +msgid "user preferences" +msgstr "" + +#~ msgid "Dynamic Preferences" +#~ msgstr "تنظیمات" diff --git a/dynamic_preferences/locale/fr/LC_MESSAGES/django.po b/dynamic_preferences/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 0000000..cc9d2fc --- /dev/null +++ b/dynamic_preferences/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,59 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2018-11-08 10:37+0100\n" +"PO-Revision-Date: 2018-11-09 17:14+0100\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"Language: fr\n" +"X-Generator: Poedit 2.1.1\n" + +#: .\admin.py:56 +msgid "Default Value" +msgstr "Valeur par défaut" + +#: .\admin.py:65 +msgid "Section Name" +msgstr "Nom de la Section" + +#: .\apps.py:9 +msgid "Dynamic Preferences" +msgstr "Préférences dynamiques" + +#: .\models.py:25 +msgid "Name" +msgstr "Nom" + +#: .\models.py:28 +msgid "Raw Value" +msgstr "Valeur RAW" + +#: .\models.py:42 +msgid "Verbose Name" +msgstr "Nom détaillé" + +#: .\models.py:47 +msgid "Help Text" +msgstr "Texte d'Aide" + +#: .\models.py:84 +msgid "Global preference" +msgstr "Préférence globale" + +#: .\models.py:85 +msgid "Global preferences" +msgstr "Préférences globales" + +#: .\templates\dynamic_preferences\form.html:11 +msgid "Submit" +msgstr "Valider" diff --git a/dynamic_preferences/locale/pl/LC_MESSAGES/django.po b/dynamic_preferences/locale/pl/LC_MESSAGES/django.po new file mode 100644 index 0000000..6d00cd1 --- /dev/null +++ b/dynamic_preferences/locale/pl/LC_MESSAGES/django.po @@ -0,0 +1,71 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-04-15 13:48+0200\n" +"PO-Revision-Date: 2020-09-23 23:59+0200\n" +"Last-Translator: \n" +"Language-Team: \n" +"Language: pl\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +"X-Generator: Poedit 2.4.1\n" + +#: .\admin.py:56 +msgid "Default Value" +msgstr "Wartość domyślna" + +#: .\admin.py:65 .\models.py:22 +msgid "Section Name" +msgstr "Nazwa sekcji" + +#: .\apps.py:9 +msgid "Dynamic Preferences" +msgstr "Dynamiczne Preferencje" + +#: .\models.py:25 +msgid "Name" +msgstr "Nazwa" + +#: .\models.py:28 +msgid "Raw Value" +msgstr "Surowa wartość" + +#: .\models.py:42 +msgid "Verbose Name" +msgstr "Nazwa Szczegółowa" + +#: .\models.py:47 +msgid "Help Text" +msgstr "Tekst pomocy" + +#: .\models.py:84 +msgid "Global preference" +msgstr "Globalna preferencja" + +#: .\models.py:85 +msgid "Global preferences" +msgstr "Globalne Preferencje" + +#: .\templates\dynamic_preferences\form.html:11 +msgid "Submit" +msgstr "Wyślij" + +#: .\users\apps.py:11 +msgid "Preferences - Users" +msgstr "Preferencje - Użytkownicy" + +#: .\users\models.py:15 +msgid "user preference" +msgstr "preferencja użytkownika" + +#: .\users\models.py:16 +msgid "user preferences" +msgstr "preferencje użytkownika" diff --git a/dynamic_preferences/locale/ru/LC_MESSAGES/django.po b/dynamic_preferences/locale/ru/LC_MESSAGES/django.po new file mode 100644 index 0000000..c1fa761 --- /dev/null +++ b/dynamic_preferences/locale/ru/LC_MESSAGES/django.po @@ -0,0 +1,80 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-04-04 06:07+0330\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || " +"(n%100>=11 && n%100<=14)? 2 : 3);\n" +#: dynamic_preferences/admin.py:52 +msgid "Verbose name" +msgstr "" + +#: dynamic_preferences/admin.py:56 +msgid "Help text" +msgstr "" + +#: dynamic_preferences/admin.py:77 +msgid "Default Value" +msgstr "" + +#: dynamic_preferences/admin.py:86 dynamic_preferences/models.py:30 +msgid "Section Name" +msgstr "" + +#: dynamic_preferences/apps.py:10 +msgid "Settings" +msgstr "" + +#: dynamic_preferences/models.py:34 +msgid "Name" +msgstr "" + +#: dynamic_preferences/models.py:37 +msgid "Raw Value" +msgstr "" + +#: dynamic_preferences/models.py:51 +msgid "Verbose Name" +msgstr "" + +#: dynamic_preferences/models.py:57 +msgid "Help Text" +msgstr "" + +#: dynamic_preferences/models.py:94 +msgid "Global preference" +msgstr "" + +#: dynamic_preferences/models.py:95 +msgid "Global preferences" +msgstr "" + +#: dynamic_preferences/templates/dynamic_preferences/form.html:11 +msgid "Submit" +msgstr "" + +#: dynamic_preferences/users/apps.py:11 +msgid "Preferences - Users" +msgstr "" + +#: dynamic_preferences/users/models.py:14 +msgid "user preference" +msgstr "" + +#: dynamic_preferences/users/models.py:15 +msgid "user preferences" +msgstr "" diff --git a/dynamic_preferences/management/__init__.py b/dynamic_preferences/management/__init__.py new file mode 100644 index 0000000..bcbc5d6 --- /dev/null +++ b/dynamic_preferences/management/__init__.py @@ -0,0 +1 @@ +__author__ = "agateblue" diff --git a/dynamic_preferences/management/commands/__init__.py b/dynamic_preferences/management/commands/__init__.py new file mode 100644 index 0000000..bcbc5d6 --- /dev/null +++ b/dynamic_preferences/management/commands/__init__.py @@ -0,0 +1 @@ +__author__ = "agateblue" diff --git a/dynamic_preferences/management/commands/checkpreferences.py b/dynamic_preferences/management/commands/checkpreferences.py new file mode 100644 index 0000000..65ac6da --- /dev/null +++ b/dynamic_preferences/management/commands/checkpreferences.py @@ -0,0 +1,76 @@ +from django.core.management.base import BaseCommand +from dynamic_preferences.exceptions import NotFoundInRegistry +from dynamic_preferences.models import GlobalPreferenceModel +from dynamic_preferences.registries import ( + global_preferences_registry, + preference_models, +) +from dynamic_preferences.settings import preferences_settings + + +def delete_preferences(queryset): + """ + Delete preferences objects if they are not present in registry. + Return a list of deleted objects + """ + deleted = [] + + # Iterate through preferences. If an error is raised when accessing + # preference object, just delete it + for p in queryset: + try: + p.registry.get(section=p.section, name=p.name, fallback=False) + except NotFoundInRegistry: + p.delete() + deleted.append(p) + + return deleted + + +class Command(BaseCommand): + help = ( + "Find and delete preferences from database if they don't exist in " + "registries. Create preferences that are not present in database" + "(except when invoked with --skip_create)." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--skip_create", + action="store_true", + help="Forces to skip the creation step for missing preferences", + ) + + def handle(self, *args, **options): + skip_create = options["skip_create"] + + # Create needed preferences + # Global + if not skip_create: + self.stdout.write("Creating missing global preferences...") + manager = global_preferences_registry.manager() + manager.all() + + deleted = delete_preferences(GlobalPreferenceModel.objects.all()) + message = "Deleted {deleted} global preferences".format(deleted=len(deleted)) + self.stdout.write(message) + + for preference_model, registry in preference_models.items(): + deleted = delete_preferences(preference_model.objects.all()) + message = "Deleted {deleted} {model} preferences".format( + deleted=len(deleted), + model=preference_model.__name__, + ) + self.stdout.write(message) + if not hasattr(preference_model, "get_instance_model"): + continue + + if skip_create: + continue + + message = "Creating missing preferences for {model} model...".format( + model=preference_model.get_instance_model().__name__, + ) + self.stdout.write(message) + for instance in preference_model.get_instance_model().objects.all(): + getattr(instance, preferences_settings.MANAGER_ATTRIBUTE).all() diff --git a/dynamic_preferences/managers.py b/dynamic_preferences/managers.py new file mode 100644 index 0000000..7ab673c --- /dev/null +++ b/dynamic_preferences/managers.py @@ -0,0 +1,239 @@ +try: + from collections.abc import Mapping +except ImportError: + from collections import Mapping + +from .settings import preferences_settings +from .exceptions import CachedValueNotFound, DoesNotExist +from .signals import preference_updated + + +class PreferencesManager(Mapping): + + """Handle retrieving / caching of preferences""" + + def __init__(self, model, registry, **kwargs): + self.model = model + self.registry = registry + self.instance = kwargs.get("instance") + + @property + def queryset(self): + qs = self.model.objects.all() + if self.instance: + qs = qs.filter(instance=self.instance) + return qs + + @property + def cache(self): + from django.core.cache import caches + + return caches[preferences_settings.CACHE_NAME] + + def __getitem__(self, key): + return self.get(key) + + def __setitem__(self, key, value): + section, name = self.parse_lookup(key) + preference = self.registry.get(section=section, name=name, fallback=False) + preference.validate(value) + self.update_db_pref(section=section, name=name, value=value) + + def __repr__(self): + return repr(self.all()) + + def __iter__(self): + return self.all().__iter__() + + def __len__(self): + return len(self.all()) + + def by_name(self): + """Return a dictionary with preferences identifiers and values, but without the section name in the identifier""" + return { + key.split(preferences_settings.SECTION_KEY_SEPARATOR)[-1]: value + for key, value in self.all().items() + } + + def get_by_name(self, name): + return self.get(self.registry.get_by_name(name).identifier()) + + def get_cache_key(self, section, name): + """Return the cache key corresponding to a given preference""" + if not self.instance: + return "dynamic_preferences_{0}_{1}_{2}".format( + self.model.__name__, section, name + ) + return "dynamic_preferences_{0}_{1}_{2}_{3}".format( + self.model.__name__, self.instance.pk, section, name, self.instance.pk + ) + + def from_cache(self, section, name): + """Return a preference raw_value from cache""" + cached_value = self.cache.get( + self.get_cache_key(section, name), CachedValueNotFound + ) + + if cached_value is CachedValueNotFound: + raise CachedValueNotFound + + if cached_value == preferences_settings.CACHE_NONE_VALUE: + cached_value = None + return self.registry.get(section=section, name=name).serializer.deserialize( + cached_value + ) + + def many_from_cache(self, preferences): + """ + Return cached value for given preferences + missing preferences will be skipped + """ + keys = {p: self.get_cache_key(p.section.name, p.name) for p in preferences} + cached = self.cache.get_many(list(keys.values())) + + for k, v in cached.items(): + # we replace dummy cached values by None here, if needed + if v == preferences_settings.CACHE_NONE_VALUE: + cached[k] = None + + # we have to remap returned value since the underlying cached keys + # are not usable for an end user + return { + p.identifier(): p.serializer.deserialize(cached[k]) + for p, k in keys.items() + if k in cached + } + + def to_cache(self, pref): + """ + Update/create the cache value for the given preference model instance + """ + key = self.get_cache_key(pref.section, pref.name) + value = pref.raw_value + if value is None or value == "": + # some cache backends refuse to cache None or empty values + # resulting in more DB queries, so we cache an arbitrary value + # to ensure the cache is hot (even with empty values) + value = preferences_settings.CACHE_NONE_VALUE + self.cache.set(key, value) + + def pref_obj(self, section, name): + return self.registry.get(section=section, name=name) + + def parse_lookup(self, lookup): + try: + section, name = lookup.split(preferences_settings.SECTION_KEY_SEPARATOR) + except ValueError: + name = lookup + section = None + return section, name + + def get(self, key, no_cache=False): + """Return the value of a single preference using a dotted path key + :arg no_cache: if true, the cache is bypassed + """ + section, name = self.parse_lookup(key) + preference = self.registry.get(section=section, name=name, fallback=False) + if no_cache or not preferences_settings.ENABLE_CACHE: + return self.get_db_pref(section=section, name=name).value + + try: + return self.from_cache(section, name) + except CachedValueNotFound: + pass + + db_pref = self.get_db_pref(section=section, name=name) + self.to_cache(db_pref) + return db_pref.value + + def get_db_pref(self, section, name): + try: + pref = self.queryset.get(section=section, name=name) + except self.model.DoesNotExist: + pref_obj = self.pref_obj(section=section, name=name) + pref = self.create_db_pref( + section=section, name=name, value=pref_obj.get("default") + ) + + return pref + + def update_db_pref(self, section, name, value): + try: + db_pref = self.queryset.get(section=section, name=name) + old_value = db_pref.value + db_pref.value = value + db_pref.save() + preference_updated.send( + sender=self.__class__, + section=section, + name=name, + old_value=old_value, + new_value=value, + ) + except self.model.DoesNotExist: + return self.create_db_pref(section, name, value) + + return db_pref + + def create_db_pref(self, section, name, value): + kwargs = { + "section": section, + "name": name, + } + if self.instance: + kwargs["instance"] = self.instance + + # this is a just a shortcut to get the raw, serialized value + # so we can pass it to get_or_create + m = self.model(**kwargs) + m.value = value + raw_value = m.raw_value + + db_pref, created = self.model.objects.get_or_create(**kwargs) + if created and db_pref.raw_value != raw_value: + db_pref.raw_value = raw_value + db_pref.save() + + return db_pref + + def all(self): + """Return a dictionary containing all preferences by section + Loaded from cache or from db in case of cold cache + """ + if not preferences_settings.ENABLE_CACHE: + return self.load_from_db() + + preferences = self.registry.preferences() + + # first we hit the cache once for all existing preferences + a = self.many_from_cache(preferences) + if len(a) == len(preferences): + return a # avoid database hit if not necessary + + # then we fill those that miss, but exist in the database + # (just hit the database for all of them, filtering is complicated, and + # in most cases you'd need to grab the majority of them anyway) + a.update(self.load_from_db(cache=True)) + return a + + def load_from_db(self, cache=False): + """Return a dictionary of preferences by section directly from DB""" + a = {} + db_prefs = {p.preference.identifier(): p for p in self.queryset} + for preference in self.registry.preferences(): + try: + db_pref = db_prefs[preference.identifier()] + except KeyError: + db_pref = self.create_db_pref( + section=preference.section.name, + name=preference.name, + value=preference.get("default"), + ) + else: + # cache if create_db_pref() hasn't already done so + if cache: + self.to_cache(db_pref) + + a[preference.identifier()] = db_pref.value + + return a diff --git a/dynamic_preferences/migrations/0001_initial.py b/dynamic_preferences/migrations/0001_initial.py new file mode 100644 index 0000000..77ddefe --- /dev/null +++ b/dynamic_preferences/migrations/0001_initial.py @@ -0,0 +1,28 @@ +# Generated by Django 5.1.8 on 2025-04-03 00:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='GlobalPreferenceModel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('section', models.CharField(blank=True, db_index=True, default=None, max_length=150, null=True, verbose_name='Section Name')), + ('name', models.CharField(db_index=True, max_length=150, verbose_name='Name')), + ('raw_value', models.TextField(blank=True, null=True, verbose_name='Raw Value')), + ], + options={ + 'verbose_name': 'Global preference', + 'verbose_name_plural': 'Global preferences', + 'unique_together': {('section', 'name')}, + }, + ), + ] diff --git a/dynamic_preferences/migrations/__init__.py b/dynamic_preferences/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dynamic_preferences/models.py b/dynamic_preferences/models.py new file mode 100644 index 0000000..f6e6482 --- /dev/null +++ b/dynamic_preferences/models.py @@ -0,0 +1,135 @@ +""" +Preference models, queryset and managers that handle the logic for persisting preferences. +""" + +from django.db import models +from django.db.models.query import QuerySet +from django.conf import settings +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ +from dynamic_preferences.registries import ( + preference_models, + global_preferences_registry, +) +from .utils import update + + +class BasePreferenceModel(models.Model): + + """ + A base model with common logic for all preferences models. + """ + + #: The section under which the preference is declared + section = models.CharField( + max_length=150, + db_index=True, + blank=True, + null=True, + default=None, + verbose_name=_("Section Name"), + ) + + #: a name for the preference + name = models.CharField(_("Name"), max_length=150, db_index=True) + + #: a value, serialized to a string. This field should not be accessed directly, use :py:attr:`BasePreferenceModel.value` instead + raw_value = models.TextField(_("Raw Value"), null=True, blank=True) + + class Meta: + abstract = True + app_label = "dynamic_preferences" + + @cached_property + def preference(self): + return self.registry.get(section=self.section, name=self.name, fallback=True) + + @property + def verbose_name(self): + return self.preference.get("verbose_name", self.preference.identifier) + + verbose_name.fget.short_description = _("Verbose Name") + + @property + def help_text(self): + return self.preference.get("help_text", "") + + help_text.fget.short_description = _("Help Text") + + def set_value(self, value): + """ + Save serialized self.value to self.raw_value + """ + self.raw_value = self.preference.serializer.serialize(value) + + def get_value(self): + """ + Return deserialized self.raw_value + """ + return self.preference.serializer.deserialize(self.raw_value) + + value = property(get_value, set_value) + + def save(self, **kwargs): + + if self.pk is None and not self.raw_value: + self.value = self.preference.get("default") + super(BasePreferenceModel, self).save(**kwargs) + + def __str__(self): + return self.__repr__() + + def __repr__(self): + return "{0} - {1}/{2}".format(self.__class__.__name__, self.section, self.name) + + +class GlobalPreferenceModel(BasePreferenceModel): + + registry = global_preferences_registry + + class Meta: + unique_together = ("section", "name") + app_label = "dynamic_preferences" + + verbose_name = _("Global preference") + verbose_name_plural = _("Global preferences") + + +class PerInstancePreferenceModel(BasePreferenceModel): + + """For preferences that are tied to a specific model instance""" + + #: the instance which is concerned by the preference + #: use a ForeignKey pointing to the model of your choice + instance = None + + class Meta(BasePreferenceModel.Meta): + unique_together = ("instance", "section", "name") + abstract = True + + @classmethod + def get_instance_model(cls): + return cls._meta.get_field("instance").remote_field.model + + +global_preferences_registry.preference_model = GlobalPreferenceModel + +# Create default preferences for new instances + +from django.db.models.signals import post_save + + +def invalidate_cache(sender, created, instance, **kwargs): + if not isinstance(instance, BasePreferenceModel): + return + registry = preference_models.get_by_preference(instance) + linked_instance = getattr(instance, "instance", None) + kwargs = {} + if linked_instance: + kwargs["instance"] = linked_instance + + manager = registry.manager(**kwargs) + manager.to_cache(instance) + + +post_save.connect(invalidate_cache) diff --git a/dynamic_preferences/preferences.py b/dynamic_preferences/preferences.py new file mode 100644 index 0000000..35f453f --- /dev/null +++ b/dynamic_preferences/preferences.py @@ -0,0 +1,104 @@ +""" +Preferences are regular Python objects that can be declared within any django app. +Once declared and registered, they can be edited by admins (for :py:class:`SitePreference` and :py:class:`GlobalPreference`) +and regular Users (for :py:class:`UserPreference`) + +UserPreference, SitePreference and GlobalPreference are mapped to corresponding PreferenceModel, +which store the actual values. + +""" +from __future__ import unicode_literals +import re +import warnings + +from .settings import preferences_settings +from .exceptions import MissingDefault +from .serializers import UNSET + + +class InvalidNameError(ValueError): + pass + + +def check_name(name, obj): + error = None + if not re.match("^\w+$", name): + error = "Non-alphanumeric / underscore characters are forbidden in section and preferences names" + if preferences_settings.SECTION_KEY_SEPARATOR in name: + error = 'Sequence "{0}" is forbidden in section and preferences name, since it is used to access values via managers'.format( + preferences_settings.SECTION_KEY_SEPARATOR + ) + + if error: + full_message = 'Invalid name "{0}" while instanciating {1} object: {2}'.format( + name, obj, error + ) + raise InvalidNameError(full_message) + + +class Section(object): + def __init__(self, name, verbose_name=None): + self.name = name + self.verbose_name = verbose_name or name + if preferences_settings.VALIDATE_NAMES and name: + check_name(self.name, self) + + def __str__(self): + if not self.verbose_name: + return "" + return str(self.verbose_name) + + +EMPTY_SECTION = Section(None) + + +class AbstractPreference(object): + """ + A base class that handle common logic for preferences + """ + + #: The section under which the preference will be registered + section = EMPTY_SECTION + + #: The preference name + name = "" + + #: A default value for the preference + default = UNSET + + def __init__(self, registry=None): + if preferences_settings.VALIDATE_NAMES: + check_name(self.name, self) + if self.section and not hasattr(self.section, "name"): + self.section = Section(name=self.section) + warnings.warn( + "Implicit section instanciation is deprecated and " + "will be removed in future versions of django-dynamic-preferences", + DeprecationWarning, + stacklevel=2, + ) + + self.registry = registry + if self.default == UNSET and not getattr(self, "get_default", None): + raise MissingDefault + + def get(self, attr, default=None): + getter = "get_{0}".format(attr) + if hasattr(self, getter): + return getattr(self, getter)() + return getattr(self, attr, default) + + @property + def model(self): + return self.registry.preference_model + + def identifier(self): + """ + Return the name and the section of the Preference joined with a separator, with the form `sectionname` + """ + + if not self.section or not self.section.name: + return self.name + return preferences_settings.SECTION_KEY_SEPARATOR.join( + [self.section.name, self.name] + ) diff --git a/dynamic_preferences/processors.py b/dynamic_preferences/processors.py new file mode 100644 index 0000000..668365d --- /dev/null +++ b/dynamic_preferences/processors.py @@ -0,0 +1,10 @@ +from .registries import global_preferences_registry as gpr + + +def global_preferences(request): + """ + Pass the values of global preferences to template context. + You can then access value with `global_preferences.
.` + """ + manager = gpr.manager() + return {"global_preferences": manager.all()} diff --git a/dynamic_preferences/registries.py b/dynamic_preferences/registries.py new file mode 100644 index 0000000..f394b21 --- /dev/null +++ b/dynamic_preferences/registries.py @@ -0,0 +1,234 @@ +from django.core.exceptions import FieldDoesNotExist +from django.apps import apps + +# import the logging library +import warnings +import logging +import collections +import persisting_theory + +# Get an instance of a logger +logger = logging.getLogger(__name__) + + +#: The package where autodiscover will try to find preferences to register + +from .managers import PreferencesManager +from .settings import preferences_settings +from .exceptions import NotFoundInRegistry +from .types import StringPreference +from .preferences import EMPTY_SECTION, Section + + +class MissingPreference(StringPreference): + """ + Used as a fallback when the preference object is not found in registries + This can happen for example when you delete a preference in the code, + but don't remove the corresponding entries in database + """ + + pass + + +class PreferenceModelsRegistry(persisting_theory.Registry): + """Store relationships beetween preferences model and preferences registry""" + + look_into = preferences_settings.REGISTRY_MODULE + + def register(self, preference_model, preference_registry): + self[preference_model] = preference_registry + preference_registry.preference_model = preference_model + if not hasattr(preference_model, "registry"): + setattr(preference_model, "registry", preference_registry) + self.attach_manager(preference_model, preference_registry) + + def attach_manager(self, model, registry): + if not hasattr(model, "instance"): + return + + def instance_getter(self): + return registry.manager(instance=self) + + getter = property(instance_getter) + instance_class = model._meta.get_field("instance").remote_field.model + setattr(instance_class, preferences_settings.MANAGER_ATTRIBUTE, getter) + + def get_by_preference(self, preference): + return self[ + preference._meta.proxy_for_model + if preference._meta.proxy + else preference.__class__ + ] + + def get_by_instance(self, instance): + """Return a preference registry using a model instance""" + # we iterate through registered preference models in order to get the instance class + # and check if instance is an instance of this class + for model, registry in self.items(): + try: + instance_class = model._meta.get_field("instance").remote_field.model + if isinstance(instance, instance_class): + return registry + + except FieldDoesNotExist: # global preferences + pass + return None + + +preference_models = PreferenceModelsRegistry() + + +class PreferenceRegistry(persisting_theory.Registry): + + """ + Registries are special dictionaries that are used by dynamic-preferences to register and access your preferences. + dynamic-preferences has one registry per Preference type: + + - :py:const:`user_preferences` + - :py:const:`site_preferences` + - :py:const:`global_preferences` + + In order to register preferences automatically, you must call :py:func:`autodiscover` in your URLconf. + + """ + + look_into = preferences_settings.REGISTRY_MODULE + + #: a name to identify the registry + name = "preferences_registry" + preference_model = None + + #: used to reverse urls for sections in form views/templates + section_url_namespace = None + + def __init__(self, *args, **kwargs): + super(PreferenceRegistry, self).__init__(*args, **kwargs) + self.section_objects = collections.OrderedDict() + + def register(self, preference_class): + """ + Store the given preference class in the registry. + + :param preference_class: a :py:class:`prefs.Preference` subclass + """ + preference = preference_class(registry=self) + self.section_objects[preference.section.name] = preference.section + + try: + self[preference.section.name][preference.name] = preference + + except KeyError: + self[preference.section.name] = collections.OrderedDict() + self[preference.section.name][preference.name] = preference + + return preference_class + + def _fallback(self, section_name, pref_name): + """ + Create a fallback preference object, + This is used when you have model instances that do not match + any registered preferences, see #41 + """ + message = ( + "Creating a fallback preference with " + + 'section "{}" and name "{}".' + + "This means you have preferences in your database that " + + "don't match any registered preference. " + + "If you want to delete these entries, please refer to the " + + "documentation: https://django-dynamic-preferences.readthedocs.io/en/latest/lifecycle.html" + ) # NOQA + warnings.warn(message.format(section_name, pref_name)) + + class Fallback(MissingPreference): + section = Section(name=section_name) if section_name else None + name = pref_name + default = "" + help_text = "Obsolete: missing in registry" + + return Fallback() + + def get(self, name, section=None, fallback=False): + """ + Returns a previously registered preference + + :param section: The section name under which the preference is registered + :type section: str. + :param name: The name of the preference. You can use dotted notation 'section.name' if you want to avoid providing section param + :type name: str. + :param fallback: Should we return a dummy preference object instead of raising an error if no preference is found? + :type name: bool. + :return: a :py:class:`prefs.BasePreference` instance + """ + # try dotted notation + try: + _section, name = name.split(preferences_settings.SECTION_KEY_SEPARATOR) + return self[_section][name] + + except ValueError: + pass + + # use standard params + try: + return self[section][name] + + except KeyError: + if fallback: + return self._fallback(section_name=section, pref_name=name) + raise NotFoundInRegistry( + "No such preference in {0} with section={1} and name={2}".format( + self.__class__.__name__, section, name + ) + ) + + def get_by_name(self, name): + """Get a preference by name only (no section)""" + for section in self.values(): + for preference in section.values(): + if preference.name == name: + return preference + raise NotFoundInRegistry( + "No such preference in {0} with name={1}".format( + self.__class__.__name__, name + ) + ) + + def manager(self, **kwargs): + """Return a preference manager that can be used to retrieve preference values""" + return PreferencesManager(registry=self, model=self.preference_model, **kwargs) + + def sections(self): + """ + :return: a list of apps with registered preferences + :rtype: list + """ + + return self.keys() + + def preferences(self, section=None): + """ + Return a list of all registered preferences + or a list of preferences registered for a given section + + :param section: The section name under which the preference is registered + :type section: str. + :return: a list of :py:class:`prefs.BasePreference` instances + """ + + if section is None: + return [self[section][name] for section in self for name in self[section]] + else: + return [self[section][name] for name in self[section]] + + +class PerInstancePreferenceRegistry(PreferenceRegistry): + pass + + +class GlobalPreferenceRegistry(PreferenceRegistry): + section_url_namespace = "dynamic_preferences:global.section" + + def populate(self, **kwargs): + return self.models(**kwargs) + + +global_preferences_registry = GlobalPreferenceRegistry() diff --git a/dynamic_preferences/serializers.py b/dynamic_preferences/serializers.py new file mode 100644 index 0000000..bf57339 --- /dev/null +++ b/dynamic_preferences/serializers.py @@ -0,0 +1,504 @@ +from __future__ import unicode_literals +import decimal +import os + +from datetime import date, timedelta, datetime, time + +from django.conf import settings +from django.core.validators import EMPTY_VALUES +from django.utils.dateparse import ( + parse_duration, + parse_datetime, + parse_date, + parse_time, +) +from django.utils.duration import duration_string +from django.utils.encoding import force_str +from datetime import timezone + +from django.utils.timezone import ( + # utc, + is_aware, + make_aware, + make_naive, + get_default_timezone, +) +from six import string_types, text_type +from django.db.models.fields.files import FieldFile + +from rest_framework import serializers + +class AboutUsSerializer(serializers.Serializer): + content = serializers.CharField(allow_blank=True) + +class FAQItemSerializer(serializers.Serializer): + question = serializers.CharField() + answer = serializers.CharField() + +class SupportSerializer(serializers.Serializer): + telegram_number = serializers.CharField() + whatsapp_number = serializers.CharField() + +class CardSerializer(serializers.Serializer): + card_number = serializers.CharField() + card_name = serializers.CharField() + whatsapp_number = serializers.CharField() + + +class UnsetValue(object): + pass + + +UNSET = UnsetValue() + + +class SerializationError(Exception): + pass + + +class BaseSerializer: + """ + A serializer take a Python variable and returns a string that can be stored safely in database + """ + + exception = SerializationError + + @classmethod + def serialize(cls, value, **kwargs): + """ + Return a string from a Python var + """ + return cls.to_db(value, **kwargs) + + @classmethod + def deserialize(cls, value, **kwargs): + """ + Convert a python string to a var + """ + return cls.to_python(value, **kwargs) + + @classmethod + def to_python(cls, value, **kwargs): + raise NotImplementedError + + @classmethod + def to_db(cls, value, **kwargs): + return text_type(cls.clean_to_db_value(value)) + + @classmethod + def clean_to_db_value(cls, value): + return value + + +class InstanciatedSerializer(BaseSerializer): + """ + In some situations, such as with FileSerializer, + we need the serializer to be an instance and not a class + """ + + def serialize(self, value, **kwargs): + return self.to_db(value, **kwargs) + + def deserialize(self, value, **kwargs): + return self.to_python(value, **kwargs) + + def to_python(self, value, **kwargs): + raise NotImplementedError + + def to_db(self, value, **kwargs): + return text_type(self.clean_to_db_value(value)) + + def clean_to_db_value(self, value): + return value + + +class BooleanSerializer(BaseSerializer): + true = ( + "True", + "true", + "TRUE", + "1", + "YES", + "Yes", + "yes", + ) + + false = ( + "False", + "false", + "FALSE", + "0", + "No", + "no", + "NO", + ) + + @classmethod + def clean_to_db_value(cls, value): + if not isinstance(value, bool): + raise cls.exception("{0} is not a boolean".format(value)) + return value + + @classmethod + def to_python(cls, value, **kwargs): + + if value in cls.true: + return True + + elif value in cls.false: + return False + + else: + raise cls.exception( + "Value {0} can't be deserialized to a Boolean".format(value) + ) + + +class IntegerSerializer(BaseSerializer): + @classmethod + def clean_to_db_value(cls, value): + if not isinstance(value, int): + raise cls.exception("IntSerializer can only serialize int values") + return value + + @classmethod + def to_python(cls, value, **kwargs): + try: + return int(value) + except: + raise cls.exception("Value {0} cannot be converted to int".format(value)) + + +IntSerializer = IntegerSerializer + + +class DecimalSerializer(BaseSerializer): + @classmethod + def clean_to_db_value(cls, value): + if not isinstance(value, decimal.Decimal): + raise cls.exception( + "DecimalSerializer can only serialize Decimal instances" + ) + return value + + @classmethod + def to_python(cls, value, **kwargs): + try: + return decimal.Decimal(value) + except decimal.InvalidOperation: + raise cls.exception( + "Value {0} cannot be converted to decimal".format(value) + ) + + +class FloatSerializer(BaseSerializer): + @classmethod + def clean_to_db_value(cls, value): + if not isinstance(value, (int, float)): + raise cls.exception( + "FloatSerializer can only serialize float or int values" + ) + return float(value) + + @classmethod + def to_python(cls, value, **kwargs): + try: + return float(value) + except float.InvalidOperation: + raise cls.exception("Value {0} cannot be converted to float".format(value)) + + +from django.template import defaultfilters + + +class StringSerializer(BaseSerializer): + @classmethod + def to_db(cls, value, **kwargs): + if not isinstance(value, string_types): + raise cls.exception( + "Cannot serialize, value {0} is not a string".format(value) + ) + + if kwargs.get("escape_html", False): + return defaultfilters.force_escape(value) + else: + return value + + @classmethod + def to_python(cls, value, **kwargs): + """String deserialisation just return the value as a string""" + if not value: + return "" + try: + return str(value) + except: + pass + try: + return value.encode("utf-8") + except: + pass + raise cls.exception("Cannot deserialize value {0} tostring".format(value)) + + +class ModelSerializer(InstanciatedSerializer): + model = None + + def __init__(self, model): + self.model = model + + def to_db(self, value, **kwargs): + if not value or (value == UNSET): + return None + return str(value.pk) + + def to_python(self, value, **kwargs): + if value is None: + return + try: + pk = int(value) + return self.model.objects.get(pk=pk) + except: + raise self.exception("Value {0} cannot be converted to pk".format(value)) + + +class ModelMultipleSerializer(ModelSerializer): + separator = "," + sort = True + + def to_db(self, value, **kwargs): + if not value: + return + if hasattr(value, "pk"): + # Support single instances in this serializer to allow + # create_deletion_handler to work for model multiple choice preferences + value = [value.pk] + else: + value = list(value.values_list("pk", flat=True)) + + if self.sort: + value = sorted(value) + + return self.separator.join(map(str, value)) + + def to_python(self, value, **kwargs): + if value in EMPTY_VALUES: + return self.model.objects.none() + + try: + pks = value.split(",") + pks = [int(i) if str(i).isdigit() else str(i) for i in pks] + return self.model.objects.filter(pk__in=pks) + except: + raise self.exception("Array {0} cannot be converted to int".format(value)) + + +class PreferenceFieldFile(FieldFile): + """ + In order to have the same API that we have with models.FileField, + we must return a FieldFile object. However, there are various + things we have to override, since our files are not bound to a model + field. + """ + + def __init__(self, preference, storage, name): + super(FieldFile, self).__init__(None, name) + + # FieldFile also needs a model instance to save changes. + class FakeInstance(object): + """ + FieldFile needs a model instance to update when file is persisted + or deleted + """ + + def save(self): + return + + self.instance = FakeInstance() + + class FakeField(object): + """ + FieldFile needs a field object to generate a filename, persist + and delete files, so we are effectively mocking that. + """ + + name = "noop" + attname = "noop" + max_length = 10000 + + def generate_filename(field, instance, name): + return os.path.join(self.preference.get_upload_path(), f.name) + + self.field = FakeField() + self.storage = storage + self._committed = True + self.preference = preference + + +class FileSerializer(InstanciatedSerializer): + """ + Since this serializer requires additional data from the preference + especially the upload path, we cannot do it without binding it + to the preference + + it is therefore designed to be explicitely instanciated by the preference + object. + """ + + def __init__(self, preference): + self.preference = preference + + def to_db(self, f, **kwargs): + if not f: + return + saved_path = f.name + if not hasattr(f, "save"): + path = os.path.join(self.preference.get_upload_path(), f.name) + saved_path = self.preference.get_file_storage().save(path, f) + + return saved_path + + def to_python(self, value, **kwargs): + if not value: + return + storage = self.preference.get_file_storage() + + return PreferenceFieldFile( + preference=self.preference, storage=storage, name=value + ) + + +class DurationSerializer(BaseSerializer): + @classmethod + def to_db(cls, value, **kwargs): + if not isinstance(value, timedelta): + raise cls.exception( + "Cannot serialize, value {0} is not a timedelta".format(value) + ) + + return duration_string(value) + + @classmethod + def to_python(cls, value, **kwargs): + parsed = parse_duration(force_str(value)) + if parsed is None: + raise cls.exception( + "Value {0} cannot be converted to timedelta".format(value) + ) + return parsed + + +class DateSerializer(BaseSerializer): + @classmethod + def to_db(cls, value, **kwargs): + if not isinstance(value, date): + raise cls.exception( + "Cannot serialize, value {0} is not a date object".format(value) + ) + + return value.isoformat() + + @classmethod + def to_python(cls, value, **kwargs): + parsed = parse_date(force_str(value)) + if parsed is None: + raise cls.exception( + "Value {0} cannot be converted to a date object".format(value) + ) + + return parsed + + +class DateTimeSerializer(BaseSerializer): + @classmethod + def to_db(cls, value, **kwargs): + if not isinstance(value, datetime): + raise cls.exception( + "Cannot serialize, value {0} is not a datetime object".format(value) + ) + + value = cls.enforce_timezone(value) + + return value.isoformat() + + @classmethod + def enforce_timezone(cls, value): + """ + When `self.default_timezone` is `None`, always return naive datetimes. + When `self.default_timezone` is not `None`, always return aware datetimes. + """ + field_timezone = cls.default_timezone() + + if (field_timezone is not None) and not is_aware(value): + return make_aware(value, field_timezone) + elif (field_timezone is None) and is_aware(value): + return make_naive(value, timezone.utc) + return value + + @classmethod + def default_timezone(cls): + return get_default_timezone() if settings.USE_TZ else None + + @classmethod + def to_python(cls, value, **kwargs): + parsed = parse_datetime(force_str(value)) + if parsed is None: + raise cls.exception( + "Value {0} cannot be converted to a datetime object".format(value) + ) + return parsed + + +class TimeSerializer(BaseSerializer): + @classmethod + def to_db(cls, value, **kwargs): + if not isinstance(value, time): + raise cls.exception( + "Cannot serialize, value {0} is not a time object".format(value) + ) + + return value.isoformat() + + @classmethod + def to_python(cls, value, **kwargs): + parsed = parse_time(force_str(value)) + if parsed is None: + raise cls.exception( + "Value {0} cannot be converted to a time object".format(value) + ) + + return parsed + + +class MultipleSerializer(BaseSerializer): + separator = "," + sort = True + + @classmethod + def to_db(cls, value, **kwargs): + if not value: + return + + # This makes the use of the separator in choices safe by duplicating + # it in each value before they are joined later on + # Contract: choices keys cannot be empty + value = [str(v).replace(cls.separator, cls.separator * 2) for v in value] + if "" in value: + raise cls.exception("Choices must not be empty") + + if cls.sort: + value = sorted(value) + + return cls.separator.join(value) + + @classmethod + def to_python(cls, value, **kwargs): + if value in EMPTY_VALUES: + return [] + + ret = value.split(cls.separator) + # Duplication of separator is reverted (cf. to_db) + while "" in ret: + pos = ret.index("") + val = ret[pos - 1] + cls.separator + ret[pos + 1] + ret = ret[0 : pos - 1] + [val] + ret[pos + 2 :] + return ret diff --git a/dynamic_preferences/settings.py b/dynamic_preferences/settings.py new file mode 100644 index 0000000..20b9688 --- /dev/null +++ b/dynamic_preferences/settings.py @@ -0,0 +1,70 @@ +# Taken from django-rest-framework +# https://github.com/tomchristie/django-rest-framework +# Copyright (c) 2011-2015, Tom Christie All rights reserved. + +from django.conf import settings + +SETTINGS_ATTR = "DYNAMIC_PREFERENCES" +USER_SETTINGS = None + + +DEFAULTS = { + # 'REGISTRY_MODULE': 'prefs', + # 'BASE_PREFIX': 'base', + # 'SECTIONS_PREFIX': 'sections', + # 'PREFERENCES_PREFIX': 'preferences', + # 'PERMISSIONS_PREFIX': 'permissions', + "MANAGER_ATTRIBUTE": "preferences", + "SECTION_KEY_SEPARATOR": "__", + "REGISTRY_MODULE": "dynamic_preferences_registry", + "ADMIN_ENABLE_CHANGELIST_FORM": False, + "ENABLE_GLOBAL_MODEL_AUTO_REGISTRATION": True, + "ENABLE_USER_PREFERENCES": True, + "ENABLE_CACHE": True, + "CACHE_NAME": "default", + "VALIDATE_NAMES": True, + "FILE_PREFERENCE_UPLOAD_DIR": "dynamic_preferences", + # this will be used to cache empty values, since some cache backends + # does not support it on get_many + "CACHE_NONE_VALUE": "__dynamic_preferences_empty_value", +} + + +class PreferenceSettings(object): + """ + A settings object, that allows API settings to be accessed as properties. + For example: + + from rest_framework.settings import api_settings + print(api_settings.DEFAULT_RENDERER_CLASSES) + + Any setting with string import paths will be automatically resolved + and return the class, rather than the string literal. + """ + + def __init__(self, defaults=None): + self.defaults = defaults or DEFAULTS + + @property + def user_settings(self): + return getattr(settings, SETTINGS_ATTR, {}) + + def __getattr__(self, attr): + if attr not in self.defaults.keys(): + raise AttributeError("Invalid preference setting: '%s'" % attr) + + try: + # Check if present in user settings + val = self.user_settings[attr] + except KeyError: + # Fall back to defaults + val = self.defaults[attr] + + # Cache the result + # We sometimes need to bypass that, like in tests + if getattr(settings, "CACHE_DYNAMIC_PREFERENCES_SETTINGS", True): + setattr(self, attr, val) + return val + + +preferences_settings = PreferenceSettings(DEFAULTS) diff --git a/dynamic_preferences/signals.py b/dynamic_preferences/signals.py new file mode 100644 index 0000000..fbec558 --- /dev/null +++ b/dynamic_preferences/signals.py @@ -0,0 +1,4 @@ +from django.dispatch import Signal + +# Arguments provided to listeners: "section", "name", "old_value" and "new_value" +preference_updated = Signal() diff --git a/dynamic_preferences/templates/dynamic_preferences/base.html b/dynamic_preferences/templates/dynamic_preferences/base.html new file mode 100644 index 0000000..9e9af57 --- /dev/null +++ b/dynamic_preferences/templates/dynamic_preferences/base.html @@ -0,0 +1,15 @@ + + + + + + + +
+ {% block content %}{% endblock %} +
+ + diff --git a/dynamic_preferences/templates/dynamic_preferences/dyna_change_form.html b/dynamic_preferences/templates/dynamic_preferences/dyna_change_form.html new file mode 100644 index 0000000..2068afa --- /dev/null +++ b/dynamic_preferences/templates/dynamic_preferences/dyna_change_form.html @@ -0,0 +1,13 @@ +{% extends "admin/change_form.html" %} + +{% block scripts %} + {{ super.block }} + + + +{% endblock %} diff --git a/dynamic_preferences/templates/dynamic_preferences/form.html b/dynamic_preferences/templates/dynamic_preferences/form.html new file mode 100644 index 0000000..d0b6480 --- /dev/null +++ b/dynamic_preferences/templates/dynamic_preferences/form.html @@ -0,0 +1,13 @@ +{% extends "dynamic_preferences/base.html" %} +{% load i18n %} +{% block content %} + + {# we continue to pass the sections key in case someone subclassed the template and use these #} + {% include "dynamic_preferences/sections.html" with registry=registry sections=registry.sections %} + +
+ {% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} diff --git a/dynamic_preferences/templates/dynamic_preferences/sections.html b/dynamic_preferences/templates/dynamic_preferences/sections.html new file mode 100644 index 0000000..8a408b9 --- /dev/null +++ b/dynamic_preferences/templates/dynamic_preferences/sections.html @@ -0,0 +1,8 @@ + diff --git a/dynamic_preferences/templates/dynamic_preferences/testcontext.html b/dynamic_preferences/templates/dynamic_preferences/testcontext.html new file mode 100644 index 0000000..e69de29 diff --git a/dynamic_preferences/types.py b/dynamic_preferences/types.py new file mode 100644 index 0000000..babb02a --- /dev/null +++ b/dynamic_preferences/types.py @@ -0,0 +1,525 @@ +""" +You'll find here the final, concrete classes of preferences you can use +in your own project. + +""" +from django import forms +from django.db.models.signals import pre_delete + +from django.core.files.storage import default_storage + +from .preferences import AbstractPreference, Section +from .exceptions import MissingModel +from dynamic_preferences.serializers import * +from dynamic_preferences.settings import preferences_settings + + +class BasePreferenceType(AbstractPreference): + """ + Used as a base for all other preference classes. You should subclass + this one if you want to implement your own preference. + """ + + field_class = None + """ + A form field that will be used to display and edit the preference + use a class, not an instance. + + :Example: + + .. code-block:: python + + from django import forms + + class MyPreferenceType(BasePreferenceType): + field_class = forms.CharField + """ + + #: A serializer class (see dynamic_preferences.serializers) + serializer = None + + field_kwargs = {} + """ + Additional kwargs to be passed to the form field. + + :Example: + + .. code-block:: python + + class MyPreference(StringPreference): + + field_kwargs = { + 'required': False, + 'initial': 'Hello there' + } + """ + + @property + def initial(self): + return self.get_initial() + + def get_initial(self): + """ + :return: + initial data for form field + from field_attribute['initial'] or default + """ + return self.field_kwargs.get("initial", self.get("default")) + + @property + def field(self): + """ + :return: + an instance of a form field for this preference, with + the correct configuration (widget, initial value, validators...) + """ + return self.setup_field() + + def setup_field(self, **kwargs): + field_class = self.get("field_class") + field_kwargs = self.get_field_kwargs() + field_kwargs.update(kwargs) + return field_class(**field_kwargs) + + def get_field_kwargs(self): + """ + Return a dict of arguments to use as parameters for the field + class instianciation. + + This will use :py:attr:`field_kwargs` as a starter, + and use sensible defaults for a few attributes: + + - :py:attr:`instance.verbose_name` for the field label + - :py:attr:`instance.help_text` for the field help text + - :py:attr:`instance.widget` for the field widget + - :py:attr:`instance.required` defined if the value is required or not + - :py:attr:`instance.initial` defined if the initial value + """ + kwargs = self.field_kwargs.copy() + kwargs.setdefault("label", self.get("verbose_name")) + kwargs.setdefault("help_text", self.get("help_text")) + kwargs.setdefault("widget", self.get("widget")) + kwargs.setdefault("required", self.get("required")) + kwargs.setdefault("initial", self.initial) + kwargs.setdefault("validators", []) + kwargs["validators"].append(self.validate) + return kwargs + + def api_repr(self, value): + """ + Used only to represent a preference value using Rest Framework + """ + return value + + def get_api_additional_data(self): + """ + Additional data to serialize for use on front-end side, for example + """ + return {} + + def get_api_field_data(self): + """ + Field data to serialize for use on front-end side, for example + will include choices available for a choice field + """ + field = self.setup_field() + d = { + "class": field.__class__.__name__, + "widget": {"class": field.widget.__class__.__name__}, + } + + try: + d["input_type"] = field.widget.input_type + except AttributeError: + # some widgets, such as Select do not have an input type + # in django < 1.11 + d["input_type"] = None + + return d + + def validate(self, value): + """ + Used to implement custom cleaning logic for use in forms + and serializers. The method will be passed as a validator to + the preference form field. + + :Example: + + .. code-block:: python + + def validate(self, value): + if value == '42': + raise ValidationError('Wrong value!') + """ + return + + +class BooleanPreference(BasePreferenceType): + """ + A preference type that stores a boolean. + """ + + field_class = forms.BooleanField + serializer = BooleanSerializer + required = False + + +class IntegerPreference(BasePreferenceType): + """ + A preference type that stores an integer. + """ + + field_class = forms.IntegerField + serializer = IntegerSerializer + + +IntPreference = IntegerPreference + + +class DecimalPreference(BasePreferenceType): + """ + A preference type that stores a :py:class:`decimal.Decimal`. + """ + + field_class = forms.DecimalField + serializer = DecimalSerializer + + +class FloatPreference(BasePreferenceType): + """ + A preference type that stores a float. + """ + + field_class = forms.FloatField + serializer = FloatSerializer + + +class StringPreference(BasePreferenceType): + """ + A preference type that stores a string. + """ + + field_class = forms.CharField + serializer = StringSerializer + + +class LongStringPreference(StringPreference): + """ + A preference type that stores a string, but with a textarea widget. + """ + + widget = forms.Textarea + + +class ChoicePreference(BasePreferenceType): + """ + A preference type that stores a string among a list of choices. + """ + + choices = () + """ + Expects the same values as for django :py:class:`forms.ChoiceField`. + + :Example: + + .. code-block:: python + + class MyChoicePreference(ChoicePreference): + choices = [ + ('c', 'Carrot'), + ('t', 'Tomato'), + ] + """ + field_class = forms.ChoiceField + serializer = StringSerializer + + def get_field_kwargs(self): + field_kwargs = super(ChoicePreference, self).get_field_kwargs() + field_kwargs["choices"] = self.get("choices") or self.field_attribute["initial"] + return field_kwargs + + def get_api_additional_data(self): + d = super(ChoicePreference, self).get_api_additional_data() + d["choices"] = self.get("choices") + return d + + def get_choice_values(self): + return [c[0] for c in self.get("choices")] + + def validate(self, value): + if value not in self.get_choice_values(): + raise forms.ValidationError("{} is not a valid choice".format(value)) + + +def create_deletion_handler(preference): + """ + Will generate a dynamic handler to purge related preference + on instance deletion + """ + + def delete_related_preferences(sender, instance, *args, **kwargs): + queryset = preference.registry.preference_model.objects.filter( + name=preference.name, section=preference.section + ) + related_preferences = queryset.filter( + raw_value=preference.serializer.serialize(instance) + ) + related_preferences.delete() + + return delete_related_preferences + + +class ModelChoicePreference(BasePreferenceType): + """ + A preference type that stores a reference to a model instance. + + :Example: + + .. code-block:: python + + from myapp.blog.models import BlogEntry + + @registry.register + class FeaturedEntry(ModelChoicePreference): + section = Section('blog') + name = 'featured_entry' + queryset = BlogEntry.objects.filter(status='published') + + blog_entry = BlogEntry.objects.get(pk=12) + manager['blog__featured_entry'] = blog_entry + + # accessing the value will return the model instance + assert manager['blog__featured_entry'].pk == 12 + + .. note:: + + You should provide either the :py:attr:`queryset` or :py:attr:`model` + attribute + """ + + field_class = forms.ModelChoiceField + serializer_class = ModelSerializer + + model = None + """ + Which model class to link the preference to. You can skip this if you + define the :py:attr:`queryset` attribute. + """ + + queryset = None + """ + A queryset to filter available model instances. + """ + signals_handlers = {} + + def __init__(self, *args, **kwargs): + super(ModelChoicePreference, self).__init__(*args, **kwargs) + + if self.model is not None: + # Set queryset following model attribute + self.queryset = self.model.objects.all() + elif self.queryset is not None: + # Set model following queryset attribute + self.model = self.queryset.model + else: + raise MissingModel + + self.serializer = self.serializer_class(self.model) + + self._setup_signals() + + def _setup_signals(self): + handler = create_deletion_handler(self) + # We need to keep a reference to the handler or it will cause + # weakref to die and our handler will not be called + self.signals_handlers["pre_delete"] = [handler] + pre_delete.connect(handler, sender=self.model) + + def get_field_kwargs(self): + kw = super(ModelChoicePreference, self).get_field_kwargs() + kw["queryset"] = self.get("queryset") + return kw + + def api_repr(self, value): + if not value: + return None + if value.__class__.__name__ == "QuerySet": + return [val.pk for val in value] + return value.pk + + +class ModelMultipleChoicePreference(ModelChoicePreference): + """ + A preference type that stores a reference list to the model instances. + + :Example: + + .. code-block:: python + + from myapp.blog.models import BlogEntry + + @registry.register + class FeaturedEntries(ModelMultipleChoicePreference): + section = Section('blog') + name = 'featured_entries' + queryset = BlogEntry.objects.all() + + blog_entries = BlogEntry.objects.filter(status='published') + manager['blog__featured_entries'] = blog_entries + + # accessing the value will return the model queryset + assert manager['blog__featured_entries'] == blog_entries + + .. note:: + + You should provide either the :py:attr:`queryset` or :py:attr:`model` + attribute + """ + + serializer_class = ModelMultipleSerializer + field_class = forms.ModelMultipleChoiceField + + def _setup_signals(self): + pass + + +class FilePreference(BasePreferenceType): + """ + A preference type that stores a a reference to a model. + + :Example: + + .. code-block:: python + + from django.core.files.uploadedfile import SimpleUploadedFile + + @registry.register + class Logo(FilePreference): + section = Section('blog') + name = 'logo' + + logo = SimpleUploadedFile( + "logo.png", b"file_content", content_type="image/png") + manager['blog__logo'] = logo + + # accessing the value will return a FieldFile object, just as + # django.db.models.FileField + assert manager['blog__logo'].read() == b'file_content' + + manager['blog__logo'].delete() + + """ + + field_class = forms.FileField + serializer_class = FileSerializer + default = None + + @property + def serializer(self): + """ + The serializer need additional data about the related preference + to upload file to correct directory + """ + return self.serializer_class(self) + + def get_field_kwargs(self): + kwargs = super(FilePreference, self).get_field_kwargs() + kwargs["required"] = self.get("required", False) + return kwargs + + def get_upload_path(self): + return os.path.join( + preferences_settings.FILE_PREFERENCE_UPLOAD_DIR, self.identifier() + ) + + def get_file_storage(self): + """ + Override this method if you want to use a custom storage + """ + return default_storage + + def api_repr(self, value): + if value: + return value.url + + +class DurationPreference(BasePreferenceType): + """ + A preference type that stores a timedelta. + """ + + field_class = forms.DurationField + serializer = DurationSerializer + + def api_repr(self, value): + return duration_string(value) + + +class DatePreference(BasePreferenceType): + """ + A preference type that stores a date. + """ + + field_class = forms.DateField + serializer = DateSerializer + + def api_repr(self, value): + return value.isoformat() + + +class DateTimePreference(BasePreferenceType): + """ + A preference type that stores a datetime. + """ + + field_class = forms.DateTimeField + serializer = DateTimeSerializer + + def api_repr(self, value): + return value.isoformat() + + +class TimePreference(BasePreferenceType): + """ + A preference type that stores a time. + """ + + field_class = forms.TimeField + serializer = TimeSerializer + + def api_repr(self, value): + return value.isoformat() + + +class MultipleChoicePreference(ChoicePreference): + """ + A preference type that stores multiple strings among a list of choices. + + :Example: + + .. code-block:: python + + @registry.register + class FeaturedEntries(MultipleChoicePreference): + section = Section('blog') + name = 'featured_entries' + choices = [ + ('c', 'Carrot'), + ('t', 'Tomato'), + ] + + .. note:: + + Internally, the selected choices are stored as a string, separated by a + separator. The separator defaults to ','. The way this is implemented still + is sae also on keys that cotain the separator, but if in doubt, you can still + set the :py:attr:`separator` to any other character. + """ + + widget = forms.CheckboxSelectMultiple + field_class = forms.MultipleChoiceField + serializer = MultipleSerializer + + def validate(self, value): + for v in value: + super().validate(v) diff --git a/dynamic_preferences/urls.py b/dynamic_preferences/urls.py new file mode 100644 index 0000000..dab3388 --- /dev/null +++ b/dynamic_preferences/urls.py @@ -0,0 +1,53 @@ +try: + from django.urls import include, re_path +except ImportError: + from django.conf.urls import include, url as re_path + +from django.contrib.admin.views.decorators import staff_member_required +from . import views +from .registries import global_preferences_registry +from .forms import GlobalPreferenceForm +from django.urls import path + +from .views import ( + AboutUsAPIView, + FAQCourseAPIView, + FAQGeneralAPIView, + SupportAPIView, + CardAPIView +) + +app_name = "dynamic_preferences" + + + + + +urlpatterns = [ + path('about-us/', AboutUsAPIView.as_view(), name='about-us-api'), + path('faq-course/', FAQCourseAPIView.as_view(), name='faq-course-api'), + path('faq-general/', FAQGeneralAPIView.as_view(), name='faq-general-api'), + path('support/', SupportAPIView.as_view(), name='support-api'), + path('card/', CardAPIView.as_view(), name='card-api'), + + + re_path( + r"^global/$", + staff_member_required( + views.PreferenceFormView.as_view( + registry=global_preferences_registry, form_class=GlobalPreferenceForm + ) + ), + name="global", + ), + re_path( + r"^global/(?P
[\w\ ]+)$", + staff_member_required( + views.PreferenceFormView.as_view( + registry=global_preferences_registry, form_class=GlobalPreferenceForm + ) + ), + name="global.section", + ), + re_path(r"^user/", include("dynamic_preferences.users.urls")), +] diff --git a/dynamic_preferences/users/__init__.py b/dynamic_preferences/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dynamic_preferences/users/admin.py b/dynamic_preferences/users/admin.py new file mode 100644 index 0000000..21be4bf --- /dev/null +++ b/dynamic_preferences/users/admin.py @@ -0,0 +1,21 @@ +from django.contrib import admin as django_admin +from django import forms + +from ..settings import preferences_settings +from .. import admin +from .models import UserPreferenceModel +from .forms import UserSinglePreferenceForm + + +class UserPreferenceAdmin(admin.PerInstancePreferenceAdmin): + search_fields = ["instance__username"] + admin.DynamicPreferenceAdmin.search_fields + form = UserSinglePreferenceForm + changelist_form = UserSinglePreferenceForm + + def get_queryset(self, request, *args, **kwargs): + # Instanciate default prefs + getattr(request.user, preferences_settings.MANAGER_ATTRIBUTE).all() + return super(UserPreferenceAdmin, self).get_queryset(request, *args, **kwargs) + + +django_admin.site.register(UserPreferenceModel, UserPreferenceAdmin) diff --git a/dynamic_preferences/users/apps.py b/dynamic_preferences/users/apps.py new file mode 100644 index 0000000..7ab9059 --- /dev/null +++ b/dynamic_preferences/users/apps.py @@ -0,0 +1,18 @@ +from django.apps import AppConfig, apps +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +from ..registries import preference_models +from .registries import user_preferences_registry + + +class UserPreferencesConfig(AppConfig): + name = "dynamic_preferences.users" + verbose_name = _("Preferences - Users") + label = "dynamic_preferences_users" + default_auto_field = "django.db.models.AutoField" + + def ready(self): + UserPreferenceModel = self.get_model("UserPreferenceModel") + + preference_models.register(UserPreferenceModel, user_preferences_registry) diff --git a/dynamic_preferences/users/forms.py b/dynamic_preferences/users/forms.py new file mode 100644 index 0000000..73019b2 --- /dev/null +++ b/dynamic_preferences/users/forms.py @@ -0,0 +1,33 @@ +from six import string_types +from django import forms +from django.core.exceptions import ValidationError +from collections import OrderedDict + +from .registries import user_preferences_registry +from ..forms import ( + SinglePerInstancePreferenceForm, + preference_form_builder, + PreferenceForm, +) +from ..exceptions import NotFoundInRegistry +from .models import UserPreferenceModel + + +class UserSinglePreferenceForm(SinglePerInstancePreferenceForm): + class Meta: + model = UserPreferenceModel + fields = SinglePerInstancePreferenceForm.Meta.fields + + +def user_preference_form_builder(instance, preferences=[], **kwargs): + """ + A shortcut :py:func:`preference_form_builder(UserPreferenceForm, preferences, **kwargs)` + :param user: a :py:class:`django.contrib.auth.models.User` instance + """ + return preference_form_builder( + UserPreferenceForm, preferences, instance=instance, **kwargs + ) + + +class UserPreferenceForm(PreferenceForm): + registry = user_preferences_registry diff --git a/dynamic_preferences/users/migrations/__init__.py b/dynamic_preferences/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dynamic_preferences/users/models.py b/dynamic_preferences/users/models.py new file mode 100644 index 0000000..46b35dd --- /dev/null +++ b/dynamic_preferences/users/models.py @@ -0,0 +1,15 @@ +from django.db import models +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +from dynamic_preferences.models import PerInstancePreferenceModel + + +class UserPreferenceModel(PerInstancePreferenceModel): + + instance = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + + class Meta(PerInstancePreferenceModel.Meta): + app_label = "dynamic_preferences_users" + verbose_name = _("user preference") + verbose_name_plural = _("user preferences") diff --git a/dynamic_preferences/users/registries.py b/dynamic_preferences/users/registries.py new file mode 100644 index 0000000..f3cd154 --- /dev/null +++ b/dynamic_preferences/users/registries.py @@ -0,0 +1,8 @@ +from ..registries import PerInstancePreferenceRegistry + + +class UserPreferenceRegistry(PerInstancePreferenceRegistry): + section_url_namespace = "dynamic_preferences:user.section" + + +user_preferences_registry = UserPreferenceRegistry() diff --git a/dynamic_preferences/users/serializers.py b/dynamic_preferences/users/serializers.py new file mode 100644 index 0000000..1470d33 --- /dev/null +++ b/dynamic_preferences/users/serializers.py @@ -0,0 +1,5 @@ +from dynamic_preferences.api.serializers import PreferenceSerializer + + +class UserPreferenceSerializer(PreferenceSerializer): + pass diff --git a/dynamic_preferences/users/urls.py b/dynamic_preferences/users/urls.py new file mode 100644 index 0000000..d75a283 --- /dev/null +++ b/dynamic_preferences/users/urls.py @@ -0,0 +1,16 @@ +try: + from django.urls import include, re_path +except ImportError: + from django.conf.urls import include, url as re_path + +from django.contrib.auth.decorators import login_required +from . import views + +urlpatterns = [ + re_path(r"^$", login_required(views.UserPreferenceFormView.as_view()), name="user"), + re_path( + r"^(?P
[\w\ ]+)$", + login_required(views.UserPreferenceFormView.as_view()), + name="user.section", + ), +] diff --git a/dynamic_preferences/users/views.py b/dynamic_preferences/users/views.py new file mode 100644 index 0000000..366cd11 --- /dev/null +++ b/dynamic_preferences/users/views.py @@ -0,0 +1,18 @@ +from ..views import PreferenceFormView +from .forms import user_preference_form_builder +from .registries import user_preferences_registry + + +class UserPreferenceFormView(PreferenceFormView): + """ + Will pass `request.user` to form_builder + """ + + registry = user_preferences_registry + + def get_form_class(self, *args, **kwargs): + section = self.kwargs.get("section", None) + form_class = user_preference_form_builder( + instance=self.request.user, section=section + ) + return form_class diff --git a/dynamic_preferences/users/viewsets.py b/dynamic_preferences/users/viewsets.py new file mode 100644 index 0000000..4f95af6 --- /dev/null +++ b/dynamic_preferences/users/viewsets.py @@ -0,0 +1,15 @@ +from rest_framework import permissions + +from dynamic_preferences.api import viewsets + +from . import serializers +from . import models + + +class UserPreferencesViewSet(viewsets.PerInstancePreferenceViewSet): + queryset = models.UserPreferenceModel.objects.all() + serializer_class = serializers.UserPreferenceSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_related_instance(self): + return self.request.user diff --git a/dynamic_preferences/utils.py b/dynamic_preferences/utils.py new file mode 100644 index 0000000..dc181f3 --- /dev/null +++ b/dynamic_preferences/utils.py @@ -0,0 +1,18 @@ +try: + from collections.abc import Mapping +except ImportError: + from collections import Mapping + + +def update(d, u): + """ + Custom recursive update of dictionary + from http://stackoverflow.com/questions/3232943/update-value-of-a-nested-dictionary-of-varying-depth + """ + for k, v in u.iteritems(): + if isinstance(v, Mapping): + r = update(d.get(k, {}), v) + d[k] = r + else: + d[k] = u[k] + return d diff --git a/dynamic_preferences/views.py b/dynamic_preferences/views.py new file mode 100644 index 0000000..f12572b --- /dev/null +++ b/dynamic_preferences/views.py @@ -0,0 +1,118 @@ +from django.views.generic import TemplateView, FormView +from django.http import Http404 +from .forms import preference_form_builder +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response +from dynamic_preferences.registries import global_preferences_registry +from .serializers import ( + AboutUsSerializer, + FAQItemSerializer, + SupportSerializer, + CardSerializer +) + +class AboutUsAPIView(GenericAPIView): + serializer_class = AboutUsSerializer + + def get(self, request, *args, **kwargs): + preferences = global_preferences_registry.manager() + about_us = preferences.get('aboutus__aboutus', '') + serializer = self.get_serializer(data={'content': about_us}) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + +class FAQCourseAPIView(GenericAPIView): + serializer_class = FAQItemSerializer + + def get(self, request, *args, **kwargs): + preferences = global_preferences_registry.manager() + faq_course = preferences.get('FAQ_Course__FAQ_Course', []) + serializer = self.get_serializer(data=faq_course, many=True) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + +class FAQGeneralAPIView(GenericAPIView): + serializer_class = FAQItemSerializer + + def get(self, request, *args, **kwargs): + preferences = global_preferences_registry.manager() + faq_general = preferences.get('FAQ_General__FAQ_General', []) + serializer = self.get_serializer(data=faq_general, many=True) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + +class SupportAPIView(GenericAPIView): + serializer_class = SupportSerializer + + def get(self, request, *args, **kwargs): + preferences = global_preferences_registry.manager() + support = preferences.get('support__support', {}) + serializer = self.get_serializer(data=support) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + +class CardAPIView(GenericAPIView): + serializer_class = CardSerializer + + def get(self, request, *args, **kwargs): + preferences = global_preferences_registry.manager() + card = preferences.get('card__card', {}) + serializer = self.get_serializer(data=card) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + + +"""Todo : remove these views and use only context processors""" + + +class RegularTemplateView(TemplateView): + """Used for testing context""" + + template_name = "dynamic_preferences/testcontext.html" + + +class PreferenceFormView(FormView): + """ + Display a form for updating preferences of the given + section provided via URL arg. + If no section is provided, will display a form for all + fields of a given registry. + """ + + #: the registry for preference lookups + registry = None + + #: will be used by :py:func:`forms.preference_form_builder` + # to create the form + form_class = None + template_name = "dynamic_preferences/form.html" + + def dispatch(self, request, *args, **kwargs): + self.section_name = kwargs.get("section", None) + if self.section_name: + try: + self.section = self.registry.section_objects[self.section_name] + except KeyError: + raise Http404 + else: + self.section = None + return super(PreferenceFormView, self).dispatch(request, *args, **kwargs) + + def get_form_class(self, *args, **kwargs): + form_class = preference_form_builder(self.form_class, section=self.section_name) + return form_class + + def get_context_data(self, *args, **kwargs): + context = super(PreferenceFormView, self).get_context_data(*args, **kwargs) + context["registry"] = self.registry + context["section"] = self.section + + return context + + def get_success_url(self): + return self.request.path + + def form_valid(self, form): + + form.update_preferences() + return super(PreferenceFormView, self).form_valid(form) diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..6317c6c --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/sh +sleep 20 + +python manage.py migrate + +exec "$@" diff --git a/errors.json b/errors.json new file mode 100644 index 0000000..8955889 --- /dev/null +++ b/errors.json @@ -0,0 +1,14 @@ +{ + "This email is already registered.": "Этот адрес электронной почты уже зарегистрирован.", + "Passwords do not match.": "Пароли не совпадают.", + "Password must be at least 8 characters long.": "Пароль должен содержать не менее 8 символов.", + "Verification data not found or expired.": "Данные верификации не найдены или устарели.", + "The verification code has expired.": "Срок действия кода подтверждения истек.", + "code notfound": "код не найден", + "Unable to log in with provided credentials.": "Невозможно войти в систему с предоставленными учетными данными.", + "User does not exist.": "Пользователь не существует.", + "Course not found": "Курс не найден.", + "course not found": "курс не найден", + "you have already participated in the quiz": "вы уже участвовали в викторине", + "The email must be for the requesting user": "Электронная почта должна быть для запрашивающего пользователя" +} diff --git a/locale/fa/LC_MESSAGES/django.po b/locale/fa/LC_MESSAGES/django.po new file mode 100644 index 0000000..221e9c7 --- /dev/null +++ b/locale/fa/LC_MESSAGES/django.po @@ -0,0 +1,1306 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#: config/settings/base.py:485 config/settings/base.py:496 +#: config/settings/base.py:507 config/settings/base.py:518 +#: config/settings/base.py:530 +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-04-04 06:00+0330\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: apps/account/admin/professor.py:59 apps/account/admin/student.py:46 +msgid "Personal info" +msgstr "اطلاعات شخصی" + +#: apps/account/admin/professor.py:60 apps/account/admin/student.py:47 +#: apps/account/admin/user.py:72 apps/account/admin/user.py:99 +#: apps/account/admin/user.py:288 apps/account/admin/user.py:291 +#: templates/admin/filer/folder/directory_table.html:129 +msgid "Permissions" +msgstr "مجوزها" + +#: apps/account/admin/professor.py:63 apps/account/admin/student.py:50 +#: apps/account/admin/user.py:105 +msgid "Important dates" +msgstr "تاریخ‌های مهم" + +#: apps/account/admin/user.py:64 apps/account/admin/user.py:166 +msgid "Location" +msgstr "موقعیت" + +#: apps/account/admin/user.py:68 +msgid "Password" +msgstr "رمز عبور" + +#: apps/account/admin/user.py:80 +msgid "Basic Information" +msgstr "اطلاعات پایه" + +#: apps/account/admin/user.py:87 +msgid "Country & City" +msgstr "کشور و شهر" + +#: apps/account/admin/user.py:93 +msgid "Device Information" +msgstr "اطلاعات دستگاه" + +#: apps/account/admin/user.py:122 apps/account/admin/user.py:147 +msgid "Date Joined" +msgstr "تاریخ عضویت" + +#: apps/account/admin/user.py:126 +msgid "Last Login" +msgstr "آخرین ورود" + +#: apps/account/admin/user.py:170 +msgid "password" +msgstr "رمز عبور" + +#: apps/account/admin/user.py:177 apps/course/admin/course.py:159 +msgid "Student" +msgstr "دانشجو" + +#: apps/account/admin/user.py:197 +msgid "Age" +msgstr "سن" + +#: apps/account/admin/user.py:217 apps/account/admin/user.py:349 +#: apps/course/admin/course.py:79 config/settings/base.py:447 +#: config/settings/base.py:542 config/settings/base.py:547 +msgid "Courses" +msgstr "دوره‌ها" + +#: apps/account/admin/user.py:298 apps/course/admin/course.py:45 +msgid "Course Categories" +msgstr "دسته‌بندی‌های دوره" + +#: apps/account/admin/user.py:317 apps/course/admin/course.py:64 +msgid "Edit" +msgstr "ویرایش" + +#: apps/account/admin/user.py:329 apps/course/admin/course.py:312 +msgid "Professor" +msgstr "استاد" + +#: apps/account/models/notification.py:10 apps/hadis/models/hadis.py:10 +#: apps/hadis/models/hadis.py:21 apps/hadis/models/hadis.py:49 +#: apps/podcast/models.py:6 apps/quiz/models/quiz.py:9 apps/video/models.py:7 +msgid "title" +msgstr "عنوان" + +#: apps/account/models/notification.py:11 +msgid "message" +msgstr "پیام" + +#: apps/account/models/notification.py:12 +msgid "user" +msgstr "کاربر" + +#: apps/account/models/notification.py:13 +msgid "is read" +msgstr "خوانده شده" + +#: apps/account/models/notification.py:18 +msgid "service" +msgstr "سرویس" + +#: apps/account/models/notification.py:20 apps/hadis/models/hadis.py:28 +#: apps/hadis/models/hadis.py:54 apps/hadis/models/hadis.py:66 +#: apps/hadis/models/transmitter.py:44 apps/library/models.py:70 +#: apps/library/models.py:114 apps/podcast/models.py:11 +#: apps/podcast/models.py:26 apps/podcast/models.py:78 apps/video/models.py:12 +#: apps/video/models.py:27 apps/video/models.py:85 +msgid "created at" +msgstr "ایجاد شده " + +#: apps/account/models/notification.py:21 apps/hadis/models/hadis.py:29 +#: apps/library/models.py:71 apps/library/models.py:115 +#: apps/podcast/models.py:12 apps/podcast/models.py:27 +#: apps/podcast/models.py:79 apps/video/models.py:13 apps/video/models.py:28 +#: apps/video/models.py:86 +msgid "updated at" +msgstr "به‌روزرسانی شده " + +#: apps/account/models/user.py:34 apps/transaction/models.py:43 +msgid "birthdate" +msgstr "تاریخ تولد" + +#: apps/account/models/user.py:41 +msgid "Phone Number" +msgstr "شماره تلفن" + +#: apps/account/models/user.py:46 apps/transaction/models.py:41 +msgid "Gender" +msgstr "جنسیت" + +#: apps/account/models/user.py:50 +msgid "City" +msgstr "شهر" + +#: apps/account/models/user.py:51 apps/account/models/user.py:121 +msgid "country" +msgstr "کشور" + +#: apps/account/models/user.py:53 +msgid "device id" +msgstr "شناسه دستگاه" + +#: apps/account/models/user.py:119 +msgid "lat" +msgstr "عرض جغرافیایی" + +#: apps/account/models/user.py:120 +msgid "lon" +msgstr "طول جغرافیایی" + +#: apps/account/models/user.py:122 +msgid "city" +msgstr "شهر" + +#: apps/account/templates/account/group_help_text.html:5 +msgid "Driver before template" +msgstr "راننده قبل از قالب" + +#: apps/account/templates/account/group_help_text.html:11 +msgid "Active drivers" +msgstr "رانندگان فعال" + +#: apps/account/templates/account/group_help_text.html:19 +msgid "Inactive drivers" +msgstr "رانندگان غیرفعال" + +#: apps/account/templates/account/group_help_text.html:27 +msgid "Total points" +msgstr "مجموع امتیازات" + +#: apps/account/templates/account/group_help_text.html:35 +msgid "Total races" +msgstr "مجموع مسابقات" + +#: apps/account/templates/account/user_list_section.html:8 +msgid "Total Actice Users" +msgstr "مجموع کاربران فعال" + +#: apps/account/templates/account/user_list_section.html:16 +msgid "Total Guest Users" +msgstr "مجموع کاربران مهمان" + +#: apps/account/templates/account/user_list_section.html:22 +msgid "Total Students" +msgstr "مجموع دانشجویان" + +#: apps/account/templates/account/user_list_section.html:28 +msgid "Total Professors" +msgstr "مجموع اساتید" + +#: apps/api/admin.py:27 +msgid "Dimensions" +msgstr "ابعاد" + +#: apps/api/admin.py:31 +msgid "Preview" +msgstr "پیش‌نمایش" + +#: apps/certificate/admin.py:23 apps/transaction/admin.py:37 +msgid "Timestamps" +msgstr "مهرهای زمانی" + +#: apps/certificate/admin.py:29 apps/course/admin/course.py:273 +#: apps/library/admin.py:20 apps/video/admin.py:78 +msgid "Status" +msgstr "وضعیت" + +#: apps/certificate/models.py:12 +msgid "pending" +msgstr " انتظار" + +#: apps/certificate/models.py:13 +msgid "approved" +msgstr "تأیید شده" + +#: apps/certificate/models.py:14 +msgid "canceled" +msgstr "لغو شده" + +#: apps/certificate/models.py:20 +msgid "certificate_file" +msgstr "فایل گواهینامه" + +#: apps/course/admin/course.py:97 +msgid "Course Weekly Schedule" +msgstr "برنامه هفتگی دوره" + +#: apps/course/admin/course.py:101 utils/schema.py:47 +msgid "Course Features" +msgstr "ویژگی‌های دوره" + +#: apps/course/admin/course.py:154 +msgid "Enrollment Details" +msgstr "جزئیات ثبت‌نام" + +#: apps/course/admin/course.py:179 apps/course/admin/course.py:293 +msgid "Course" +msgstr "دوره" + +#: apps/course/admin/course.py:191 +msgid "Participant" +msgstr "شرکت‌کننده" + +#: apps/course/admin/course.py:192 +msgid "Participants" +msgstr "شرکت‌کنندگان" + +#: apps/course/admin/course.py:222 +msgid "Select Student" +msgstr "انتخاب دانشجو" + +#: apps/course/admin/course.py:276 +msgid "Course Details" +msgstr "جزئیات دوره" + +#: apps/course/admin/course.py:280 +msgid "Media" +msgstr "رسانه" + +#: apps/course/admin/course.py:283 +msgid "Pricing" +msgstr "قیمت‌گذاری" + +#: apps/course/admin/course.py:286 +msgid "Timing & Features" +msgstr "زمان‌بندی و ویژگی‌ها" + +#: apps/course/admin/course.py:301 +msgid "No description" +msgstr "بدون توضیحات" + +#: apps/course/admin/course.py:316 apps/transaction/admin.py:49 +msgid "Price" +msgstr "قیمت" + +#: apps/course/admin/course.py:319 +msgid "Free" +msgstr "رایگان" + +#: apps/course/admin/course.py:340 +msgid "View Lessons" +msgstr "مشاهده دروس" + +#: apps/course/admin/course.py:351 apps/course/admin/course.py:386 +msgid "Course not found" +msgstr "دوره یافت نشد" + +#: apps/course/admin/course.py:376 +msgid "Add Student to Course" +msgstr "افزودن دانشجو به دوره" + +#: apps/course/admin/course.py:396 +#, python-brace-format +msgid "Student {student.fullname} is already enrolled in this course" +msgstr "دانشجو {student.fullname} قبلاً در این دوره ثبت‌نام کرده است" + +#: apps/course/admin/course.py:405 +#, python-brace-format +msgid "" +"Student {student.fullname} has been successfully added to {course.title}" +msgstr "دانشجو {student.fullname} با موفقیت به دوره {course.title} اضافه شد" + +#: apps/course/admin/course.py:418 +msgid "Change detail action for {}" +msgstr "تغییر عملیات جزئیات برای {}" + +#: apps/course/admin/lesson.py:59 apps/hadis/admin/hadis.py:148 +msgid "Content" +msgstr "محتوا" + +#: apps/course/admin/lesson.py:82 +msgid "Duration" +msgstr "مدت زمان" + +#: apps/course/models/course.py:69 +msgid "Thumbnail" +msgstr "تصویر بندانگشتی" + +#: apps/course/models/course.py:95 +msgid "Course Final Price" +msgstr "قیمت نهایی دوره" + +#: apps/course/models/course.py:96 +msgid "" +"This field is automatically calculated based on the discount percentage." +msgstr "این فیلد به صورت خودکار بر اساس درصد تخفیف محاسبه می‌شود." + +#: apps/course/models/course.py:99 +msgid "Timing" +msgstr "زمان‌بندی" + +#: apps/course/models/course.py:100 +msgid "Course features" +msgstr "ویژگی‌های دوره" + +#: apps/course/models/course.py:101 apps/course/models/lesson.py:35 +#: apps/course/models/lesson.py:98 apps/transaction/models.py:20 +msgid "Created at" +msgstr "ایجاد شده در" + +#: apps/course/models/course.py:102 apps/course/models/lesson.py:36 +msgid "Updated At" +msgstr "به‌روزرسانی شده در" + +#: apps/course/models/lesson.py:25 +msgid "Is Active" +msgstr "فعال است" + +#: apps/course/templates/course/add_student_form.html:25 +msgid "Submit form" +msgstr "ارسال فرم" + +#: apps/hadis/admin/category.py:38 apps/hadis/models/category.py:21 +msgid "Source Type" +msgstr "نوع منبع" + +#: apps/hadis/admin/category.py:69 +msgid "This item can not be modified" +msgstr "این مورد قابل تغییر نیست" + +#: apps/hadis/admin/category.py:198 +msgid "Category saved successfully. Tree will be reloaded." +msgstr "دسته‌بندی با موفقیت ذخیره شد. درخت مجدداً بارگذاری خواهد شد." + +#: apps/hadis/admin/hadis.py:17 +msgid "Red" +msgstr "قرمز" + +#: apps/hadis/admin/hadis.py:18 +msgid "Blue" +msgstr "آبی" + +#: apps/hadis/admin/hadis.py:19 +msgid "Green" +msgstr "سبز" + +#: apps/hadis/admin/hadis.py:20 +msgid "Yellow" +msgstr "زرد" + +#: apps/hadis/admin/hadis.py:21 +msgid "Orange" +msgstr "نارنجی" + +#: apps/hadis/admin/hadis.py:22 +msgid "Purple" +msgstr "بنفش" + +#: apps/hadis/admin/hadis.py:23 +msgid "Pink" +msgstr "صورتی" + +#: apps/hadis/admin/hadis.py:24 +msgid "Brown" +msgstr "قهوه‌ای" + +#: apps/hadis/admin/hadis.py:25 +msgid "Gray" +msgstr "خاکستری" + +#: apps/hadis/admin/hadis.py:26 +msgid "Black" +msgstr "سیاه" + +#: apps/hadis/admin/hadis.py:41 +msgid "Link" +msgstr "پیوند" + +#: apps/hadis/admin/hadis.py:43 apps/hadis/models/hadis.py:22 +msgid "text" +msgstr "متن" + +#: apps/hadis/admin/hadis.py:44 +msgid "link" +msgstr "پیوند" + +#: apps/hadis/admin/hadis.py:76 apps/hadis/models/hadis.py:91 +msgid "Reference Images" +msgstr "تصاویر مرجع" + +#: apps/hadis/admin/hadis.py:109 +msgid "Reference Information" +msgstr "اطلاعات مرجع" + +#: apps/hadis/admin/hadis.py:112 +msgid "Additional Information" +msgstr "اطلاعات تکمیلی" + +#: apps/hadis/admin/hadis.py:125 +msgid "Hadis Overview" +msgstr "نمای کلی حدیث" + +#: apps/hadis/models/category.py:9 +#: apps/hadis/templates/admin/category_index.html:25 +msgid "Shia" +msgstr "شیعه" + +#: apps/hadis/models/category.py:10 +#: apps/hadis/templates/admin/category_index.html:31 +msgid "Sunni" +msgstr "سنی" + +#: apps/hadis/models/category.py:13 +#: apps/hadis/templates/admin/category_index.html:158 +msgid "Quran" +msgstr "قرآن" + +#: apps/hadis/models/category.py:14 +#: apps/hadis/templates/admin/category_index.html:161 +msgid "Hadith" +msgstr "حدیث" + +#: apps/hadis/models/category.py:17 +msgid "Level 1 (Root)" +msgstr "سطح ۱ (ریشه)" + +#: apps/hadis/models/category.py:18 +msgid "Level 2 (Child)" +msgstr "سطح ۲ (فرزند)" + +#: apps/hadis/models/category.py:19 +msgid "Level 3 (Grandchild)" +msgstr "سطح ۳ (نوه)" + +#: apps/hadis/models/category.py:22 +msgid "Category Content Type" +msgstr "نوع محتوای دسته‌بندی" + +#: apps/hadis/models/category.py:23 +msgid "name" +msgstr "نام" + +#: apps/hadis/models/category.py:24 apps/library/models.py:23 +#: apps/podcast/models.py:10 apps/video/models.py:11 +msgid "order" +msgstr "ترتیب" + +#: apps/hadis/models/category.py:34 +msgid "Hadis Category" +msgstr "دسته‌بندی حدیث" + +#: apps/hadis/models/category.py:35 +msgid "Hadis Categories" +msgstr "دسته‌بندی‌های حدیث" + +#: apps/hadis/models/category.py:54 +msgid "Level 1 cannot have content type" +msgstr "سطح ۱ نمی‌تواند نوع محتوا داشته باشد" + +#: apps/hadis/models/category.py:57 +msgid "Level 2 must have content type" +msgstr "سطح ۲ باید نوع محتوا داشته باشد" + +#: apps/hadis/models/category.py:60 +msgid "Level 3 cannot have source/content type" +msgstr "سطح ۳ نمی‌تواند نوع منبع/محتوا داشته باشد" + +#: apps/hadis/models/hadis.py:11 apps/hadis/models/hadis.py:45 +#: apps/hadis/models/transmitter.py:15 apps/library/models.py:22 +#: apps/library/models.py:67 apps/library/models.py:100 +#: apps/podcast/models.py:9 apps/podcast/models.py:76 apps/video/models.py:10 +#: apps/video/models.py:26 apps/video/models.py:83 +msgid "status" +msgstr "وضعیت" + +#: apps/hadis/models/hadis.py:20 +msgid "number" +msgstr "شماره" + +#: apps/hadis/models/hadis.py:23 +msgid "translation" +msgstr "ترجمه" + +#: apps/hadis/models/hadis.py:25 +msgid "category" +msgstr "دسته‌بندی" + +#: apps/hadis/models/hadis.py:27 +msgid "visibility" +msgstr "قابلیت مشاهده" + +#: apps/hadis/models/hadis.py:39 apps/hadis/models/hadis.py:61 +#: apps/hadis/models/transmitter.py:29 +msgid "hadis" +msgstr "حدیث" + +#: apps/hadis/models/hadis.py:40 +msgid "hadises" +msgstr "احادیث" + +#: apps/hadis/models/hadis.py:46 apps/hadis/models/transmitter.py:16 +msgid "Display Status Color" +msgstr "رنگ نمایش وضعیت" + +#: apps/hadis/models/hadis.py:47 +msgid "Status Text" +msgstr "متن وضعیت" + +#: apps/hadis/models/hadis.py:48 +msgid "address" +msgstr "آدرس" + +#: apps/hadis/models/hadis.py:50 +msgid "tags" +msgstr "برچسب‌ها" + +#: apps/hadis/models/hadis.py:51 +msgid "share link" +msgstr "پیوند اشتراک‌گذاری" + +#: apps/hadis/models/hadis.py:52 +msgid "explanation" +msgstr "توضیح" + +#: apps/hadis/models/hadis.py:64 +msgid "book" +msgstr "کتاب" + +#: apps/hadis/models/hadis.py:65 apps/hadis/models/transmitter.py:38 +msgid "description" +msgstr "توضیحات" + +#: apps/hadis/models/hadis.py:69 +msgid "Hadis Reference" +msgstr "مرجع حدیث" + +#: apps/hadis/models/hadis.py:70 +msgid "Hadis References" +msgstr "مراجع حدیث" + +#: apps/hadis/models/hadis.py:80 +msgid "thumbnail" +msgstr "تصویر بندانگشتی" + +#: apps/hadis/models/hadis.py:84 +msgid "Priority" +msgstr "اولویت" + +#: apps/hadis/models/hadis.py:85 +msgid "Priority of the image, lower values mean higher priority." +msgstr "اولویت تصویر، مقادیر کمتر به معنای اولویت بالاتر است." + +#: apps/hadis/models/hadis.py:90 +msgid "Reference Image" +msgstr "تصویر مرجع" + + +#: apps/hadis/models/transmitter.py:18 apps/library/models.py:97 +#: apps/podcast/models.py:66 apps/video/models.py:68 +msgid "image allowed" +msgstr "" + +#: apps/hadis/models/transmitter.py:35 +msgid "transmitter" +msgstr "" + +#: apps/hadis/models/transmitter.py:41 +msgid "Order" +msgstr "" + +#: apps/hadis/models/transmitter.py:42 +msgid "Order in the chain of transmission" +msgstr "" + +#: apps/hadis/models/transmitter.py:47 +msgid "Hadis Transmitter" +msgstr "" + +#: apps/hadis/models/transmitter.py:48 +msgid "Hadis Transmitters" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:11 +msgid "Category Tree Editor" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:15 +msgid "" +"Make your category and sort it by drag and drop . and try to edit items by " +"double click." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:62 +msgid "Parent: " +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:70 +msgid "Category Level:" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:77 +msgid "L1" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:83 +msgid "L2" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:89 +msgid "L3" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:99 +msgid "Level 1 categories represent source types: Shia or Sunni" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:106 +msgid "" +"Level 2 categories are children of Shia/Sunni with content type: Quran or " +"Hadith" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:113 +msgid "Level 3 categories are children of Quran or Hadith categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:127 +msgid "Parent Category:" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:130 +msgid "-- Select Parent Category --" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:134 +msgid "Select a parent category or leave empty for top-level category" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:916 +msgid "Search for a category..." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:925 +msgid "No categories found" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:928 +msgid "Searching..." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:931 +msgid "Type to search..." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1021 +#: apps/hadis/templates/admin/category_index.html:1190 +msgid "Level 1 Categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1028 +#: apps/hadis/templates/admin/category_index.html:1197 +msgid "Level 2 Categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1035 +#: apps/hadis/templates/admin/category_index.html:1204 +msgid "Level 3 Categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1344 +msgid "Level 1 must have a source type" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1347 +msgid "Level 2 must have a category type" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1423 +#: apps/hadis/templates/admin/category_index.html:1498 +#: apps/hadis/templates/admin/category_index.html:1719 +#: apps/hadis/templates/admin/category_index.html:2119 +msgid "Add Child Category" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1992 +msgid "No Items Found" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:2206 +#: apps/hadis/templates/admin/category_index.html:2214 +msgid "No Quran categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:2210 +#: apps/hadis/templates/admin/category_index.html:2215 +msgid "No Hadith categories" +msgstr "" + +#: apps/hadis/templates/admin/hadiscategory/change_form.html:28 +msgid "Add Category" +msgstr "" + +#: apps/hadis/templates/admin/hadisowerview_change_form.html:8 +msgid "Save And Edit Next Hadis" +msgstr "" + +#: apps/hadis/templates/admin/hadisowerview_change_form.html:12 +msgid "Save And Edit Previus Hadis" +msgstr "" + +#: apps/hadis/templates/admin/hadisowerview_change_form.html:16 +msgid "Save And Edit Random" +msgstr "" + +#: apps/library/admin.py:23 +msgid "File Information" +msgstr "" + +#: apps/library/admin.py:26 +msgid "Relations" +msgstr "" + +#: apps/library/admin.py:29 apps/video/admin.py:81 +msgid "Statistics" +msgstr "" + +#: apps/library/admin.py:48 apps/library/models.py:24 +#: apps/library/models.py:128 +msgid "Books" +msgstr "" + +#: apps/library/admin.py:57 utils/keyval_field.py:39 utils/keyval_field.py:59 +#: utils/keyval_field.py:76 utils/keyval_field.py:139 utils/keyval_field.py:167 +#: utils/schema.py:49 +msgid "Title" +msgstr "" + +#: apps/library/admin.py:60 apps/library/admin.py:185 +msgid "Number of Books" +msgstr "" + +#: apps/library/apps.py:8 +msgid "Library" +msgstr "" + +#: apps/library/models.py:10 +msgid "Pinned" +msgstr "" + +#: apps/library/models.py:11 +msgid "Middle Section" +msgstr "" + +#: apps/library/models.py:12 +msgid "Bottom Section" +msgstr "" + +#: apps/library/models.py:15 apps/library/models.py:95 +#: apps/library/models.py:96 +msgid "could be null" +msgstr "" + +#: apps/library/models.py:20 +msgid "Display Position" +msgstr "" + +#: apps/library/models.py:30 +msgid "Book Collection" +msgstr "" + +#: apps/library/models.py:31 +msgid "Book Collections" +msgstr "" + +#: apps/library/models.py:40 +msgid "Pinned Book Collection" +msgstr "" + +#: apps/library/models.py:41 +msgid "Pinned Book Collections" +msgstr "" + +#: apps/library/models.py:50 +msgid "Middle Section Book Collection" +msgstr "" + +#: apps/library/models.py:51 +msgid "Middle Section Book Collections" +msgstr "" + +#: apps/library/models.py:60 +msgid "Bottom Section Book Collection" +msgstr "" + +#: apps/library/models.py:61 +msgid "Bottom Section Book Collections" +msgstr "" + +#: apps/library/models.py:82 +msgid "Category" +msgstr "" + +#: apps/library/models.py:83 config/settings/base.py:552 +msgid "Categories" +msgstr "" + +#: apps/library/models.py:99 +msgid "Number of Pages" +msgstr "" + +#: apps/library/models.py:99 +msgid "eg. 34" +msgstr "" + +#: apps/library/models.py:101 +msgid "Pin to top" +msgstr "" + +#: apps/library/models.py:103 apps/podcast/models.py:68 apps/video/models.py:73 +msgid "categories" +msgstr "" + +#: apps/library/models.py:104 +msgid "collections" +msgstr "" + +#: apps/library/models.py:107 apps/library/models.py:108 +#: apps/podcast/models.py:73 apps/podcast/models.py:74 apps/video/models.py:81 +msgid "view count" +msgstr "" + +#: apps/library/models.py:111 +msgid "File Type" +msgstr "" + +#: apps/library/models.py:127 +msgid "Book" +msgstr "" + +#: apps/podcast/models.py:7 apps/video/models.py:8 +msgid "slug" +msgstr "" + +#: apps/podcast/models.py:18 apps/video/models.py:19 +msgid "Video Category" +msgstr "" + +#: apps/podcast/models.py:19 apps/video/models.py:20 +msgid "Video Categories" +msgstr "" + +#: apps/podcast/models.py:32 apps/podcast/models.py:48 +msgid "podcasts" +msgstr "" + +#: apps/podcast/models.py:39 +msgid "Podcast Collection" +msgstr "" + +#: apps/podcast/models.py:40 +msgid "Podcasts Collections" +msgstr "" + +#: apps/podcast/models.py:45 +msgid "podcast collection" +msgstr "" + +#: apps/podcast/models.py:50 apps/video/models.py:50 +msgid "priority" +msgstr "" + +#: apps/podcast/models.py:56 +msgid "Podcast in Collection" +msgstr "" + +#: apps/podcast/models.py:57 +msgid "Podcasts in Collection" +msgstr "" + +#: apps/podcast/models.py:85 +msgid "Podcast" +msgstr "" + +#: apps/podcast/models.py:86 +msgid "Podcasts" +msgstr "" + +#: apps/quiz/admin/participant.py:35 +msgid "User Email" +msgstr "" + +#: apps/quiz/models/quiz.py:8 +msgid "lesson" +msgstr "" + +#: apps/transaction/admin.py:43 +msgid "Payment Status" +msgstr "" + +#: apps/transaction/models.py:21 +msgid "Updated at" +msgstr "" + +#: apps/transaction/models.py:39 +msgid "phone" +msgstr "" + +#: apps/video/admin.py:31 apps/video/admin.py:55 +msgid "Number of Videos" +msgstr "" + +#: apps/video/admin.py:75 +msgid "Video Information" +msgstr "" + +#: apps/video/models.py:33 +msgid "videos" +msgstr "" + +#: apps/video/models.py:39 +msgid "Video Collection" +msgstr "" + +#: apps/video/models.py:40 +msgid "Video Collections" +msgstr "" + +#: apps/video/models.py:45 +msgid "video collection" +msgstr "" + +#: apps/video/models.py:48 +msgid "video" +msgstr "" + +#: apps/video/models.py:56 +msgid "Video in Collection" +msgstr "" + +#: apps/video/models.py:57 +msgid "Videos in Collection" +msgstr "" + +#: apps/video/models.py:99 +msgid "Video" +msgstr "" + +#: apps/video/models.py:100 +msgid "Videos" +msgstr "" + +#: config/settings/base.py:198 +msgid "English" +msgstr "" + +#: config/settings/base.py:199 +msgid "Persian" +msgstr "" + +#: config/settings/base.py:200 +msgid "Russia" +msgstr "" + +#: config/settings/base.py:323 config/settings/base.py:324 +msgid "Imam Jawad Admin" +msgstr "" + +#: config/settings/base.py:325 +msgid "Imam Jawad Online School" +msgstr "" + +#: config/settings/base.py:329 +msgid "Imam Javad Site" +msgstr "" + +#: config/settings/base.py:409 config/settings/base.py:510 +msgid "Users" +msgstr "" + +#: config/settings/base.py:417 +msgid "Guest Users" +msgstr "" + +#: config/settings/base.py:429 +msgid "Groups" +msgstr "" + +#: config/settings/base.py:453 +msgid "Lessons" +msgstr "" + +#: config/settings/base.py:459 +msgid "Attachments" +msgstr "" + +#: config/settings/base.py:465 +msgid "Glossary" +msgstr "" + +#: config/settings/base.py:471 +msgid "Quizzes" +msgstr "" + +#: config/settings/base.py:489 templates/admin/index.html:8 utils/admin.py:105 +msgid "Dashboard" +msgstr "" + +#: config/settings/base.py:499 +msgid "Authentication" +msgstr "" + +#: config/settings/base.py:521 +msgid "Students" +msgstr "" + +#: config/settings/base.py:533 +msgid "Professors" +msgstr "" + +#: config/settings/base.py:557 +msgid "Certificates" +msgstr "" + +#: config/settings/base.py:579 config/settings/base.py:584 +msgid "Transactions" +msgstr "" + +#: templates/admin/auth/user/change_password.html:10 +msgid "Home" +msgstr "" + +#: templates/admin/auth/user/change_password.html:14 +#: templates/admin/auth/user/change_password.html:52 +msgid "Change password" +msgstr "" + +#: templates/admin/auth/user/change_password.html:25 +msgid "Please correct the error below." +msgstr "" + +#: templates/admin/auth/user/change_password.html:25 +msgid "Please correct the errors below." +msgstr "" + +#: templates/admin/auth/user/change_password.html:29 +#, python-format +msgid "Enter a new password for the user %(username)s." +msgstr "" + +#: templates/admin/base_site.html:3 templates/admin/index.html:8 +msgid "Django site admin" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:4 +#: templates/admin/filer/folder/directory_table.html:160 +msgid "Unsorted Uploads" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:13 +#: utils/keyval_field.py:118 +msgid "Name" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:14 +msgid "Owner" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:15 +msgid "Size" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:16 +msgid "Action" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:25 +#: templates/admin/filer/folder/directory_table.html:32 +#: templates/admin/filer/folder/directory_table.html:55 +#: templates/admin/filer/folder/directory_table.html:62 +#, python-format +msgid "Change '%(item_label)s' folder details" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:26 +#: templates/admin/filer/folder/directory_table.html:56 +#: templates/admin/filer/folder/directory_table.html:167 +msgid "Folder Icon" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:73 +#, python-format +msgid "%(counter)s folder" +msgid_plural "%(counter)s folders" +msgstr[0] "" +msgstr[1] "" + +#: templates/admin/filer/folder/directory_table.html:74 +#, python-format +msgid "%(counter)s file" +msgid_plural "%(counter)s files" +msgstr[0] "" +msgstr[1] "" + +#: templates/admin/filer/folder/directory_table.html:80 +msgid "Change folder details" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:81 +msgid "Remove folder" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:93 +#: templates/admin/filer/folder/directory_table.html:102 +#: templates/admin/filer/folder/directory_table.html:117 +msgid "Select this file" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:105 +#: templates/admin/filer/folder/directory_table.html:120 +#: templates/admin/filer/folder/directory_table.html:146 +#, python-format +msgid "Change '%(item_label)s' details" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:129 +msgid "disabled" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:129 +msgid "enabled" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:142 +#, python-format +msgid "Canonical url '%(item_label)s'" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:144 +#, python-format +msgid "Download '%(item_label)s'" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:147 +msgid "Remove file" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:154 +msgid "Drop files here or use the \"Upload Files\" button" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:164 +msgid "Drop your file to upload into:" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:174 +msgid "Upload" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:186 +msgid "cancel" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:190 +msgid "Upload success!" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:194 +msgid "Upload canceled!" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:201 +msgid "previous" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:206 +#, python-format +msgid "Page %(number)s of %(num_pages)s." +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:211 +msgid "next" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:221 +msgid "Click here to select the objects across all pages" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:221 +#, python-format +msgid "Select all %(total_count)s" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:223 +msgid "Clear selection" +msgstr "" + +#: templates/admin/includes/object_delete_summary.html:2 +msgid "Summary" +msgstr "" + +#: templates/docs.html:4 +msgid "Django site adminssss" +msgstr "" + +#: utils/__init__.py:75 +msgid "Development" +msgstr "" + +#: utils/__init__.py:77 +msgid "Production" +msgstr "" + +#: utils/admin.py:106 +msgid "Analytics" +msgstr "" + +#: utils/admin.py:107 +msgid "Settings" +msgstr "" + +#: utils/admin.py:110 +msgid "All" +msgstr "" + +#: utils/admin.py:112 +msgid "New" +msgstr "" + +#: utils/admin.py:222 +msgid "Last week revenue" +msgstr "" + +#: utils/admin.py:240 +msgid "Last week expenses" +msgstr "" + +#: utils/keyval_field.py:16 utils/keyval_field.py:37 utils/keyval_field.py:74 +#: utils/keyval_field.py:94 utils/keyval_field.py:116 utils/keyval_field.py:137 +msgid "Translation" +msgstr "" + +#: utils/keyval_field.py:18 utils/keyval_field.py:96 +msgid "Detail" +msgstr "" + +#: utils/keyval_field.py:23 utils/keyval_field.py:44 utils/keyval_field.py:81 +#: utils/keyval_field.py:101 utils/keyval_field.py:123 +#: utils/keyval_field.py:144 +msgid "Language Code" +msgstr "" + +#: utils/keyval_field.py:57 +msgid "Tour Features" +msgstr "" + +#: utils/keyval_field.py:162 utils/keyval_field.py:172 +msgid "Description" +msgstr "" diff --git a/locale/ru/LC_MESSAGES/django.po b/locale/ru/LC_MESSAGES/django.po new file mode 100644 index 0000000..abab263 --- /dev/null +++ b/locale/ru/LC_MESSAGES/django.po @@ -0,0 +1,1307 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#: config/settings/base.py:485 config/settings/base.py:496 +#: config/settings/base.py:507 config/settings/base.py:518 +#: config/settings/base.py:530 +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-04-04 06:07+0330\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || " +"(n%100>=11 && n%100<=14)? 2 : 3);\n" + +#: apps/account/admin/professor.py:59 apps/account/admin/student.py:46 +msgid "Personal info" +msgstr "" + +#: apps/account/admin/professor.py:60 apps/account/admin/student.py:47 +#: apps/account/admin/user.py:72 apps/account/admin/user.py:99 +#: apps/account/admin/user.py:288 apps/account/admin/user.py:291 +#: templates/admin/filer/folder/directory_table.html:129 +msgid "Permissions" +msgstr "" + +#: apps/account/admin/professor.py:63 apps/account/admin/student.py:50 +#: apps/account/admin/user.py:105 +msgid "Important dates" +msgstr "" + +#: apps/account/admin/user.py:64 apps/account/admin/user.py:166 +msgid "Location" +msgstr "" + +#: apps/account/admin/user.py:68 +msgid "Password" +msgstr "" + +#: apps/account/admin/user.py:80 +msgid "Basic Information" +msgstr "" + +#: apps/account/admin/user.py:87 +msgid "Country & City" +msgstr "" + +#: apps/account/admin/user.py:93 +msgid "Device Information" +msgstr "" + +#: apps/account/admin/user.py:122 apps/account/admin/user.py:147 +msgid "Date Joined" +msgstr "" + +#: apps/account/admin/user.py:126 +msgid "Last Login" +msgstr "" + +#: apps/account/admin/user.py:170 +msgid "password" +msgstr "" + +#: apps/account/admin/user.py:177 apps/course/admin/course.py:159 +msgid "Student" +msgstr "" + +#: apps/account/admin/user.py:197 +msgid "Age" +msgstr "" + +#: apps/account/admin/user.py:217 apps/account/admin/user.py:349 +#: apps/course/admin/course.py:79 config/settings/base.py:447 +#: config/settings/base.py:542 config/settings/base.py:547 +msgid "Courses" +msgstr "" + +#: apps/account/admin/user.py:298 apps/course/admin/course.py:45 +msgid "Course Categories" +msgstr "" + +#: apps/account/admin/user.py:317 apps/course/admin/course.py:64 +msgid "Edit" +msgstr "" + +#: apps/account/admin/user.py:329 apps/course/admin/course.py:312 +msgid "Professor" +msgstr "" + +#: apps/account/models/notification.py:10 apps/hadis/models/hadis.py:10 +#: apps/hadis/models/hadis.py:21 apps/hadis/models/hadis.py:49 +#: apps/podcast/models.py:6 apps/quiz/models/quiz.py:9 apps/video/models.py:7 +msgid "title" +msgstr "" + +#: apps/account/models/notification.py:11 +msgid "message" +msgstr "" + +#: apps/account/models/notification.py:12 +msgid "user" +msgstr "" + +#: apps/account/models/notification.py:13 +msgid "is read" +msgstr "" + +#: apps/account/models/notification.py:18 +msgid "service" +msgstr "" + +#: apps/account/models/notification.py:20 apps/hadis/models/hadis.py:28 +#: apps/hadis/models/hadis.py:54 apps/hadis/models/hadis.py:66 +#: apps/hadis/models/transmitter.py:44 apps/library/models.py:70 +#: apps/library/models.py:114 apps/podcast/models.py:11 +#: apps/podcast/models.py:26 apps/podcast/models.py:78 apps/video/models.py:12 +#: apps/video/models.py:27 apps/video/models.py:85 +msgid "created at" +msgstr "" + +#: apps/account/models/notification.py:21 apps/hadis/models/hadis.py:29 +#: apps/library/models.py:71 apps/library/models.py:115 +#: apps/podcast/models.py:12 apps/podcast/models.py:27 +#: apps/podcast/models.py:79 apps/video/models.py:13 apps/video/models.py:28 +#: apps/video/models.py:86 +msgid "updated at" +msgstr "" + +#: apps/account/models/user.py:34 apps/transaction/models.py:43 +msgid "birthdate" +msgstr "" + +#: apps/account/models/user.py:41 +msgid "Phone Number" +msgstr "" + +#: apps/account/models/user.py:46 apps/transaction/models.py:41 +msgid "Gender" +msgstr "" + +#: apps/account/models/user.py:50 +msgid "City" +msgstr "" + +#: apps/account/models/user.py:51 apps/account/models/user.py:121 +msgid "country" +msgstr "" + +#: apps/account/models/user.py:53 +msgid "device id" +msgstr "" + +#: apps/account/models/user.py:119 +msgid "lat" +msgstr "" + +#: apps/account/models/user.py:120 +msgid "lon" +msgstr "" + +#: apps/account/models/user.py:122 +msgid "city" +msgstr "" + +#: apps/account/templates/account/group_help_text.html:5 +msgid "Driver before template" +msgstr "" + +#: apps/account/templates/account/group_help_text.html:11 +msgid "Active drivers" +msgstr "" + +#: apps/account/templates/account/group_help_text.html:19 +msgid "Inactive drivers" +msgstr "" + +#: apps/account/templates/account/group_help_text.html:27 +msgid "Total points" +msgstr "" + +#: apps/account/templates/account/group_help_text.html:35 +msgid "Total races" +msgstr "" + +#: apps/account/templates/account/user_list_section.html:8 +msgid "Total Actice Users" +msgstr "" + +#: apps/account/templates/account/user_list_section.html:16 +msgid "Total Guest Users" +msgstr "" + +#: apps/account/templates/account/user_list_section.html:22 +msgid "Total Students" +msgstr "" + +#: apps/account/templates/account/user_list_section.html:28 +msgid "Total Professors" +msgstr "" + +#: apps/api/admin.py:27 +msgid "Dimensions" +msgstr "" + +#: apps/api/admin.py:31 +msgid "Preview" +msgstr "" + +#: apps/certificate/admin.py:23 apps/transaction/admin.py:37 +msgid "Timestamps" +msgstr "" + +#: apps/certificate/admin.py:29 apps/course/admin/course.py:273 +#: apps/library/admin.py:20 apps/video/admin.py:78 +msgid "Status" +msgstr "" + +#: apps/certificate/models.py:12 +msgid "pending" +msgstr "" + +#: apps/certificate/models.py:13 +msgid "approved" +msgstr "" + +#: apps/certificate/models.py:14 +msgid "canceled" +msgstr "" + +#: apps/certificate/models.py:20 +msgid "certificate_file" +msgstr "" + +#: apps/course/admin/course.py:97 +msgid "Course Weekly Schedule" +msgstr "" + +#: apps/course/admin/course.py:101 utils/schema.py:47 +msgid "Course Features" +msgstr "" + +#: apps/course/admin/course.py:154 +msgid "Enrollment Details" +msgstr "" + +#: apps/course/admin/course.py:179 apps/course/admin/course.py:293 +msgid "Course" +msgstr "" + +#: apps/course/admin/course.py:191 +msgid "Participant" +msgstr "" + +#: apps/course/admin/course.py:192 +msgid "Participants" +msgstr "" + +#: apps/course/admin/course.py:222 +msgid "Select Student" +msgstr "" + +#: apps/course/admin/course.py:276 +msgid "Course Details" +msgstr "" + +#: apps/course/admin/course.py:280 +msgid "Media" +msgstr "" + +#: apps/course/admin/course.py:283 +msgid "Pricing" +msgstr "" + +#: apps/course/admin/course.py:286 +msgid "Timing & Features" +msgstr "" + +#: apps/course/admin/course.py:301 +msgid "No description" +msgstr "" + +#: apps/course/admin/course.py:316 apps/transaction/admin.py:49 +msgid "Price" +msgstr "" + +#: apps/course/admin/course.py:319 +msgid "Free" +msgstr "" + +#: apps/course/admin/course.py:340 +msgid "View Lessons" +msgstr "" + +#: apps/course/admin/course.py:351 apps/course/admin/course.py:386 +msgid "Course not found" +msgstr "" + +#: apps/course/admin/course.py:376 +msgid "Add Student to Course" +msgstr "" + +#: apps/course/admin/course.py:396 +#, python-brace-format +msgid "Student {student.fullname} is already enrolled in this course" +msgstr "" + +#: apps/course/admin/course.py:405 +#, python-brace-format +msgid "" +"Student {student.fullname} has been successfully added to {course.title}" +msgstr "" + +#: apps/course/admin/course.py:418 +msgid "Change detail action for {}" +msgstr "" + +#: apps/course/admin/lesson.py:59 apps/hadis/admin/hadis.py:148 +msgid "Content" +msgstr "" + +#: apps/course/admin/lesson.py:82 +msgid "Duration" +msgstr "" + +#: apps/course/models/course.py:69 +msgid "Thumbnail" +msgstr "" + +#: apps/course/models/course.py:95 +msgid "Course Final Price" +msgstr "" + +#: apps/course/models/course.py:96 +msgid "" +"This field is automatically calculated based on the discount percentage." +msgstr "" + +#: apps/course/models/course.py:99 +msgid "Timing" +msgstr "" + +#: apps/course/models/course.py:100 +msgid "Course features" +msgstr "" + +#: apps/course/models/course.py:101 apps/course/models/lesson.py:35 +#: apps/course/models/lesson.py:98 apps/transaction/models.py:20 +msgid "Created at" +msgstr "" + +#: apps/course/models/course.py:102 apps/course/models/lesson.py:36 +msgid "Updated At" +msgstr "" + +#: apps/course/models/lesson.py:25 +msgid "Is Active" +msgstr "" + +#: apps/course/templates/course/add_student_form.html:25 +msgid "Submit form" +msgstr "" + +#: apps/hadis/admin/category.py:38 apps/hadis/models/category.py:21 +msgid "Source Type" +msgstr "" + +#: apps/hadis/admin/category.py:69 +msgid "This item can not be modified" +msgstr "" + +#: apps/hadis/admin/category.py:198 +msgid "Category saved successfully. Tree will be reloaded." +msgstr "" + +#: apps/hadis/admin/hadis.py:17 +msgid "Red" +msgstr "" + +#: apps/hadis/admin/hadis.py:18 +msgid "Blue" +msgstr "" + +#: apps/hadis/admin/hadis.py:19 +msgid "Green" +msgstr "" + +#: apps/hadis/admin/hadis.py:20 +msgid "Yellow" +msgstr "" + +#: apps/hadis/admin/hadis.py:21 +msgid "Orange" +msgstr "" + +#: apps/hadis/admin/hadis.py:22 +msgid "Purple" +msgstr "" + +#: apps/hadis/admin/hadis.py:23 +msgid "Pink" +msgstr "" + +#: apps/hadis/admin/hadis.py:24 +msgid "Brown" +msgstr "" + +#: apps/hadis/admin/hadis.py:25 +msgid "Gray" +msgstr "" + +#: apps/hadis/admin/hadis.py:26 +msgid "Black" +msgstr "" + +#: apps/hadis/admin/hadis.py:41 +msgid "Link" +msgstr "" + +#: apps/hadis/admin/hadis.py:43 apps/hadis/models/hadis.py:22 +msgid "text" +msgstr "" + +#: apps/hadis/admin/hadis.py:44 +msgid "link" +msgstr "" + +#: apps/hadis/admin/hadis.py:76 apps/hadis/models/hadis.py:91 +msgid "Reference Images" +msgstr "" + +#: apps/hadis/admin/hadis.py:109 +msgid "Reference Information" +msgstr "" + +#: apps/hadis/admin/hadis.py:112 +msgid "Additional Information" +msgstr "" + +#: apps/hadis/admin/hadis.py:125 +msgid "Hadis Overview" +msgstr "" + +#: apps/hadis/models/category.py:9 +#: apps/hadis/templates/admin/category_index.html:25 +msgid "Shia" +msgstr "" + +#: apps/hadis/models/category.py:10 +#: apps/hadis/templates/admin/category_index.html:31 +msgid "Sunni" +msgstr "" + +#: apps/hadis/models/category.py:13 +#: apps/hadis/templates/admin/category_index.html:158 +msgid "Quran" +msgstr "" + +#: apps/hadis/models/category.py:14 +#: apps/hadis/templates/admin/category_index.html:161 +msgid "Hadith" +msgstr "" + +#: apps/hadis/models/category.py:17 +msgid "Level 1 (Root)" +msgstr "" + +#: apps/hadis/models/category.py:18 +msgid "Level 2 (Child)" +msgstr "" + +#: apps/hadis/models/category.py:19 +msgid "Level 3 (Grandchild)" +msgstr "" + +#: apps/hadis/models/category.py:22 +msgid "Category Content Type" +msgstr "" + +#: apps/hadis/models/category.py:23 +msgid "name" +msgstr "" + +#: apps/hadis/models/category.py:24 apps/library/models.py:23 +#: apps/podcast/models.py:10 apps/video/models.py:11 +msgid "order" +msgstr "" + +#: apps/hadis/models/category.py:34 +msgid "Hadis Category" +msgstr "" + +#: apps/hadis/models/category.py:35 +msgid "Hadis Categories" +msgstr "" + +#: apps/hadis/models/category.py:54 +msgid "Level 1 cannot have content type" +msgstr "" + +#: apps/hadis/models/category.py:57 +msgid "Level 2 must have content type" +msgstr "" + +#: apps/hadis/models/category.py:60 +msgid "Level 3 cannot have source/content type" +msgstr "" + +#: apps/hadis/models/hadis.py:11 apps/hadis/models/hadis.py:45 +#: apps/hadis/models/transmitter.py:15 apps/library/models.py:22 +#: apps/library/models.py:67 apps/library/models.py:100 +#: apps/podcast/models.py:9 apps/podcast/models.py:76 apps/video/models.py:10 +#: apps/video/models.py:26 apps/video/models.py:83 +msgid "status" +msgstr "" + +#: apps/hadis/models/hadis.py:20 +msgid "number" +msgstr "" + +#: apps/hadis/models/hadis.py:23 +msgid "translation" +msgstr "" + +#: apps/hadis/models/hadis.py:25 +msgid "category" +msgstr "" + +#: apps/hadis/models/hadis.py:27 +msgid "visibility" +msgstr "" + +#: apps/hadis/models/hadis.py:39 apps/hadis/models/hadis.py:61 +#: apps/hadis/models/transmitter.py:29 +msgid "hadis" +msgstr "" + +#: apps/hadis/models/hadis.py:40 +msgid "hadises" +msgstr "" + +#: apps/hadis/models/hadis.py:46 apps/hadis/models/transmitter.py:16 +msgid "Display Status Color" +msgstr "" + +#: apps/hadis/models/hadis.py:47 +msgid "Status Text" +msgstr "" + +#: apps/hadis/models/hadis.py:48 +msgid "address" +msgstr "" + +#: apps/hadis/models/hadis.py:50 +msgid "tags" +msgstr "" + +#: apps/hadis/models/hadis.py:51 +msgid "share link" +msgstr "" + +#: apps/hadis/models/hadis.py:52 +msgid "explanation" +msgstr "" + +#: apps/hadis/models/hadis.py:64 +msgid "book" +msgstr "" + +#: apps/hadis/models/hadis.py:65 apps/hadis/models/transmitter.py:38 +msgid "description" +msgstr "" + +#: apps/hadis/models/hadis.py:69 +msgid "Hadis Reference" +msgstr "" + +#: apps/hadis/models/hadis.py:70 +msgid "Hadis References" +msgstr "" + +#: apps/hadis/models/hadis.py:80 +msgid "thumbnail" +msgstr "" + +#: apps/hadis/models/hadis.py:84 +msgid "Priority" +msgstr "" + +#: apps/hadis/models/hadis.py:85 +msgid "Priority of the image, lower values mean higher priority." +msgstr "" + +#: apps/hadis/models/hadis.py:90 +msgid "Reference Image" +msgstr "" + +#: apps/hadis/models/transmitter.py:18 apps/library/models.py:97 +#: apps/podcast/models.py:66 apps/video/models.py:68 +msgid "image allowed" +msgstr "" + +#: apps/hadis/models/transmitter.py:35 +msgid "transmitter" +msgstr "" + +#: apps/hadis/models/transmitter.py:41 +msgid "Order" +msgstr "" + +#: apps/hadis/models/transmitter.py:42 +msgid "Order in the chain of transmission" +msgstr "" + +#: apps/hadis/models/transmitter.py:47 +msgid "Hadis Transmitter" +msgstr "" + +#: apps/hadis/models/transmitter.py:48 +msgid "Hadis Transmitters" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:11 +msgid "Category Tree Editor" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:15 +msgid "" +"Make your category and sort it by drag and drop . and try to edit items by " +"double click." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:62 +msgid "Parent: " +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:70 +msgid "Category Level:" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:77 +msgid "L1" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:83 +msgid "L2" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:89 +msgid "L3" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:99 +msgid "Level 1 categories represent source types: Shia or Sunni" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:106 +msgid "" +"Level 2 categories are children of Shia/Sunni with content type: Quran or " +"Hadith" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:113 +msgid "Level 3 categories are children of Quran or Hadith categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:127 +msgid "Parent Category:" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:130 +msgid "-- Select Parent Category --" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:134 +msgid "Select a parent category or leave empty for top-level category" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:916 +msgid "Search for a category..." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:925 +msgid "No categories found" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:928 +msgid "Searching..." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:931 +msgid "Type to search..." +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1021 +#: apps/hadis/templates/admin/category_index.html:1190 +msgid "Level 1 Categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1028 +#: apps/hadis/templates/admin/category_index.html:1197 +msgid "Level 2 Categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1035 +#: apps/hadis/templates/admin/category_index.html:1204 +msgid "Level 3 Categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1344 +msgid "Level 1 must have a source type" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1347 +msgid "Level 2 must have a category type" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1423 +#: apps/hadis/templates/admin/category_index.html:1498 +#: apps/hadis/templates/admin/category_index.html:1719 +#: apps/hadis/templates/admin/category_index.html:2119 +msgid "Add Child Category" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:1992 +msgid "No Items Found" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:2206 +#: apps/hadis/templates/admin/category_index.html:2214 +msgid "No Quran categories" +msgstr "" + +#: apps/hadis/templates/admin/category_index.html:2210 +#: apps/hadis/templates/admin/category_index.html:2215 +msgid "No Hadith categories" +msgstr "" + +#: apps/hadis/templates/admin/hadiscategory/change_form.html:28 +msgid "Add Category" +msgstr "" + +#: apps/hadis/templates/admin/hadisowerview_change_form.html:8 +msgid "Save And Edit Next Hadis" +msgstr "" + +#: apps/hadis/templates/admin/hadisowerview_change_form.html:12 +msgid "Save And Edit Previus Hadis" +msgstr "" + +#: apps/hadis/templates/admin/hadisowerview_change_form.html:16 +msgid "Save And Edit Random" +msgstr "" + +#: apps/library/admin.py:23 +msgid "File Information" +msgstr "" + +#: apps/library/admin.py:26 +msgid "Relations" +msgstr "" + +#: apps/library/admin.py:29 apps/video/admin.py:81 +msgid "Statistics" +msgstr "" + +#: apps/library/admin.py:48 apps/library/models.py:24 +#: apps/library/models.py:128 +msgid "Books" +msgstr "" + +#: apps/library/admin.py:57 utils/keyval_field.py:39 utils/keyval_field.py:59 +#: utils/keyval_field.py:76 utils/keyval_field.py:139 utils/keyval_field.py:167 +#: utils/schema.py:49 +msgid "Title" +msgstr "" + +#: apps/library/admin.py:60 apps/library/admin.py:185 +msgid "Number of Books" +msgstr "" + +#: apps/library/apps.py:8 +msgid "Library" +msgstr "" + +#: apps/library/models.py:10 +msgid "Pinned" +msgstr "" + +#: apps/library/models.py:11 +msgid "Middle Section" +msgstr "" + +#: apps/library/models.py:12 +msgid "Bottom Section" +msgstr "" + +#: apps/library/models.py:15 apps/library/models.py:95 +#: apps/library/models.py:96 +msgid "could be null" +msgstr "" + +#: apps/library/models.py:20 +msgid "Display Position" +msgstr "" + +#: apps/library/models.py:30 +msgid "Book Collection" +msgstr "" + +#: apps/library/models.py:31 +msgid "Book Collections" +msgstr "" + +#: apps/library/models.py:40 +msgid "Pinned Book Collection" +msgstr "" + +#: apps/library/models.py:41 +msgid "Pinned Book Collections" +msgstr "" + +#: apps/library/models.py:50 +msgid "Middle Section Book Collection" +msgstr "" + +#: apps/library/models.py:51 +msgid "Middle Section Book Collections" +msgstr "" + +#: apps/library/models.py:60 +msgid "Bottom Section Book Collection" +msgstr "" + +#: apps/library/models.py:61 +msgid "Bottom Section Book Collections" +msgstr "" + +#: apps/library/models.py:82 +msgid "Category" +msgstr "" + +#: apps/library/models.py:83 config/settings/base.py:552 +msgid "Categories" +msgstr "" + +#: apps/library/models.py:99 +msgid "Number of Pages" +msgstr "" + +#: apps/library/models.py:99 +msgid "eg. 34" +msgstr "" + +#: apps/library/models.py:101 +msgid "Pin to top" +msgstr "" + +#: apps/library/models.py:103 apps/podcast/models.py:68 apps/video/models.py:73 +msgid "categories" +msgstr "" + +#: apps/library/models.py:104 +msgid "collections" +msgstr "" + +#: apps/library/models.py:107 apps/library/models.py:108 +#: apps/podcast/models.py:73 apps/podcast/models.py:74 apps/video/models.py:81 +msgid "view count" +msgstr "" + +#: apps/library/models.py:111 +msgid "File Type" +msgstr "" + +#: apps/library/models.py:127 +msgid "Book" +msgstr "" + +#: apps/podcast/models.py:7 apps/video/models.py:8 +msgid "slug" +msgstr "" + +#: apps/podcast/models.py:18 apps/video/models.py:19 +msgid "Video Category" +msgstr "" + +#: apps/podcast/models.py:19 apps/video/models.py:20 +msgid "Video Categories" +msgstr "" + +#: apps/podcast/models.py:32 apps/podcast/models.py:48 +msgid "podcasts" +msgstr "" + +#: apps/podcast/models.py:39 +msgid "Podcast Collection" +msgstr "" + +#: apps/podcast/models.py:40 +msgid "Podcasts Collections" +msgstr "" + +#: apps/podcast/models.py:45 +msgid "podcast collection" +msgstr "" + +#: apps/podcast/models.py:50 apps/video/models.py:50 +msgid "priority" +msgstr "" + +#: apps/podcast/models.py:56 +msgid "Podcast in Collection" +msgstr "" + +#: apps/podcast/models.py:57 +msgid "Podcasts in Collection" +msgstr "" + +#: apps/podcast/models.py:85 +msgid "Podcast" +msgstr "" + +#: apps/podcast/models.py:86 +msgid "Podcasts" +msgstr "" + +#: apps/quiz/admin/participant.py:35 +msgid "User Email" +msgstr "" + +#: apps/quiz/models/quiz.py:8 +msgid "lesson" +msgstr "" + +#: apps/transaction/admin.py:43 +msgid "Payment Status" +msgstr "" + +#: apps/transaction/models.py:21 +msgid "Updated at" +msgstr "" + +#: apps/transaction/models.py:39 +msgid "phone" +msgstr "" + +#: apps/video/admin.py:31 apps/video/admin.py:55 +msgid "Number of Videos" +msgstr "" + +#: apps/video/admin.py:75 +msgid "Video Information" +msgstr "" + +#: apps/video/models.py:33 +msgid "videos" +msgstr "" + +#: apps/video/models.py:39 +msgid "Video Collection" +msgstr "" + +#: apps/video/models.py:40 +msgid "Video Collections" +msgstr "" + +#: apps/video/models.py:45 +msgid "video collection" +msgstr "" + +#: apps/video/models.py:48 +msgid "video" +msgstr "" + +#: apps/video/models.py:56 +msgid "Video in Collection" +msgstr "" + +#: apps/video/models.py:57 +msgid "Videos in Collection" +msgstr "" + +#: apps/video/models.py:99 +msgid "Video" +msgstr "" + +#: apps/video/models.py:100 +msgid "Videos" +msgstr "" + +#: config/settings/base.py:198 +msgid "English" +msgstr "" + +#: config/settings/base.py:199 +msgid "Persian" +msgstr "" + +#: config/settings/base.py:200 +msgid "Russia" +msgstr "" + +#: config/settings/base.py:323 config/settings/base.py:324 +msgid "Imam Jawad Admin" +msgstr "" + +#: config/settings/base.py:325 +msgid "Imam Jawad Online School" +msgstr "" + +#: config/settings/base.py:329 +msgid "Imam Javad Site" +msgstr "" + +#: config/settings/base.py:409 config/settings/base.py:510 +msgid "Users" +msgstr "" + +#: config/settings/base.py:417 +msgid "Guest Users" +msgstr "" + +#: config/settings/base.py:429 +msgid "Groups" +msgstr "" + +#: config/settings/base.py:453 +msgid "Lessons" +msgstr "" + +#: config/settings/base.py:459 +msgid "Attachments" +msgstr "" + +#: config/settings/base.py:465 +msgid "Glossary" +msgstr "" + +#: config/settings/base.py:471 +msgid "Quizzes" +msgstr "" + +#: config/settings/base.py:489 templates/admin/index.html:8 utils/admin.py:105 +msgid "Dashboard" +msgstr "" + +#: config/settings/base.py:499 +msgid "Authentication" +msgstr "" + +#: config/settings/base.py:521 +msgid "Students" +msgstr "" + +#: config/settings/base.py:533 +msgid "Professors" +msgstr "" + +#: config/settings/base.py:557 +msgid "Certificates" +msgstr "" + +#: config/settings/base.py:579 config/settings/base.py:584 +msgid "Transactions" +msgstr "" + +#: templates/admin/auth/user/change_password.html:10 +msgid "Home" +msgstr "" + +#: templates/admin/auth/user/change_password.html:14 +#: templates/admin/auth/user/change_password.html:52 +msgid "Change password" +msgstr "" + +#: templates/admin/auth/user/change_password.html:25 +msgid "Please correct the error below." +msgstr "" + +#: templates/admin/auth/user/change_password.html:25 +msgid "Please correct the errors below." +msgstr "" + +#: templates/admin/auth/user/change_password.html:29 +#, python-format +msgid "Enter a new password for the user %(username)s." +msgstr "" + +#: templates/admin/base_site.html:3 templates/admin/index.html:8 +msgid "Django site admin" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:4 +#: templates/admin/filer/folder/directory_table.html:160 +msgid "Unsorted Uploads" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:13 +#: utils/keyval_field.py:118 +msgid "Name" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:14 +msgid "Owner" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:15 +msgid "Size" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:16 +msgid "Action" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:25 +#: templates/admin/filer/folder/directory_table.html:32 +#: templates/admin/filer/folder/directory_table.html:55 +#: templates/admin/filer/folder/directory_table.html:62 +#, python-format +msgid "Change '%(item_label)s' folder details" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:26 +#: templates/admin/filer/folder/directory_table.html:56 +#: templates/admin/filer/folder/directory_table.html:167 +msgid "Folder Icon" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:73 +#, python-format +msgid "%(counter)s folder" +msgid_plural "%(counter)s folders" +msgstr[0] "" +msgstr[1] "" + +#: templates/admin/filer/folder/directory_table.html:74 +#, python-format +msgid "%(counter)s file" +msgid_plural "%(counter)s files" +msgstr[0] "" +msgstr[1] "" + +#: templates/admin/filer/folder/directory_table.html:80 +msgid "Change folder details" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:81 +msgid "Remove folder" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:93 +#: templates/admin/filer/folder/directory_table.html:102 +#: templates/admin/filer/folder/directory_table.html:117 +msgid "Select this file" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:105 +#: templates/admin/filer/folder/directory_table.html:120 +#: templates/admin/filer/folder/directory_table.html:146 +#, python-format +msgid "Change '%(item_label)s' details" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:129 +msgid "disabled" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:129 +msgid "enabled" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:142 +#, python-format +msgid "Canonical url '%(item_label)s'" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:144 +#, python-format +msgid "Download '%(item_label)s'" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:147 +msgid "Remove file" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:154 +msgid "Drop files here or use the \"Upload Files\" button" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:164 +msgid "Drop your file to upload into:" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:174 +msgid "Upload" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:186 +msgid "cancel" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:190 +msgid "Upload success!" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:194 +msgid "Upload canceled!" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:201 +msgid "previous" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:206 +#, python-format +msgid "Page %(number)s of %(num_pages)s." +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:211 +msgid "next" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:221 +msgid "Click here to select the objects across all pages" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:221 +#, python-format +msgid "Select all %(total_count)s" +msgstr "" + +#: templates/admin/filer/folder/directory_table.html:223 +msgid "Clear selection" +msgstr "" + +#: templates/admin/includes/object_delete_summary.html:2 +msgid "Summary" +msgstr "" + +#: templates/docs.html:4 +msgid "Django site adminssss" +msgstr "" + +#: utils/__init__.py:75 +msgid "Development" +msgstr "" + +#: utils/__init__.py:77 +msgid "Production" +msgstr "" + +#: utils/admin.py:106 +msgid "Analytics" +msgstr "" + +#: utils/admin.py:107 +msgid "Settings" +msgstr "" + +#: utils/admin.py:110 +msgid "All" +msgstr "" + +#: utils/admin.py:112 +msgid "New" +msgstr "" + +#: utils/admin.py:222 +msgid "Last week revenue" +msgstr "" + +#: utils/admin.py:240 +msgid "Last week expenses" +msgstr "" + +#: utils/keyval_field.py:16 utils/keyval_field.py:37 utils/keyval_field.py:74 +#: utils/keyval_field.py:94 utils/keyval_field.py:116 utils/keyval_field.py:137 +msgid "Translation" +msgstr "" + +#: utils/keyval_field.py:18 utils/keyval_field.py:96 +msgid "Detail" +msgstr "" + +#: utils/keyval_field.py:23 utils/keyval_field.py:44 utils/keyval_field.py:81 +#: utils/keyval_field.py:101 utils/keyval_field.py:123 +#: utils/keyval_field.py:144 +msgid "Language Code" +msgstr "" + +#: utils/keyval_field.py:57 +msgid "Tour Features" +msgstr "" + +#: utils/keyval_field.py:162 utils/keyval_field.py:172 +msgid "Description" +msgstr "" diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..6361598 --- /dev/null +++ b/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.base') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..4e8da2e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,959 @@ +{ + "name": "backend2", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "tailwindcss": "^3.3.1" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.9" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", + "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", + "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.19", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.19.tgz", + "integrity": "sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.9", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.9.tgz", + "integrity": "sha512-t8Sg3DyynFysV9f4JDOVISGsjazNb48AeIYQwcL+Bsq5uf4RYL75C1giZ43KISjeDGBaTN3Kxh7Xj/vRSMJUUg==", + "dev": true, + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "engines": { + "node": ">=8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", + "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + }, + "node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/has": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", + "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", + "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.19.3", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.19.3.tgz", + "integrity": "sha512-5eEbBDQT/jF1xg6l36P+mWGGoH9Spuy0PCdSr2dtWRDGC6ph/w9ZCL4lmESW8f8F7MwT3XKescfP0wnZWAKL9w==", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.1.tgz", + "integrity": "sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^2.1.1" + }, + "engines": { + "node": ">= 14" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.0.1.tgz", + "integrity": "sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==", + "dependencies": { + "postcss-selector-parser": "^6.0.11" + }, + "engines": { + "node": ">=12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-nested/node_modules/postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.4", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz", + "integrity": "sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.34.0.tgz", + "integrity": "sha512-70/LQEZ07TEcxiU2dz51FKaE6hCTWC6vr7FOk3Gr0U60C3shtAN+H+BFr9XlYe5xqf3RA8nrc+VIwzCfnxuXJw==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "7.1.6", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.3.3.tgz", + "integrity": "sha512-A0KgSkef7eE4Mf+nKJ83i75TMyq8HqY3qmFIJSWy8bNt0v1lG7jUcpGpoTFxAwYcWOphcTBLPPJg+bDfhDf52w==", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.5.3", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.2.12", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.18.2", + "lilconfig": "^2.1.0", + "micromatch": "^4.0.5", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.0.0", + "postcss": "^8.4.23", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.1", + "postcss-nested": "^6.0.1", + "postcss-selector-parser": "^6.0.11", + "resolve": "^1.22.2", + "sucrase": "^3.32.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss/node_modules/postcss-selector-parser": { + "version": "6.0.13", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.13.tgz", + "integrity": "sha512-EaV1Gl4mUEV4ddhDnv/xtj7sxwrwxdetHdWUGnT4VJQf+4d05v6lHYZr8N573k5Z0BViss7BDhfWtKS3+sfAqQ==", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/yaml": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", + "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", + "engines": { + "node": ">= 14" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4233a28 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "scripts": { + "tailwind:build": "tailwindcss -i utils/styles.css -o static/css/styles.css --minify", + "tailwind:watch": "tailwindcss -i utils/styles.css -o static/css/styles.css --watch --minify" + }, + "dependencies": { + "tailwindcss": "^3.3.1" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.9" + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5e173f6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,126 @@ +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 +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 +lxml==5.3.1 +markdown-it-py==3.0.0 +MarkupSafe==3.0.2 +mdurl==0.1.2 +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 + +https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-limitless-dashboard.git/archive/master.zip +https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/ajax-datatable.git/archive/master.zip +https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-seo.git/archive/master.zip +https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-language.git/archive/master.zip +https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-category.git/archive/master.zip +https://yaghoubi:e07059e0ac6be3b0032ded5f65f03363fbd3811f@git.habibapp.com/NewHorizon/django-filer.git/archive/master.zip diff --git a/runner.sh b/runner.sh new file mode 100644 index 0000000..73206b5 --- /dev/null +++ b/runner.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +# Check if the '--dev' argument is provided +if [ "$1" == "--dev" ]; then + echo "Run development docker" + DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 docker compose -f docker-compose.yml up -d --build +else + echo "Run Production docker" + + git pull origin master + DOCKER_BUILDKIT=1 COMPOSE_DOCKER_CLI_BUILD=1 docker compose -f docker-compose.prod.yml up -d --build +fi \ No newline at end of file diff --git a/sshs b/sshs new file mode 100644 index 0000000..dc0a152 --- /dev/null +++ b/sshs @@ -0,0 +1,4 @@ +Host 88.99.212.243 + HostName 88.99.212.243 + User nwhco + Port 1782 diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..e971187 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,53 @@ +module.exports = { + content: ["./**/*.{html,py,js}"], + media: false, + darkMode: "class", + theme: { + extend: { + colors: { + primary: { + 50: "rgb(var(--color-primary-100) / )", + 100: "rgb(var(--color-primary-100) / )", + 200: "rgb(var(--color-primary-200) / )", + 300: "rgb(var(--color-primary-300) / )", + 400: "rgb(var(--color-primary-400) / )", + 500: "rgb(var(--color-primary-500) / )", + 600: "rgb(var(--color-primary-600) / )", + 700: "rgb(var(--color-primary-700) / )", + 800: "rgb(var(--color-primary-800) / )", + 900: "rgb(var(--color-primary-900) / )", + }, + }, + fontSize: { + 0: [0, 1], + xxs: ["11px", "14px"], + }, + fontFamily: { + sans: ["Inter", "sans-serif"], + }, + minWidth: { + sidebar: "18rem", + }, + spacing: { + 68: "17rem", + 128: "32rem", + }, + transitionProperty: { + height: "height", + width: "width", + }, + width: { + sidebar: "18rem", + }, + }, + }, + variants: { + extend: { + borderColor: ["checked", "focus-within", "hover"], + display: ["group-hover"], + overflow: ["hover"], + textColor: ["hover"], + }, + }, + plugins: [require("@tailwindcss/typography")], +}; diff --git a/templates/admin/auth/user/add_form.html b/templates/admin/auth/user/add_form.html new file mode 100644 index 0000000..df9b72f --- /dev/null +++ b/templates/admin/auth/user/add_form.html @@ -0,0 +1,10 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} + +{% block form_top %} + {% if not is_popup %} + {% comment %}

{% translate 'First, enter a username and password. Then, you’ll be able to edit more user options.' %}

{% endcomment %} + {% else %} + {% comment %}

{% translate "You can add the details" %}

{% endcomment %} + {% endif %} +{% endblock %} diff --git a/templates/admin/auth/user/change_password.html b/templates/admin/auth/user/change_password.html new file mode 100644 index 0000000..c107161 --- /dev/null +++ b/templates/admin/auth/user/change_password.html @@ -0,0 +1,57 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} +{% load admin_urls %} + +{% block extrastyle %}{{ block.super }}{% endblock %} +{% block bodyclass %}{{ block.super }} {{ opts.app_label }}-{{ opts.model_name }} change-form{% endblock %} +{% if not is_popup %} +{% block breadcrumbs %} + +{% endblock %} +{% endif %} +{% block content %}
+{% csrf_token %}{% block form_top %}{% endblock %} + +
+{% if is_popup %}{% endif %} +{% if form.errors %} +

+ {% if form.errors.items|length == 1 %}{% translate "Please correct the error below." %}{% else %}{% translate "Please correct the errors below." %}{% endif %} +

+{% endif %} + +

{% blocktranslate with username=original %}Enter a new password for the user {{ username }}.{% endblocktranslate %}

+ +
+ +
+ {{ form.password1.errors }} + {{ form.password1.label_tag }} {{ form.password1 }} + {% if form.password1.help_text %} +
{{ form.password1.help_text|safe }}
+ {% endif %} +
+ +
+ {{ form.password2.errors }} + {{ form.password2.label_tag }} {{ form.password2 }} + {% if form.password2.help_text %} +
{{ form.password2.help_text|safe }}
+ {% endif %} +
+ +
+ +
+ +
+ +
+
+{% endblock %} diff --git a/templates/admin/base_site.html b/templates/admin/base_site.html new file mode 100644 index 0000000..83e0b87 --- /dev/null +++ b/templates/admin/base_site.html @@ -0,0 +1,13 @@ +{% extends "admin/base.html" %} + +{% block title %}{% if subtitle %}{{ subtitle }} | {% endif %}{{ title }} | {{ site_title|default:_('Django site admin') }}{% endblock %} + +{% block branding %} + {% include "unfold/helpers/site_branding.html" %} +{% endblock %} + +{% block extrahead %} + {% if plausible_domain %} + + {% endif %} +{% endblock %} diff --git a/templates/admin/filer/folder/directory_table.html b/templates/admin/filer/folder/directory_table.html new file mode 100644 index 0000000..338bd17 --- /dev/null +++ b/templates/admin/filer/folder/directory_table.html @@ -0,0 +1,228 @@ +{% load i18n l10n admin_list filer_tags filer_admin_tags static %} +
+ diff --git a/templates/admin/helpers/kpi_progress.html b/templates/admin/helpers/kpi_progress.html new file mode 100644 index 0000000..871658c --- /dev/null +++ b/templates/admin/helpers/kpi_progress.html @@ -0,0 +1,6 @@ +{% load humanize %} + + +
+ {{ total|intcomma }} +
diff --git a/templates/admin/includes/object_delete_summary.html b/templates/admin/includes/object_delete_summary.html new file mode 100644 index 0000000..9ad97db --- /dev/null +++ b/templates/admin/includes/object_delete_summary.html @@ -0,0 +1,7 @@ +{% load i18n %} +

{% translate "Summary" %}

+
    + {% for model_name, object_count in model_count %} +
  • {{ model_name|capfirst }}: {{ object_count }}
  • + {% endfor %} +
diff --git a/templates/admin/index.html b/templates/admin/index.html new file mode 100644 index 0000000..c2f6d1a --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,24 @@ +{% extends 'admin/base.html' %} + +{% load i18n unfold %} + +{% block breadcrumbs %}{% endblock %} + +{% block title %} + {% trans 'Dashboard' %} | {{ site_title|default:_('Django site admin') }} +{% endblock %} + +{% block extrahead %} + {% if plausible_domain %} + + {% endif %} +{% endblock %} + +{% block branding %} + {% include "unfold/helpers/site_branding.html" %} +{% endblock %} + +{% block content %} + {% include "unfold/helpers/messages.html" %} +{% endblock %} + diff --git a/templates/docs.html b/templates/docs.html new file mode 100644 index 0000000..c5f9142 --- /dev/null +++ b/templates/docs.html @@ -0,0 +1,69 @@ +{% extends 'admin/base_site.html' %} + +{% block title %} + {{ title }} | {{ site_title|default:_('Django site adminssss') }} +{% endblock %} + +{% block contentwrap %} +
+
+
+
+
+ آموزش ساختار کلی ادمین +
+
+ +
+
+
+
+
+
+ آموزش بخش تقویم +
+
+ +
+
+
+ +
+
+
+ آموزش بخش مفاتیح +
+
+ +
+
+
+
+
+
+ آموزش بخش احکام +
+
+ +
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/fields/json_editor_field.html b/templates/fields/json_editor_field.html new file mode 100644 index 0000000..cd18395 --- /dev/null +++ b/templates/fields/json_editor_field.html @@ -0,0 +1,38 @@ +{% load i18n %} + +
+ + + + diff --git a/templates/fields/jsonlanguage_field.html b/templates/fields/jsonlanguage_field.html new file mode 100644 index 0000000..cd18395 --- /dev/null +++ b/templates/fields/jsonlanguage_field.html @@ -0,0 +1,38 @@ +{% load i18n %} + +
+ + + + diff --git a/templates/name_finder.html b/templates/name_finder.html new file mode 100644 index 0000000..02fd663 --- /dev/null +++ b/templates/name_finder.html @@ -0,0 +1,93 @@ + + + + + + Search Names + + + + + + + + +
+
+
+

Search Names

+ + +
+
loading...
+
+
+ By Meaning: +
+
+ + By Name: +
+
+
+
+
+
+ + + + + + + diff --git a/test.py b/test.py new file mode 100644 index 0000000..eb0983d --- /dev/null +++ b/test.py @@ -0,0 +1,20 @@ +import random +# import pyarabic.araby as araby +# from fuzzywuzzy import fuzz +# from utils.similarity import find_similarity ,rm_sign +import json +import os + + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.develop') +from django.core.wsgi import get_wsgi_application + +application = get_wsgi_application() + +from apps.course.models import Course, CourseCategory +from apps.hadis.models.category import HadisCategory + +g = HadisCategory.objects.all()[2] + + +print(f'---> {g.parent}') \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..420f9ee --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,304 @@ +import os +import secrets +import shutil +import mimetypes +import re +from urllib.parse import urlparse + +from django.conf import settings +from django.core.files import File +from django.http import HttpRequest +from django.core.mail import send_mail + +from rest_framework import serializers, status +from rest_framework.generics import GenericAPIView +from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.response import Response +from unidecode import unidecode +from django.utils.text import slugify +import random +import string + +from django.conf import settings + +from django.utils.translation import gettext_lazy as _ + +from cachetools.func import lru_cache +from django.http import HttpRequest +from django.contrib import admin + +# Moved filer imports to avoid circular imports +# These will be imported when needed in functions + +@lru_cache +def qs_thumbs(): + from filer.models import ThumbnailOption + return ThumbnailOption.objects.all() + + +def get_thumbs(obj, request: HttpRequest = None) -> dict: + print(f'----> {obj}') + if not obj: + return {} + + try: + from easy_thumbnails.files import get_thumbnailer + + thumbnail_object = {} + thumbs = qs_thumbs() + print(f'--> {thumbs}') + + # بررسی نوع فیلد و استفاده از روش مناسب + if hasattr(obj, 'easy_thumbnails_thumbnailer'): + # برای فیلدهای FilerImageField + thumbnailer = obj.easy_thumbnails_thumbnailer + else: + # برای فیلدهای ImageField معمولی + thumbnailer = get_thumbnailer(obj) + + for thumb in thumbs: + url = thumbnailer.get_thumbnail(thumb.as_dict).url + if request: + url = request.build_absolute_uri(url) + + thumbnail_object[thumb.name] = url + + return thumbnail_object + + except Exception as p: + print(p) + return {} + + +def environment_callback(request): + if settings.DEBUG: + return [_("Development"), "primary"] + + return [_("Production"), "primary"] + + + +def send_email(recipient, code): + send_mail( + 'Test Email', + f'This is a test email {code} from Django using Gmail SMTP.', + 'aliabdolahi.171@gmail.com', + recipient, + fail_silently=False, + ) + return True + + +def is_valid_email(email): + # تعریف الگوی regex برای یک ایمیل معتبر + email_regex = r'^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$' + + # بررسی اینکه آیا ایمیل با regex مطابقت دارد یا خیر + if re.match(email_regex, email): + return True + return False + + +def generate_slug_for_model(model, value: str, recycled_count: int = 0): + from slugify import slugify + try: + + base_slug = slugify(unidecode(value)) + slug = base_slug + if recycled_count > 0: + slug = f"{base_slug}-{recycled_count}" + + if model.objects.filter(slug=slug).exists(): + return generate_slug_for_model(model, value, recycled_count + 1) + + return slug[:50] + except Exception as exp: + letters = string.ascii_lowercase + result_str = ''.join(random.choice(letters) for i in range(8)) + return result_str + + +def generate_slugen_for_model(model, value_en, value_pk): + try: + unique_slug = value_en + if not value_pk or not value_en: + base_slug = slugify(unidecode(value_en)) + unique_slug = base_slug + num = 1 + while model.objects.filter(slug=unique_slug).exists(): + unique_slug = f"{base_slug}-{num}" + num += 1 + + return unique_slug + except Exception as exp: + letters = string.ascii_lowercase + result_str = ''.join(random.choice(letters) for i in range(8)) + return result_str + + +def exclude_host_from_url(url): + # Parse the URL + parsed_url = urlparse(url) + + # Extract the path and query parameters + path_with_query = parsed_url.path + parsed_url.query + + return path_with_query + + +def generate_slug_for_model(model, value: str, recycled_count: int = 0): + from slugify import slugify + + slug = slugify(value) + if model.objects.filter(slug=slug).exists(): + recycled_count += 1 + if value.endswith(f'-{recycled_count - 1}'): + value = value.replace(f'-{recycled_count - 1}', f'-{recycled_count}') + else: + value = f"{value}-{recycled_count}" + return generate_slug_for_model(model, value, recycled_count) + + return slug[:50] + +def absolute_url(req, url): + """ + can either be a file instance or a URL string + """ + try: + return req.build_absolute_uri(url.url if hasattr(url, 'url') else url) + except Exception: + return None + +def sizeof_fmt(num, suffix="B"): + for unit in ["", "K", "M", "G"]: + if abs(num) < 1024.0: + return f"{num:3.1f} {unit}{suffix}" + num /= 1024.0 + return f"{num:.1f} Yi{suffix}" + + +def file_location(path): + from django.conf import settings + import os + + if path.startswith('http'): + path = exclude_host_from_url(path) + + if path.startswith("/static"): + path = path[7:] + + if path.startswith('/'): + path = path[1:] + + return os.path.join(settings.STATIC_ROOT, path) + + +def guess_file_type(filename): + try: + mimetype = mimetypes.guess_type(filename)[0].split('/')[0] + return mimetype + + except Exception: + return False + +class FileFieldSerializer(serializers.CharField): + """ + a field to handle uploaded file + """ + + def get_rpath(self, p): + # extract relative path of doc + return p[p.find('/static/') + 7:] + + def to_representation(self, value): + request = self.context.get('request', None) + if value: + if isinstance(value, str): + # If value is a string, assume it's a file path + return value + elif hasattr(value, 'url'): + if 'http://' in str(value) or 'https://' in str(value): + return str(value) + + return absolute_url(request, value.url) if request else value.url + return None + + def to_internal_value(self, data): + if not data: + return None + + if "/tmp/" not in data: + # value not changed and here we simply return old file path + return self.get_rpath(data) + + if data.startswith('http'): + data = self.get_rpath(data) + + fpath = file_location(data) + if not os.path.exists(fpath): + raise serializers.ValidationError(f"File: '{fpath}' Does not exist") + + return File(open(fpath, 'rb'), os.path.basename(data)) + + +class UploadTmpSerializer(serializers.Serializer): + file = serializers.FileField() + url = serializers.URLField(read_only=True) + name = serializers.CharField(read_only=True) + size = serializers.CharField(read_only=True) + mime_type = serializers.CharField(read_only=True) + + def to_representation(self, instance): + data = super(UploadTmpSerializer, self).to_representation(instance) + data['file'] = instance['file'] + return data + + def store_file(self, file): + from django.conf import settings + static_path = settings.STATIC_ROOT + + os.makedirs(f'{static_path}/tmp', exist_ok=True) + fpath = f"/tmp/{secrets.token_urlsafe(4)}-{file.name}" + shutil.move(file.temporary_file_path(), static_path + fpath) + os.chmod(static_path + fpath, 0o644) + + return { + 'file': fpath, + 'url': absolute_url(self.context['request'], f"/static{fpath}"), + 'name': file.name, + 'size': sizeof_fmt(file.size), + 'mime_type': guess_file_type(fpath) + } + + def validate(self, attrs): + file_details = self.store_file(attrs['file']) + return file_details + + +class UploadTmpMedia(GenericAPIView): + """ + Files will remove every 1 hour + """ + parser_classes = (FormParser, MultiPartParser) + serializer_class = UploadTmpSerializer + + def post(self, request: HttpRequest, *args, **kwargs): + serializer = UploadTmpSerializer(data=request.FILES, context={'request': request}) + is_valid = serializer.is_valid(raise_exception=True) + if not is_valid: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + return Response(serializer.data) + +# Configure filer admin after Django is fully loaded +def configure_filer_admin(): + try: + from filer.admin.fileadmin import FileAdmin + from filer.apps import FilerConfig + + FileAdmin.readonly_fields += ('owner',) + FilerConfig.icon = 'icon-folder' + except ImportError: + pass + +# This will be executed when this module is imported after Django is fully loaded diff --git a/utils/admin.py b/utils/admin.py new file mode 100644 index 0000000..c5049ce --- /dev/null +++ b/utils/admin.py @@ -0,0 +1,258 @@ + + +import json +import random +from functools import lru_cache + +from django.contrib.humanize.templatetags.humanize import intcomma +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ +from django.views.generic import RedirectView, TemplateView + +# Import Filer admin classes +from filer.admin.fileadmin import FileAdmin +from filer.admin.folderadmin import FolderAdmin +from filer.admin.imageadmin import ImageAdmin +from filer.models import File, Folder, Image + +def dashboard_callback(request, context): + context.update(random_data()) + return context + +import json +import random +from functools import lru_cache + +from django.contrib.humanize.templatetags.humanize import intcomma +from django.utils.safestring import mark_safe +from django.utils.translation import gettext_lazy as _ +from django.views.generic import RedirectView, TemplateView +from unfold.views import UnfoldModelAdminViewMixin + + +from unfold.sites import UnfoldAdminSite +from django import forms +from django.conf import settings +from unfold.forms import AuthenticationForm + + +class LoginForm(AuthenticationForm): + password = forms.CharField(widget=forms.PasswordInput(render_value=True)) + + def __init__(self, request=None, *args, **kwargs): + super().__init__(request, *args, **kwargs) + # Change the label of the username field to "Email" + self.fields["username"].label = "Email" + + + +class FormulaAdminSite(UnfoldAdminSite): + login_form = LoginForm + + +project_admin_site = FormulaAdminSite() + +# # Register Filer models with the admin site +# project_admin_site.register(Folder, FolderAdmin) +# project_admin_site.register(File, FileAdmin) +# project_admin_site.register(Image, ImageAdmin) + + +class HomeView(RedirectView): + pattern_name = "admin:index" + + +def variables(request): + return {"plausible_domain": settings.PLAUSIBLE_DOMAIN} + +# class MyClassBasedView(UnfoldModelAdminViewMixin, TemplateView): +# title = "Custom Title" # required: custom page header title +# # required: tuple of permissions +# permission_required = ( +# "formula.view_driver", +# "formula.add_driver", +# "formula.change_driver", +# "formula.delete_driver", +# ) +# template_name = "formula/driver_custom_page.html" + + +def dashboard_callback(request, context): + context.update(random_data()) + return context + + +@lru_cache +def random_data(): + WEEKDAYS = [ + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", + "Sun", + ] + + positive = [[1, random.randrange(8, 28)] for i in range(1, 28)] + negative = [[-1, -random.randrange(8, 28)] for i in range(1, 28)] + average = [r[1] - random.randint(3, 5) for r in positive] + performance_positive = [[1, random.randrange(8, 28)] for i in range(1, 28)] + performance_negative = [[-1, -random.randrange(8, 28)] for i in range(1, 28)] + + return { + "navigation": [ + {"title": _("Dashboard"), "link": "/", "active": True}, + {"title": _("Analytics"), "link": "#"}, + {"title": _("Settings"), "link": "#"}, + ], + "filters": [ + {"title": _("All"), "link": "#", "active": True}, + { + "title": _("New"), + "link": "#", + }, + ], + "kpi": [ + { + "title": "Product A Performance", + "metric": f"${intcomma(f'{random.uniform(1000, 9999):.02f}')}", + "footer": mark_safe( + f'+{intcomma(f"{random.uniform(1, 9):.02f}")}% progress from last week' + ), + "chart": json.dumps( + { + "labels": [WEEKDAYS[day % 7] for day in range(1, 28)], + "datasets": [{"data": average, "borderColor": "#9333ea"}], + } + ), + }, + { + "title": "Product B Performance", + "metric": f"${intcomma(f'{random.uniform(1000, 9999):.02f}')}", + "footer": mark_safe( + f'+{intcomma(f"{random.uniform(1, 9):.02f}")}% progress from last week' + ), + }, + { + "title": "Product C Performance", + "metric": f"${intcomma(f'{random.uniform(1000, 9999):.02f}')}", + "footer": mark_safe( + f'+{intcomma(f"{random.uniform(1, 9):.02f}")}% progress from last week' + ), + }, + ], + "progress": [ + { + "title": "🦆 Social marketing e-book", + "description": f"${intcomma(f'{random.uniform(1000, 9999):.02f}')}", + "value": random.randint(10, 90), + }, + { + "title": "🦍 Freelancing tasks", + "description": f"${intcomma(f'{random.uniform(1000, 9999):.02f}')}", + "value": random.randint(10, 90), + }, + { + "title": "🐋 Development coaching", + "description": f"${intcomma(f'{random.uniform(1000, 9999):.02f}')}", + "value": random.randint(10, 90), + }, + { + "title": "🦑 Product consulting", + "description": f"${intcomma(f'{random.uniform(1000, 9999):.02f}')}", + "value": random.randint(10, 90), + }, + { + "title": "🐨 Other income", + "description": f"${intcomma(f'{random.uniform(1000, 9999):.02f}')}", + "value": random.randint(10, 90), + }, + { + "title": "🐶 Course sales", + "description": f"${intcomma(f'{random.uniform(1000, 9999):.02f}')}", + "value": random.randint(10, 90), + }, + { + "title": "🐻‍❄️ Ads revenue", + "description": f"${intcomma(f'{random.uniform(1000, 9999):.02f}')}", + "value": random.randint(10, 90), + }, + { + "title": "🦩 Customer Retention Rate", + "description": f"${intcomma(f'{random.uniform(1000, 9999):.02f}')}", + "value": random.randint(10, 90), + }, + { + "title": "🦊 Marketing ROI", + "description": f"${intcomma(f'{random.uniform(1000, 9999):.02f}')}", + "value": random.randint(10, 90), + }, + { + "title": "🦁 Affiliate partnerships", + "description": f"${intcomma(f'{random.uniform(1000, 9999):.02f}')}", + "value": random.randint(10, 90), + }, + ], + "chart": json.dumps( + { + "labels": [WEEKDAYS[day % 7] for day in range(1, 28)], + "datasets": [ + { + "label": "Example 1", + "type": "line", + "data": average, + "borderColor": "var(--color-primary-500)", + }, + { + "label": "Example 2", + "data": positive, + "backgroundColor": "var(--color-primary-700)", + }, + { + "label": "Example 3", + "data": negative, + "backgroundColor": "var(--color-primary-300)", + }, + ], + } + ), + "performance": [ + { + "title": _("Last week revenue"), + "metric": "$1,234.56", + "footer": mark_safe( + '+3.14% progress from last week' + ), + "chart": json.dumps( + { + "labels": [WEEKDAYS[day % 7] for day in range(1, 28)], + "datasets": [ + { + "data": performance_positive, + "borderColor": "var(--color-primary-700)", + } + ], + } + ), + }, + { + "title": _("Last week expenses"), + "metric": "$1,234.56", + "footer": mark_safe( + '+3.14% progress from last week' + ), + "chart": json.dumps( + { + "labels": [WEEKDAYS[day % 7] for day in range(1, 28)], + "datasets": [ + { + "data": performance_negative, + "borderColor": "var(--color-primary-300)", + } + ], + } + ), + }, + ], + } diff --git a/utils/apps.py b/utils/apps.py new file mode 100644 index 0000000..4e9b93e --- /dev/null +++ b/utils/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class UtilsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'utils' + + def ready(self): + # Import and run the configure_filer_admin function + from utils import configure_filer_admin + configure_filer_admin() \ No newline at end of file diff --git a/utils/calculate_distance.py b/utils/calculate_distance.py new file mode 100644 index 0000000..ab0df4e --- /dev/null +++ b/utils/calculate_distance.py @@ -0,0 +1,50 @@ +from math import radians + +from django.db.models import F, Value +from django.db.models.functions import Radians, Sin, ATan2, Sqrt, Cos + + +def calculate_distance(qs, client_lat: float, client_lon: float): + """ + Based on stackoverflow question: https://stackoverflow.com/a/19412565/10261581 + Distance Unit is in Kilometres + + R = 6373.0 + + lat1 = radians(52.2296756) + lon1 = radians(21.0122287) + lat2 = radians(52.406374) + lon2 = radians(16.9251681) + + dlon = lon2 - lon1 + dlat = lat2 - lat1 + + a = sin(dlat / 2)**2 + cos(lat1) * cos(lat2) * sin(dlon / 2)**2 + c = 2 * atan2(sqrt(a), sqrt(1 - a)) + + distance = R * c + + print("Result: ", distance) + print("Should be: ", 278.546, "km") + """ + earth_radius = 6373.0 + + client_lat, client_lon = radians(float(client_lat)), radians(float(client_lon)) + if not client_lat: + return qs.annotate( + distance=Value(0), + ) + + return qs.annotate( + rlat=Radians('latitude'), + rlon=Radians('longitude'), + + # a=sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2 + # c = 2 * atan2(sqrt(a), sqrt(1 - a)) + lat_diff=F('rlat') - client_lat, + lon_diff=F('rlon') - client_lon, + a=Sin(F('lat_diff') / 2.0) ** 2.0 + Cos(client_lat) * Cos(F('rlat')) * Sin( + F('lon_diff') / 2.0) ** 2.0, + c=2.0 * ATan2(Sqrt(F('a')), Sqrt(1.0 - F('a'))), + distance=F('c') * earth_radius + ) diff --git a/utils/convert_currency.py b/utils/convert_currency.py new file mode 100644 index 0000000..54092c3 --- /dev/null +++ b/utils/convert_currency.py @@ -0,0 +1,70 @@ + +from djmoney.money import Money +from decimal import Decimal +from apps.tasrif.models import CurrencyRate + +def convert_currency_pure(from_rate, to_rate, amount): + + converted_amount = (amount / from_rate.rate) * to_rate.rate + + if to_rate.code == "IRR": + converted_amount = round(converted_amount) + + one_unit_conversion = to_rate.rate / from_rate.rate + + return converted_amount, one_unit_conversion + +def convert_currency(amount, from_currency_code, to_currency_code): + try: + from_currency_rate = CurrencyRate.objects.get(code=from_currency_code) + to_currency_rate = CurrencyRate.objects.get(code=to_currency_code) + + # مطمئن شدن از نوع داده Decimal برای نرخ‌ها + from_rate = Decimal(to_currency_rate.rate) + to_rate = Decimal(from_currency_rate.rate) + + # محاسبه نرخ تبدیل + conversion_rate = from_rate / to_rate + + # تبدیل مبلغ + converted_amount = Decimal(amount) * conversion_rate + + # ایجاد نمونه Money + converted_money = Money(converted_amount, to_currency_code) + print(f'>>>>>>>>> {converted_amount} /// {converted_money}') + return converted_money + except CurrencyRate.DoesNotExist: + return None # یا می‌توانید خطا را مدیریت کنید + + +def convert_currency2(from_currency_code, to_currency_code, amount): + try: + # Fetch the currencies from the database + from_currency = CurrencyRate.objects.get(code=from_currency_code) + to_currency = CurrencyRate.objects.get(code=to_currency_code) + + # Convert the amount to USD first, then to the target currency + amount_in_usd = amount / from_currency.rate + converted_amount = amount_in_usd * to_currency.rate + + # Ensure IRR values are integer if converting to IRR + if to_currency_code == 'IRR': + converted_amount = round(converted_amount) + + return converted_amount + + except CurrencyRate.DoesNotExist: + raise ValueError("One or both of the specified currencies are not available in the database.") + + +def formater_convert_currency(to_currency, converted_amount, rate_per_unit): + if to_currency == 'IRR': + # Format without decimals for IRR + formatted_converted_amount = f"{converted_amount:,.0f}" # No decimal places + formatted_rate_per_unit = f"{rate_per_unit:,.0f}" + else: + # Format with two decimal places for other currencies + formatted_converted_amount = f"{converted_amount:,.2f}" # Two decimal places + formatted_rate_per_unit = f"{rate_per_unit:,.2f}" + + return formatted_converted_amount, formatted_rate_per_unit \ No newline at end of file diff --git a/utils/countries.py b/utils/countries.py new file mode 100644 index 0000000..7bca0f3 --- /dev/null +++ b/utils/countries.py @@ -0,0 +1,32 @@ +countries = [ + {"id": 1, "name": "Иран", "cities": ["Тегеран", "Мешхед", "Исфахан", "Шираз", "Табриз"]}, + {"id": 2, "name": "Ирак", "cities": ["Багдад", "Мосул", "Басра", "Кербела", "Наджаф"]}, + {"id": 3, "name": "Сирия", "cities": ["Дамаск", "Алеппо", "Хомс", "Латакия", "Хама"]}, + {"id": 4, "name": "Турция", "cities": ["Стамбул", "Анкара", "Измир", "Анталия", "Бурса"]}, + {"id": 5, "name": "Саудовская Аравия", "cities": ["Рияд", "Джидда", "Мекка", "Медина", "Хофуф"]}, + {"id": 6, "name": "Иордания", "cities": ["Амман", "Зарка", "Ирбид", "Аджлун", "Мафрак"]}, + {"id": 7, "name": "Ливан", "cities": ["Бейрут", "Триполи", "Сидон", "Тир", "Захле"]}, + {"id": 8, "name": "Египет", "cities": ["Каир", "Александрия", "Гиза", "Шарма-эль-Шейх", "Луксор"]}, + {"id": 9, "name": "Объединённые Арабские Эмираты", "cities": ["Дубай", "Абу-Даби", "Шарджа", "Аджман", "Рас-эль-Хайма"]}, + {"id": 10, "name": "Катар", "cities": ["Доха", "Ал-Хор", "Ал-Вакра", "Лусаил", "Шахания"]}, + {"id": 11, "name": "Кувейт", "cities": ["Кувейт", "Хавалли", "Аль-Фарванийя", "Аль-Асима", "Махбула"]}, + {"id": 12, "name": "Бахрейн", "cities": ["Манама", "Мухаррак", "Рифа", "Ситра", "Худад"]}, + {"id": 13, "name": "Оман", "cities": ["Мускат", "Салала", "Низва", "Сухар", "Масйат"]}, + {"id": 14, "name": "Йемен", "cities": ["Сана", "Аден", "Таиз", "Ходейда", "Мукалла"]}, + {"id": 15, "name": "Афганистан", "cities": ["Кабул", "Кандагар", "Герат", "Мазари-Шариф", "Пешавар"]}, + {"id": 16, "name": "Пакистан", "cities": ["Исламабад", "Лахор", "Карачи", "Пешавар", "Кветта"]}, + {"id": 17, "name": "Азербайджан", "cities": ["Баку", "Гянджа", "Мингечаур", "Шеки", "Сумгаит"]}, + {"id": 18, "name": "Армения", "cities": ["Ереван", "Гюмри", "Ванадзор", "Вагаршапат", "Раздан"]}, + {"id": 19, "name": "Грузия", "cities": ["Тбилиси", "Батуми", "Кутаиси", "Зугдиди", "Мцхета"]}, + {"id": 20, "name": "Кувейт", "cities": ["Кувейт", "Хавалли", "Аль-Фарванийя", "Аль-Асима", "Махбула"]}, + {"id": 21, "name": "Ливан", "cities": ["Бейрут", "Триполи", "Сидон", "Тир", "Захле"]}, + {"id": 22, "name": "Израиль", "cities": ["Иерусалим", "Тель-Авив", "Хайфа", "Эйлат", "Беэр-Шева"]}, + {"id": 23, "name": "Египет", "cities": ["Каир", "Александрия", "Гиза", "Шарма-эль-Шейх", "Луксор"]}, + {"id": 24, "name": "Палестина", "cities": ["Иерусалим", "Газа", "Рамалла", "Хеврон", "Наблус"]}, + {"id": 25, "name": "Ливия", "cities": ["Триполи", "Бенгази", "Мисрата", "Злитен", "Табарка"]}, +] + +def get_country_with_id(country_id: int): + country_name = next((country['name'] for country in countries if country['id'] == country_id), None) + return city_name if city_name else None + diff --git a/utils/exceptions.py b/utils/exceptions.py new file mode 100644 index 0000000..147defb --- /dev/null +++ b/utils/exceptions.py @@ -0,0 +1,155 @@ + + +from rest_framework.exceptions import APIException +from rest_framework import status +from rest_framework.views import exception_handler as drf_exception_handler +from rest_framework.response import Response +from rest_framework.exceptions import ValidationError + + + +def error_response(error_code, message, details=None, status_code=status.HTTP_400_BAD_REQUEST): + # print(f'>>>> {error_code}') + response_data = { + "status": "error", + "code": error_code, + "message": message, + "details": details or {} + } + + return Response(response_data, status=status_code) + + +def exception_handler(exc, context): + # Call REST framework's default exception handler first, + # to get the standard error response. + response = drf_exception_handler(exc, context) + # قالب‌بندی جدید خطاها + formatted_errors = [] + + if response is not None: + # تعیین نوع خطاها + print(f'>>> {type(exc)}/ {exc}') + + if isinstance(exc, ValidationError): + error_code = "validation_error" + message = "There were validation errors." + + if isinstance(response.data, list): + for error in response.data: + formatted_errors.append({ + "field": None, # No specific field when the error is a list + "message": error + }) + + else: + # فرمت کردن خطاها به فرمت جدید + print(f'>>>>>> {response.data}') + for field, errors in response.data.items(): + if isinstance(errors, list): + for error in errors: + # print() + # print(f'>>>> {field}') + formatted_errors.append({ + "field": field, + "message": error + }) + else: + formatted_errors.append({ + "field": field, + "message": errors + }) + + seen_fields = set() + for i in range(len(formatted_errors) - 1, -1, -1): + field = formatted_errors[i]['field'] + if field in seen_fields: + # اگر فیلد تکراری بود، آن را حذف کن + del formatted_errors[i] + else: + # اولین باری که فیلد دیده شده، آن را ثبت کن + seen_fields.add(field) + + elif isinstance(exc, AppAPIException): + error_code = "app_api_error" + message = "An error occurred while processing the request." + for field, errors in response.data.items(): + if isinstance(errors, list): + for error in errors: + formatted_errors.append({ + "message": error + }) + else: + formatted_errors.append({ + "message": errors + }) + + + else: + error_code = "server_error" + message = "An error occurred." + # فرمت کردن خطاها به فرمت جدید + for field, errors in response.data.items(): + if isinstance(errors, list): + for error in errors: + formatted_errors.append({ + "message": error + }) + else: + formatted_errors.append({ + "message": errors + }) + + + + + # تنظیم ساختار جدید برای پاسخ خطا + response.data = { + "status": "error", + "code": error_code, + "status_code": response.status_code, # Adding the status code + "message": message, + "errors": formatted_errors + } + + return response + + +class AppAPIException(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = 'An error occurred while processing the request.' + + def __init__(self, detail=None, status_code=None): + if detail is None: + detail = self.default_detail + if status_code is None: + status_code = self.default_code + else: + self.status_code = status_code + super().__init__(detail, status_code) + + + +class ExpiredCodeException(APIException): + status_code = status.HTTP_410_GONE + default_detail = "The verification code has expired." + default_code = "expired_code" + +class UserNotFoundException(APIException): + status_code = status.HTTP_404_NOT_FOUND + default_detail = 'user notfound' +class NotFoundException(APIException): + status_code = status.HTTP_404_NOT_FOUND + default_detail = "The requested resource was not found." + default_code = "not_found" + + +class InvaliedCodeVrify(APIException): + status_code = status.HTTP_400_BAD_REQUEST + default_detail = "code notfound" + + +class ServiceUnavailableException(APIException): + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR + default_detail = 'Service temporarily unavailable' + default_code = 'service_unavailable' \ No newline at end of file diff --git a/utils/json_editor_field.py b/utils/json_editor_field.py new file mode 100644 index 0000000..b907505 --- /dev/null +++ b/utils/json_editor_field.py @@ -0,0 +1,86 @@ +import json +from typing import Any, Optional + +from django import forms +from django.db import models +from django.utils.safestring import mark_safe + +from django.forms import MultiWidget, Widget + + +JSON_EDITOR_CLASSES = [ + "border", + "border-base-200", + "rounded", + "group-[.errors]:border-red-600", + "w-full", + "dark:border-base-700", + "dark:group-[.errors]:border-red-500", +] + + +class JsonEditorWidget(Widget): + template_name = 'account/json_editor_field.html' + + class Media: + css = { + 'all': ('https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/css/jsoneditor.min.css',) + } + js = ('https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js',) + + def __init__(self, attrs: Optional[dict[str, Any]] = None) -> None: + if attrs is None: + attrs = {} + + # Set default title if not provided + if 'title' not in attrs and 'label' in attrs: + attrs['title'] = attrs['label'] + elif 'title' not in attrs: + attrs['title'] = 'JSON Editor' + + super().__init__(attrs) + + self.attrs.update({ + 'class': ' '.join(JSON_EDITOR_CLASSES), + }) + + def render(self, name, value, attrs=None, renderer=None): + if value is None: + value = '{}' + elif isinstance(value, dict): + value = json.dumps(value) + + attrs = self.build_attrs(self.attrs, attrs) + attrs['name'] = name + + # Ensure the schema is properly passed to the template + if 'schema' in self.attrs: + attrs['schema'] = self.attrs['schema'] + + # Pass field name as title if not set + if 'title' not in attrs: + attrs['title'] = name.replace('_', ' ').title() + + return super().render(name, value, attrs, renderer) + + +class JsonEditorField(models.JSONField): + schema = {} + + def __init__(self, *args, schema: dict, **kwargs): + self.schema = schema + super().__init__(*args, **kwargs) + + def formfield(self, **kwargs): + schema = self.schema() if callable(self.schema) else self.schema + + kwargs.update({ + 'widget': JsonEditorWidget(attrs={'schema': json.dumps(schema)}), + }) + return super(JsonEditorField, self).formfield(**kwargs) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + kwargs['schema'] = self.schema + + return name, path, args, kwargs diff --git a/utils/keyval_field.py b/utils/keyval_field.py new file mode 100644 index 0000000..06f84af --- /dev/null +++ b/utils/keyval_field.py @@ -0,0 +1,229 @@ +import json + +from django.db import models + +from utils.json_editor_field import JsonEditorWidget +from django.utils.translation import gettext_lazy as _ +from dj_language.models import Language + +def get_simcard_detail_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Translation')), + 'properties': { + 'detail': {'type': 'string', 'title': str(_('Detail'))}, + 'language_code': { + 'type': "string", + 'enum': list(Language.objects.filter(status=True).values_list('code', flat=True)), + 'default': "en", + 'title': str(_('Language Code')) + } + } + } + } + + +def get_simcard_title_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Translation')), + 'properties': { + 'title': {'type': 'string', 'title': str(_('Title'))}, + 'language_code': { + 'type': "string", + 'enum': list(Language.objects.filter(status=True).values_list('code', flat=True)), + 'default': "en", + 'title': str(_('Language Code')) + } + } + } + } + +def get_tour_feature_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Tour Features')), + 'properties': { + 'title': {'type': 'string', 'title': str(_('Title'))}, + } + } + } + + + + +def get_product_title_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Translation')), + 'properties': { + 'title': {'type': 'string', 'title': str(_('Title'))}, + 'language_code': { + 'type': "string", + 'enum': list(Language.objects.filter(status=True).values_list('code', flat=True)), + 'default': "en", + 'title': str(_('Language Code')) + } + } + } + } + +def get_product_detail_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Translation')), + 'properties': { + 'detail': {'type': 'string', "format": "textarea",'title': str(_('Detail'))}, + 'language_code': { + 'type': "string", + 'enum': list(Language.objects.filter(status=True).values_list('code', flat=True)), + 'default': "en", + 'title': str(_('Language Code')) + } + } + } + } + + + +def get_name_translation_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Translation')), + 'properties': { + 'name': {'type': 'string', 'title': str(_('Name'))}, + 'language_code': { + 'type': "string", + 'enum': list(Language.objects.filter(status=True).values_list('code', flat=True)), + 'default': "en", + 'title': str(_('Language Code')) + } + } + } + } + + +def get_translation_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Translation')), + 'properties': { + 'title': {'type': 'string', 'title': str(_('Title'))}, + 'language_code': { + 'type': "string", + 'enum': list(Language.objects.filter(status=True).values_list('code', flat=True)), + 'default': "en", + 'title': str(_('Language Code')) + } + } + } + } + + +def get_travel_guide_schema(): + from dj_language.models import Language + from django.utils.translation import gettext_lazy as _ + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + "required_by_default": 1, + 'items': { + 'type': 'object', + "required": ["title", "description"], + 'title': str(_('Description')), + 'properties': { + 'title': { + 'type': 'string', + "format": "textarea", + 'title': str(_('Title')) + }, + 'description': { + 'type': "string", + "format": "textarea", + 'title': str(_('Description')) + } + } + } + } + + +class JsonKeyValueField(models.JSONField): + description = "custom json key value field" + + def __init__(self, key_index='key', value_index='value', schema=None, *args, **kwargs): + self.key_index = key_index + self.value_index = value_index + self.schema = schema or { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str('Title'), + 'properties': { + self.key_index: {'type': 'string', 'title': self.key_index.title()}, + self.value_index: {'type': 'string', 'title': self.value_index.title()}, + } + } + } + kwargs.setdefault('default', dict) + super().__init__(*args, **kwargs) + + def save_form_data(self, instance, data): + _data = {} + for i in data: + key, value = i[self.key_index], i[self.value_index] + _data[key] = value + + return super().save_form_data(instance, _data) + + def value_from_object(self, obj): + _data = [] + field = getattr(obj, self.attname, {}) + for key, val in field.items(): + _data.append({ + self.key_index: key, + self.value_index: val + }) + return _data + + def formfield(self, **kwargs): + schema = self.schema() if callable(self.schema) else self.schema + if type(schema) is dict or type(schema) is list: + schema = json.dumps(schema) + + return super().formfield(**{ + 'widget': JsonEditorWidget(attrs={'schema': schema}), + 'encoder': self.encoder, + 'decoder': self.decoder, + **kwargs, + }) diff --git a/utils/pageless.py b/utils/pageless.py new file mode 100644 index 0000000..12edbee --- /dev/null +++ b/utils/pageless.py @@ -0,0 +1,19 @@ +from rest_framework.response import Response + + +class PageLessMixin: + pagination_class = None + filter_backends = [] + + def list(self, request, *args, **kwargs): + queryset = self.filter_queryset(self.get_queryset()) + + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response({ + 'results': serializer.data + }) \ No newline at end of file diff --git a/utils/redis.py b/utils/redis.py new file mode 100644 index 0000000..9d66dd3 --- /dev/null +++ b/utils/redis.py @@ -0,0 +1,70 @@ +import random +from datetime import datetime, timedelta + +from redis.exceptions import RedisError + +from config.redis_config import RedisConfig +from utils.exceptions import ServiceUnavailableException, NotFoundException + +class RedisManager(RedisConfig): + + def __serialize(self, code, fullname, password): + return f'{code},{fullname},{password}' + + + def add_to_redis(self, code, **kwargs) -> bool: + try: + password = kwargs.get('password') + key = self.__serialize( + code=code, fullname=kwargs['fullname'], password=password + ) + self.redis.set(kwargs["email"], str(key), ex=timedelta(minutes=20)) + return kwargs["email"] + except RedisError as exp: + raise ServiceUnavailableException() + + def __deserialize( + self, + value: str, + key: list = ['code', 'fullname', 'password'] + ): + values = value.split(',') + # Check if lengths of keys and values are not equal + + if len(key) != len(values): + raise ValueError("The number of keys does not match the number of values.") + + result = {} + for k, v in zip(key, values): + if not k or not v: # Check if either key or value is empty + result[k] = None # or '' if you prefer empty string + else: + result[k] = v + + return result + + def get_by_redis(self, key: str): + try: + print(key) + data = self.redis.get(key) + print(f'get => {data}') + return self.__deserialize(data.decode()) + except RedisError as exp: + raise ServiceUnavailableException() + except (TypeError, ValueError, AttributeError): + raise NotFoundException() + + def check_exists_redis(self, email: str) -> bool: + """ + check exists key in redis + """ + try: + exists = self.redis.exists(email) + return exists + except RedisError as exp: + raise CustomException("Service temporarily unavailable") + + @staticmethod + def generate_otp_code() -> int: + random_code = random.randint(10000, 99999) + return random_code \ No newline at end of file diff --git a/utils/schema.py b/utils/schema.py new file mode 100644 index 0000000..110a701 --- /dev/null +++ b/utils/schema.py @@ -0,0 +1,52 @@ +from django.utils.translation import gettext_lazy as _ + + + + +def default_timing(): + return { + # "saturday": "", + # "sunday": "", + # "monday": "", + # "tuesday": "", + # "wednesday": "", + # "thursday": "", + # "friday": "" + } + + +def get_weekly_timing_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str('Weekly Timing'), + 'properties': { + 'day': { + 'type': 'string', + 'enum': ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'], + 'title': 'Day', + + }, + 'time': {'type': 'string', 'format': 'time','title': str('Time')}, + } + } + } + + + +def get_course_feature_schema(): + return { + 'type': "array", + 'format': 'table', + 'title': ' ', + 'items': { + 'type': 'object', + 'title': str(_('Course Features')), + 'properties': { + 'title': {'type': 'string', 'title': str(_('Title'))}, + } + } + } diff --git a/utils/styles.css b/utils/styles.css new file mode 100644 index 0000000..b5c61c9 --- /dev/null +++ b/utils/styles.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/utils/thumbail.py b/utils/thumbail.py new file mode 100644 index 0000000..0f1b63d --- /dev/null +++ b/utils/thumbail.py @@ -0,0 +1,16 @@ +from easy_thumbnails.files import get_thumbnailer + +from config.settings.base import THUMBNAIL_ALIASES + + +def get_thumbnail(file, size='medium', request=None): + # try: + options = THUMBNAIL_ALIASES[''].get(size) + url = get_thumbnailer(file).get_thumbnail(options).url + if request: + return request.build_absolute_uri(url) + + return url + + # except: + # return file.url diff --git a/utils/validators.py b/utils/validators.py new file mode 100644 index 0000000..62cf2e0 --- /dev/null +++ b/utils/validators.py @@ -0,0 +1,29 @@ + + +from django.core.exceptions import ValidationError + +from phonenumber_field.phonenumber import to_python +from phonenumbers.phonenumberutil import is_possible_number + + + + +def validate_possible_number(phone, country=None): + phone_number = to_python(phone, country) + if ( + phone_number + and not is_possible_number(phone_number) + or not phone_number.is_valid() + ): + raise ValidationError( + "The phone number entered is not valid.", code="invalid" + ) + return phone_number + +def validate_type_code(value): + from rest_framework import serializers + if not value.isdigit(): + raise serializers.ValidationError('کد باید شامل اعداد باشد.') + if len(value) != 5: + raise serializers.ValidationError('کد باید ۵ رقمی باشد.') + return value \ No newline at end of file