commit 50a5e016c82320442b35945d18112d6baa59887a Author: alireza Date: Thu Nov 21 01:35:08 2024 +0330 init project first commit diff --git a/.env.dev b/.env.dev new file mode 100644 index 0000000..025a936 --- /dev/null +++ b/.env.dev @@ -0,0 +1,19 @@ + +# DJANGO_ALLOWED_HOSTS=127.0.0.1,* +# DJANGO_SETTINGS_MODULE=config.settings.base + + +# #[database.POSTGRES] + +# POSTGRES_USER=postgres2 +# POSTGRES_DB=aquila +# POSTGRES_PASSWORD=admin +# POSTGRES_PORT=5432 +# POSTGRES_HOST=postgres +# DATABASE=aquila + + +# #[captcha] +# captcha_public_key="6LdkezEdAAAAAHFBxFSL6xJOYHxC66R274uVrqhC" +# captcha_private_key="6LdkezEdAAAAAMw997urKO6dOW8L223ql555KeaO" + diff --git a/.env.prod b/.env.prod new file mode 100644 index 0000000..2586f22 --- /dev/null +++ b/.env.prod @@ -0,0 +1,26 @@ +# DJANGO_ALLOWED_HOSTS=127.0.0.1,aqila.nwhco.ir,www.aqila.nwhco.ir,*.nwhco.ir,188.40.92.124,88.99.212.243 +# DJANGO_SETTINGS_MODULE=config.settings.production + + +# #[database.POSTGRES] +# POSTGRES_USER="pg-user" +# POSTGRES_DB="aqila" +# POSTGRES_PASSWORD="fdhd484fgsfddsdaf5@4df8g?90)(dfg78" +# POSTGRES_PORT="5432" +# POSTGRES_HOST="postgres" + + +# REDIS_URL=redis://aqila_redis:6379/0 +# # celery +# CELERY_BROKER=redis://aqila_redis:6379/0 +# CELERY_BACKEND=redis://aqila_redis:6379/0 +# FLOWER_UNAUTHENTICATED_API=true +# TIMEZONE="Asia/Tehran" +# CELERY_TIMEZONE="Asia/Tehran" + + +# #[captcha] +# captcha_public_key="6LdgCjseAAAAAIwg41-kyyulmwDtqD2Gk3THIwy2" +# captcha_private_key="6LdgCjseAAAAAPHMsIHuQgYAGTJ7_QlhqG4G0NyS" + +# FCM_API_KEY="" 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/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..414639d --- /dev/null +++ b/apps/account/admin/__init__.py @@ -0,0 +1,4 @@ + +from .user import * +from .professor import * +from .student import * \ 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..583293e --- /dev/null +++ b/apps/account/admin/professor.py @@ -0,0 +1,56 @@ +from django.contrib import admin +from django.contrib.auth.forms import UserChangeForm, UsernameField +from django.contrib.auth.admin import UserAdmin +from django.utils.translation import gettext_lazy as _ +from rest_framework.authtoken.models import TokenProxy +from ajaxdatatable.admin import AjaxDatatable + +from django.contrib import admin +from apps.account.models import User +from django import forms +from django.contrib import admin +from django.urls import path, reverse +from django.shortcuts import render, redirect +from django.contrib import messages + +from apps.account.models import ProfessorUser + + + +@admin.register(ProfessorUser) +class ProfessorUserAdmin(UserAdmin, AjaxDatatable): + list_display = ( + 'email', 'fullname', 'user_type','last_login', 'date_joined', + ) + ordering = 'last_login', + readonly_fields = ('date_joined',) + exclude = ('password', 'user_permissions') + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password1', 'password2'), + }), + ) + search_fields = ( + 'email', 'fullname', 'username', + ) + fieldsets = ( + (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}), + (_('Permissions'), { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'password'), + }), + (_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}), + ) + + def save_model(self, request, obj, form, change): + if not change: + obj.set_password(form.cleaned_data['password1']) + obj.user_type = User.UserType.PROFESSOR + super().save_model(request, obj, form, change) + + @admin.display(description='Phone Number') + def _phone_number(self, obj): + return obj.phone_number + + +# admin.site.unregister(TokenProxy) diff --git a/apps/account/admin/student.py b/apps/account/admin/student.py new file mode 100644 index 0000000..2b1cf11 --- /dev/null +++ b/apps/account/admin/student.py @@ -0,0 +1,56 @@ +from django.contrib import admin +from django.contrib.auth.forms import UserChangeForm, UsernameField +from django.contrib.auth.admin import UserAdmin +from django.utils.translation import gettext_lazy as _ +from rest_framework.authtoken.models import TokenProxy +from ajaxdatatable.admin import AjaxDatatable + +from django.contrib import admin +from apps.account.models import User +from django import forms +from django.contrib import admin +from django.urls import path, reverse +from django.shortcuts import render, redirect +from django.contrib import messages + +from apps.account.models import StudentUser + + + +@admin.register(StudentUser) +class StudentUserAdmin(UserAdmin, AjaxDatatable): + list_display = ( + 'email', 'fullname', 'user_type','last_login', 'date_joined', + ) + ordering = 'last_login', + readonly_fields = ('date_joined',) + exclude = ('password', 'user_permissions') + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password1', 'password2'), + }), + ) + search_fields = ( + 'email', 'fullname', 'username', + ) + fieldsets = ( + (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}), + (_('Permissions'), { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'password'), + }), + (_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}), + ) + + def save_model(self, request, obj, form, change): + if not change: + obj.set_password(form.cleaned_data['password1']) + obj.user_type = User.UserType.PROFESSOR + super().save_model(request, obj, form, change) + + @admin.display(description='Phone Number') + def _phone_number(self, obj): + return obj.phone_number + + +# admin.site.unregister(TokenProxy) diff --git a/apps/account/admin/user.py b/apps/account/admin/user.py new file mode 100644 index 0000000..0f43854 --- /dev/null +++ b/apps/account/admin/user.py @@ -0,0 +1,94 @@ +from django.contrib import admin +from django.contrib.auth.forms import UserChangeForm, UsernameField +from django.contrib.auth.admin import UserAdmin +from django.utils.translation import gettext_lazy as _ +from rest_framework.authtoken.models import TokenProxy +from ajaxdatatable.admin import AjaxDatatable + +from apps.account.models import User +from django import forms +from django.contrib import admin +from django.urls import path, reverse +from django.shortcuts import render, redirect +from django.contrib import messages + +from apps.account.models import ClientUser, AdminUser + + + +@admin.register(User) +class UserAdmin(UserAdmin, AjaxDatatable): + list_display = ( + 'email', 'fullname', 'user_type','last_login', 'date_joined', + ) + ordering = 'last_login', + readonly_fields = ('date_joined',) + exclude = ('password', 'user_permissions') + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password1', 'password2'), + }), + ) + search_fields = ( + 'email', 'fullname', 'username', + ) + fieldsets = ( + (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}), + (_('Permissions'), { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'password'), + }), + (_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}), + ) + + def save_model(self, request, obj, form, change): + if not change: + obj.set_password(form.cleaned_data['password1']) + + # obj.user_type = User.UserType.CLIENT + super().save_model(request, obj, form, change) + + @admin.display(description='Phone Number') + def _phone_number(self, obj): + return obj.phone_number + + + + +@admin.register(AdminUser) +class AdminUserAdmin(UserAdmin, AjaxDatatable): + list_display = ( + 'email', 'fullname', 'user_type','last_login', 'date_joined', + ) + ordering = 'last_login', + readonly_fields = ('date_joined',) + exclude = ('password', 'user_permissions') + add_fieldsets = ( + (None, { + 'classes': ('wide',), + 'fields': ('email', 'password1', 'password2'), + }), + ) + search_fields = ( + 'email', 'fullname', 'username', + ) + fieldsets = ( + (_('Personal info'), {'fields': ('fullname', 'email', 'phone_number', 'avatar',)}), + (_('Permissions'), { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'password'), + }), + (_('Important dates'), {'fields': ('last_login', 'date_joined', 'fcm')}), + ) + + def save_model(self, request, obj, form, change): + if not change: + obj.set_password(form.cleaned_data['password1']) + + # obj.user_type = User.UserType.CLIENT + super().save_model(request, obj, form, change) + + @admin.display(description='Phone Number') + def _phone_number(self, obj): + return obj.phone_number + +admin.site.unregister(TokenProxy) 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..2a41354 --- /dev/null +++ b/apps/account/custom_user_login.py @@ -0,0 +1,25 @@ +from django.contrib.auth.backends import BaseBackend +from django.db.models import Q + +from apps.account.models import User +from utils.exceptions import UserNotFoundException +from rest_framework.exceptions import AuthenticationFailed + + +class CustomLoginBackend(BaseBackend): + """ + Authenticate with username email and phone_number. + """ + + def authenticate(self, request, username=None, password=None): + if user := self.get_user(username): + if user.check_password(password): + return user + + return None + + def get_user(self, username): + try: + return User.objects.filter(Q(email=username) | Q(phone_number=username)).first() + except Exception.DoesNotExist: + return None diff --git a/apps/account/doc.py b/apps/account/doc.py new file mode 100644 index 0000000..e69de29 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..db93a79 --- /dev/null +++ b/apps/account/manager.py @@ -0,0 +1,83 @@ + +from django.contrib.auth.models import BaseUserManager + + +from django.db.models import Manager + + + +class UserManager(BaseUserManager): + + def create_user( + self, + email: str = None, + fullname: str = None, + password: str = None, + **extra_fields + ): + email = UserManager.normalize_email(email) + user = self.model( + email=email, + fullname=fullname, + **extra_fields + ) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, email, fullname, password): + user = self.create_user( + email=email, + fullname=fullname, + password=password, + ) + user.is_admin = True + user.is_staff = True + user.is_superuser = True + user.is_active = True + user.user_type="super_admin" + user.save(using=self._db) + return user + + + def change_user_type(self, new_user_type): + # حذف گروه‌های فعلی + old_group_name = f"{self.user_type.capitalize()} Group" + old_group = Group.objects.filter(name=old_group_name).first() + if old_group: + self.groups.remove(old_group) + + # تغییر نوع کاربر + self.user_type = new_user_type + + # افزودن گروه جدید + new_group_name = f"{new_user_type.capitalize()} Group" + new_group, _ = Group.objects.get_or_create(name=new_group_name) + self.groups.add(new_group) + + # ذخیره تغییرات + self.save() + + + +class ProfessorUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="professor") + + +class ClientUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="client") + +class AdminUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="admin") + + +class SuperAdminUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="super_admin") + +class StudentUserManager(UserManager): + def get_queryset(self): + return super().get_queryset().filter(user_type="student") diff --git a/apps/account/migrations/0001_initial.py b/apps/account/migrations/0001_initial.py new file mode 100644 index 0000000..ea3b60c --- /dev/null +++ b/apps/account/migrations/0001_initial.py @@ -0,0 +1,116 @@ +# Generated by Django 3.2.4 on 2024-11-19 08:43 + +import dj_language.field +from django.db import migrations, models +import django.db.models.deletion +import phonenumber_field.modelfields +import utils.validators + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dj_language', '0002_auto_20220120_1344'), + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('email', models.EmailField(help_text="Enter the user's email address.", max_length=254, unique=True, verbose_name='Email Address')), + ('fullname', models.CharField(help_text='Enter the full name of the user.', max_length=255, verbose_name='Full Name')), + ('birthdate', models.DateField(verbose_name='birthdate')), + ('avatar', models.ImageField(blank=True, null=True, upload_to='users/avatars/%Y/%m/')), + ('phone_number', phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, unique=True, validators=[utils.validators.validate_possible_number], verbose_name='phone')), + ('gender', models.CharField(blank=True, choices=[('male', 'Male'), ('female', 'Female')], help_text="Select the user's gender.", max_length=20, null=True, verbose_name='Gender')), + ('user_type', models.CharField(choices=[('professor', 'Professor'), ('client', 'Client'), ('student', 'Student'), ('admin', 'Admin'), ('super_admin', 'Super Admin')], default='client', help_text='Type of the user.', max_length=20, verbose_name='User Type')), + ('device_id', models.CharField(blank=True, max_length=255, null=True, verbose_name='device id')), + ('fcm', models.CharField(blank=True, max_length=512, null=True)), + ('date_joined', models.DateTimeField(auto_now_add=True, help_text='The date and time the user registered.', verbose_name='Date Joined')), + ('is_staff', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='Active')), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.Group', verbose_name='groups')), + ('language', dj_language.field.LanguageField(default=69, limit_choices_to={'status': True}, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dj_language.language', verbose_name='language')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.Permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'ordering': ('-id',), + }, + ), + migrations.CreateModel( + name='AdminUser', + fields=[ + ], + options={ + 'verbose_name': 'Admin User', + 'verbose_name_plural': 'Admin Users', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='ClientUser', + fields=[ + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'ordering': ('-id',), + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='ProfessorUser', + fields=[ + ], + options={ + 'verbose_name': 'Professor User', + 'verbose_name_plural': 'Professor Users', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='StudentUser', + fields=[ + ], + options={ + 'verbose_name': 'Student User', + 'verbose_name_plural': 'Student Users', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + migrations.CreateModel( + name='SuperAdminUser', + fields=[ + ], + options={ + 'verbose_name': 'Super Admin User', + 'verbose_name_plural': 'Super Admin Users', + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('account.user',), + ), + ] diff --git a/apps/account/migrations/0002_alter_user_birthdate.py b/apps/account/migrations/0002_alter_user_birthdate.py new file mode 100644 index 0000000..0e0350f --- /dev/null +++ b/apps/account/migrations/0002_alter_user_birthdate.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.4 on 2024-11-19 08:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='birthdate', + field=models.DateField(blank=True, null=True, verbose_name='birthdate'), + ), + ] diff --git a/apps/account/migrations/0003_auto_20241120_1741.py b/apps/account/migrations/0003_auto_20241120_1741.py new file mode 100644 index 0000000..fd0f54b --- /dev/null +++ b/apps/account/migrations/0003_auto_20241120_1741.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.4 on 2024-11-20 17:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('account', '0002_alter_user_birthdate'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={'ordering': ('-id',), 'verbose_name': 'All Users', 'verbose_name_plural': 'All Users'}, + ), + migrations.AddField( + model_name='user', + name='info', + field=models.TextField(blank=True, null=True, verbose_name='Info'), + ), + ] 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..6876ad1 --- /dev/null +++ b/apps/account/models/__init__.py @@ -0,0 +1,4 @@ + + +from .user import * +from .groups 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/user.py b/apps/account/models/user.py new file mode 100644 index 0000000..0015041 --- /dev/null +++ b/apps/account/models/user.py @@ -0,0 +1,84 @@ +import random +from dj_language.field import LanguageField +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils import timezone +from phonenumber_field.modelfields import PhoneNumberField +from utils.validators import validate_possible_number +from apps.account.manager import UserManager + + + +class User(AbstractUser): + class UserType(models.TextChoices): + PROFESSOR = 'professor', 'Professor' + CLIENT = 'client', 'Client' + STUDENT = 'student', "Student" + ADMIN = 'admin', 'Admin' + SUPER_ADMIN = 'super_admin', 'Super Admin' + + class GenderChoices(models.TextChoices): + MALE = 'male', 'Male' + FEMALE = 'female', 'Female' + + email = models.EmailField(unique=True, verbose_name="Email Address", help_text="Enter the user's email address.") + fullname = models.CharField(max_length=255, verbose_name="Full Name", help_text="Enter the full name of the user.") + birthdate = models.DateField(verbose_name=_('birthdate'), null=True, blank=True) + + avatar = models.ImageField(null=True, blank=True, upload_to='users/avatars/%Y/%m/') + phone_number = PhoneNumberField(unique=True, validators=[validate_possible_number], null=True, blank=True, verbose_name=_('phone')) + language = LanguageField(null=True) + username = None + last_name = None + first_name = None + gender = models.CharField( + max_length=20, choices=GenderChoices.choices, null=True, blank=True, verbose_name=_('Gender'), help_text="Select the user's gender." + ) + user_type = models.CharField( + max_length=20, + choices=UserType.choices, + default=UserType.CLIENT, + verbose_name="User Type", + help_text="Type of the user." + ) + device_id = models.CharField(verbose_name=_('device id'), max_length=255, null=True, blank=True) + fcm = models.CharField(max_length=512, null=True, blank=True) + date_joined = models.DateTimeField(auto_now_add=True, verbose_name="Date Joined", help_text="The date and time the user registered.") + is_staff = models.BooleanField(default=False) + is_active = models.BooleanField(default=True, verbose_name="Active", help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.") + deleted_at = models.DateTimeField(null=True, blank=True) + info = models.TextField(verbose_name="Info", null=True, blank=True) + objects = UserManager() + + + EMAIL_FIELD = "email" + USERNAME_FIELD = "email" + REQUIRED_FIELDS = ["fullname", ] + + def soft_delete(self): + self.deleted_at = timezone.now() + self.is_active = False + number = str(random.randint(1000000000, 9999999999)) # ایجاد یک عدد رندوم 10 رقمی + self.phone_number = f'{self.phone_number}:deleted{number}' + self.email = f'{self.email}:deleted{number}' if self.email else None + self.save() + + # def clean(self): + # super().clean() + # if self.email == "": + # # fix db uniqueness error bcz of django charfield null to empty string conversion + # self.email = None + + def __str__(self): + return f"{self.email} - {self.get_full_name()}" + + + def get_full_name(self): + return self.fullname + + + class Meta: + ordering = ("-id",) + verbose_name = "All Users" + verbose_name_plural = "All Users" 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..7e669dd --- /dev/null +++ b/apps/account/serializers/__init__.py @@ -0,0 +1,2 @@ + +from .user import * diff --git a/apps/account/serializers/user.py b/apps/account/serializers/user.py new file mode 100644 index 0000000..34aaf63 --- /dev/null +++ b/apps/account/serializers/user.py @@ -0,0 +1,152 @@ + +from rest_framework import serializers +from rest_framework.authtoken.models import Token +from django.contrib.auth.password_validation import validate_password +from django.utils.translation import gettext_lazy as _ +from apps.account.models import User +from utils import FileFieldSerializer, absolute_url +from utils.validators import validate_type_code + + + +class UserProfileSerializer(serializers.ModelSerializer): + avatar = FileFieldSerializer(required=False) + password = serializers.CharField(write_only=True, required=False, validators=[validate_password]) + fullname = serializers.CharField(required=False) + class Meta: + model = User + fields = ['id', 'fullname', 'avatar', 'email', 'phone_number', 'password', 'info'] + read_only_fields = ['email', 'info'] + + # def validate_email(self, value): + # if User.objects.filter(email=value).exists(): + # raise serializers.ValidationError("This email is already registered.") + # return value + + def update(self, instance, validated_data): + password = validated_data.pop('password', None) + if password: + instance.set_password(password) + # Update other fields + for attr, value in validated_data.items(): + if value is not None: + setattr(instance, attr, value) + + instance.save() + return instance + + +class UserRegisterSerializer(serializers.ModelSerializer): + password_confirmation = serializers.CharField(write_only=True) + fcm = serializers.CharField(required=False) + device_id = serializers.CharField(required=False) + email = serializers.EmailField() + + class Meta: + model = User + fields = ['id','fullname', 'email', 'password', 'password_confirmation', 'fcm', 'device_id'] + extra_kwargs = { + 'fullname': {'required': True,}, + 'email': {'required': True,}, + 'password': {'required': True,}, + 'password_confirmation': {'required': True,}, + } + + def validate_email(self, value): + if User.objects.filter(email=value).exists(): + raise serializers.ValidationError("This email is already registered.") + return value + + + def validate(self, data): + password = data.get('password') + password_confirmation = data.get('password_confirmation') + if password and password_confirmation and password != password_confirmation: + raise serializers.ValidationError("Passwords do not match.") + if len(password) < 8: + raise serializers.ValidationError("Password must be at least 8 characters long.") + + data.pop('password_confirmation', None) + data.pop('fcm', None) + data.pop('device_id', None) + return data + + + +class UserVerifySerializer(serializers.ModelSerializer): + code = serializers.CharField(max_length=5, validators=[validate_type_code]) + email = serializers.EmailField() + + class Meta: + model = User + fields = ["email", "code"] + extra_kwargs = { + 'email': {'required': True,}, + 'code': {'required': True,}, + } + + +class UserLoginSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + token = serializers.CharField(allow_null=True, read_only=True, required=False) + fullname = serializers.CharField(allow_null=True, read_only=True, required=False) + avatar = serializers.CharField(allow_null=True, read_only=True, required=False) + email = serializers.EmailField(write_only=True) + password = serializers.CharField(style={'input_type': 'password'}, trim_whitespace=False) + fcm = serializers.CharField(required=False) + device_id = serializers.CharField(required=False) + + + class Meta: + model = User + fields = ['id', 'phone_number', 'password', 'fullname', 'avatar', 'email', 'token', 'fcm', 'device_id'] + + def get_token(self, obj): + token, created = Token.objects.get_or_create(user=obj) + return token.key + + def validate(self, data): + data.pop('fcm', None) + data.pop('device_id', None) + return data + + + + +class UserRecoverPasswordSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + + class Meta: + model = User + fields = ['email',] + extra_kwargs = { + 'email': {'required': True,}, + } + + +class UserResetPasswordSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True) + password_confirmation = serializers.CharField(write_only=True) + + class Meta: + model = User + fields = ['password', 'password_confirmation'] + extra_kwargs = { + 'password': {'required': True,}, + 'password_confirmation': {'required': True,}, + } + + + def validate(self, data): + password = data.get('password') + password_confirmation = data.get('password_confirmation') + if password and password_confirmation and password != password_confirmation: + raise serializers.ValidationError("Passwords do not match.") + if len(password) < 8: + raise serializers.ValidationError("Password must be at least 8 characters long.") + + data.pop('password_confirmation', None) + + return data + + 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/tests.py b/apps/account/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/apps/account/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/account/urls.py b/apps/account/urls.py new file mode 100644 index 0000000..9f3229d --- /dev/null +++ b/apps/account/urls.py @@ -0,0 +1,35 @@ + +from django.urls import path, include + +from rest_framework.routers import DefaultRouter + +from apps.account import views + + + +urlpatterns = [ + # URL for user registration, accepts POST requests for creating new user instances. + + path('register/', views.UserRegisterView.as_view(), name='user-register'), + path('verify/', views.UserVerifyView.as_view(), name='user-verify'), + path('login/', views.UserLoginView.as_view(), name='user-login'), + + + # path('notif/', views.NotificationListView.as_view(), name='user-notif'), + # path('notif/read', views.NotificationReadAllView.as_view(), name='user-notif-read-all'), + + + # # URL to get user details, supports GET for fetching user profile based on the provided token. + path('profile/', views.UserProfileView.as_view(), name='user-profile'), + + path('recover/', views.UserRecoverPassword.as_view(), name='user-recover'), + path('reset/', views.UserResetPassword.as_view(), name='user-reset'), + + + # # URL to update user details, supports PUT to update user fields like phone or email given a token. + path('profile/update/', views.UserUpdateView.as_view(), name='user-update'), + + # # delete user account + path('profile/delete/', views.UserDeleteView.as_view(), name='user-delete'), + +] \ 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..f4a2da0 --- /dev/null +++ b/apps/account/views/__init__.py @@ -0,0 +1 @@ +from .user import * diff --git a/apps/account/views/user.py b/apps/account/views/user.py new file mode 100644 index 0000000..54b2540 --- /dev/null +++ b/apps/account/views/user.py @@ -0,0 +1,238 @@ +import logging +import requests +import json +from rest_framework.generics import CreateAPIView, RetrieveUpdateAPIView, GenericAPIView, RetrieveAPIView, UpdateAPIView, ListAPIView +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.authtoken.models import Token +from rest_framework.exceptions import AuthenticationFailed +from django.utils.translation import gettext_lazy as _ +from django.shortcuts import get_object_or_404 +from rest_framework.authtoken.models import Token + +from django.utils import timezone +from rest_framework.authentication import TokenAuthentication +from django.contrib.auth import authenticate +from phonenumbers import parse, region_code_for_number +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from utils.exceptions import InvaliedCodeVrify, ExpiredCodeException, ServiceUnavailableException +from apps.account.models import User +from apps.account.serializers import UserRegisterSerializer, UserProfileSerializer, UserVerifySerializer, UserLoginSerializer, UserRecoverPasswordSerializer, UserResetPasswordSerializer +from utils.redis import RedisManager +from utils import send_email, is_valid_email +from config.settings import base as settings +from apps.account.permissions import IsActiveUser + +logger = logging.getLogger(__name__) + + + + + +class UserRegisterView(CreateAPIView): + permission_classes = [AllowAny] + serializer_class = UserRegisterSerializer + + + @swagger_auto_schema( + request_body=UserRegisterSerializer, + responses={201: 'User registered successfully', 400: 'Bad request'} + ) + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + + code = RedisManager.generate_otp_code() + logger.info(f"phone= {data['email']}") + print(f' send {code}/{data["email"]}') + phone_number = RedisManager().add_to_redis(code, **data) + + send_email([data['email']], code) + password = data.pop('password') + return Response( + data= { + "user": data, + "message": "The otp code was sent to the user's email" + }, + status=status.HTTP_202_ACCEPTED, + ) + + + +class UserVerifyView(CreateAPIView): + permission_classes = [AllowAny] + serializer_class = UserVerifySerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + try: + verify_data = RedisManager().get_by_redis(data['email']) + if not verify_data: + raise ExpiredCodeException("Verification data not found or expired.") + except (ServiceUnavailableException) as e: + return Response({"detail": str(e)}, status=e.status_code) + except ExpiredCodeException: + raise ExpiredCodeException("The verification code has expired.") + + + code = self.valied_code(data['code'], verify_data['code']) + del verify_data['code'] + user = self.perform_create( + email=serializer.data['email'],**verify_data + ) + Token.objects.filter(user=user).delete() + token = Token.objects.create(user=user) + return Response(data={ + 'token': str(token), + 'user_id': user.id, + 'phone_number': str(user.phone_number), + 'email': str(user.email), + 'fullname': str(user.fullname), + 'avatar': str(user.avatar) if user.avatar else None + }, status=status.HTTP_201_CREATED) + + def valied_code(self, current_code, save_code): + if (current_code and save_code) and ( current_code != save_code): + raise InvaliedCodeVrify() + return current_code + + def perform_create(self, *args, **kwargs): + email = kwargs.get('email') + user = User.objects.filter(email=email).first() + if user: + if kwargs['password']: + user.is_active = True + user.deletion_date = None + user.last_login = timezone.now() + user.set_password(kwargs['password']) + user.save() + else: + user = User.objects.create(**kwargs) + user.set_password(kwargs['password']) + user.last_login = timezone.now() + user.is_active = True + user.save() + + return user + + +class UserLoginView(CreateAPIView): + permission_classes = [AllowAny] + serializer_class = UserLoginSerializer + + def create(self, request, *args, **kwargs): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + user = authenticate(request, username=request.data['email'], password=data['password']) + if not user: + raise AuthenticationFailed(_('Unable to log in with provided credentials.')) + user.last_login = timezone.now() + user.is_active = True + user.save + token, created = Token.objects.get_or_create(user=user) + serializer_data = serializer.data + serializer_data['token'] = token.key + + return Response({ + "id": user.id, + "fullname": user.fullname, + "email": user.email, + "token": token.key, + "avatar": request.build_absolute_uri(user.avatar.url) if user.avatar else None, + }, status=status.HTTP_201_CREATED) + + +class UserProfileView(RetrieveAPIView): + serializer_class = UserProfileSerializer + permission_classes = [IsAuthenticated, IsActiveUser] + queryset = User.objects.all() + + def get_object(self): + return self.request.user + + +class UserUpdateView(UpdateAPIView): + permission_classes = [IsAuthenticated, IsActiveUser] + serializer_class = UserProfileSerializer + + def get_object(self): + return self.request.user + + +class UserRecoverPassword(CreateAPIView): + serializer_class = UserRecoverPasswordSerializer + + def post(self, request): + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.data + user = get_object_or_404(User, email=data['email']) + code = RedisManager.generate_otp_code() + print(f' send {code}') + phone_number = RedisManager().add_to_redis(code, fullname=str(user.fullname), password='', email=data['email']) + + send_email([data['email']], code) + + return Response( + data= { + "id": user.id, + "fullname": user.fullname, + "phone_number": str(user.phone_number), + "email": user.email if user.email else None, + "avatar": user.avatar if user.avatar else None, + "message": "Forgot password code sent" + }, + status=status.HTTP_202_ACCEPTED, + ) + + +class UserResetPassword(CreateAPIView): + serializer_class = UserResetPasswordSerializer + permission_classes = [IsAuthenticated] + + def post(self, request, *args, **kwargs): + # Get the logged-in user + user = request.user + + # Use the serializer to validate data + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Set the new password + user.set_password(serializer.validated_data['password']) + user.save() + + # Return a success response + return Response({"message": "Your password has been changed successfully."}, status=status.HTTP_200_OK) + + + + +class UserDeleteView(APIView): + permission_classes = [IsAuthenticated] + + def delete(self, request, *args, **kwargs): + try: + user = request.user + if user.email == "admin@gmail.com": + return Response({"detail": "admin"}, status=status.HTTP_204_NO_CONTENT) + + user.soft_delete() + if t := Token.objects.filter(user=user).first(): + t.delete() + + return Response({"detail": "Your account has been deleted."}, status=status.HTTP_204_NO_CONTENT) + + except Exception: + # پیام خطای ثابت برای سایر خطاهای غیرمنتظره + return Response({"detail": "User does not exist."}, status=status.HTTP_404_NOT_FOUND) + + 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..8c38f3f --- /dev/null +++ b/apps/api/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. 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..dc60af2 --- /dev/null +++ b/apps/api/urls.py @@ -0,0 +1,9 @@ + +from django.urls import path +from .views import HomeView + + + +urlpatterns = [ + path('', HomeView.as_view()) +] diff --git a/apps/api/views.py b/apps/api/views.py new file mode 100644 index 0000000..b044e94 --- /dev/null +++ b/apps/api/views.py @@ -0,0 +1,33 @@ +import random +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response +from rest_framework import serializers + +from rest_framework.authtoken.models import Token +from apps.account.models import User + +class HomeSerializer(serializers.Serializer): + token = serializers.CharField() + +# test class generate token +class HomeView(GenericAPIView): + serializer_class = HomeSerializer + + def get(self, request): + emails = ["zahra@gmail.com", "john.doe@example.com", "alice@example.com"] + phone_numbers = ["09012037621", "09012037615", "09012045432"] + fullnames = ["Alireza", "John Doe", "Alice Smith"] + # انتخاب رندوم از هر لیست + email = random.choice(emails) + phone_number = random.choice(phone_numbers) + fullname = random.choice(fullnames) + # ساخت کاربر جدید + user = User.objects.create( + email=email, + phone_number=phone_number, + fullname=fullname, + ) + # ایجاد توکن برای کاربر + token, created = Token.objects.get_or_create(user=user) + + return Response({'token': token.key}) 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..6e8ef48 --- /dev/null +++ b/apps/course/admin/__init__.py @@ -0,0 +1,2 @@ +from .course import * +from .lesson 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..c2313ad --- /dev/null +++ b/apps/course/admin/course.py @@ -0,0 +1,87 @@ +from django.contrib import admin +from ajaxdatatable.admin import AjaxDatatable + +from apps.course.models import Course, Glossary, Attachment, CourseCategory + + + + +@admin.register(CourseCategory) +class CourseCategoryAdmin(admin.ModelAdmin): + list_display = ('name', 'slug') + search_fields = ('name',) + exclude = ('slug', ) + + + + +@admin.register(Course) +class CourseAdmin(AjaxDatatable): + list_display = ('title', 'category', 'level', 'status', 'final_price', 'is_online') + list_filter = ('status', 'level', 'is_online', 'is_free', 'category') + search_fields = ('title', 'description') + exclude = ('slug', ) + + + + + +@admin.register(Glossary) +class GlossaryAdmin(admin.ModelAdmin): + list_display = ('title', 'course', 'description') + list_filter = ('course',) + search_fields = ('title', 'description', 'course__title') + ordering = ('-id',) + + + +from django import forms +import hashlib +import os + + +class AttachmentAdminForm(forms.ModelForm): + class Meta: + model = Attachment + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if 'file' in self.data or 'file' in self.files: + file = self.files.get('file') + if file: + file.name = self._shorten_file_name(file.name) + + + def _shorten_file_name(self, file_name): + max_length = 100 + if len(file_name) > max_length: + base_name, ext = os.path.splitext(file_name) # جدا کردن نام و پسوند + allowed_length = max_length - len(ext) # طول مجاز نام بدون پسوند + + # 80٪ از نام اصلی و 20٪ هش + base_length = int(allowed_length * 0.8) # 80٪ از طول مجاز + hash_length = allowed_length - base_length # 20٪ از طول مجاز + + base_part = base_name[:base_length] # 80٪ اول نام اصلی + hash_part = hashlib.sha256(base_name.encode('utf-8')).hexdigest()[:hash_length] # 20٪ هش + + return f"{base_part}{hash_part}{ext}" # ترکیب بخش اصلی و هش با پسوند + + return file_name + + +@admin.register(Attachment) +class AttachmentAdmin(admin.ModelAdmin): + form = AttachmentAdminForm + list_display = ('title', 'course', 'file', 'file_size') + list_filter = ('course',) + search_fields = ('title', 'file', 'course__title') + + def save_model(self, request, obj, form, change): + if obj.file: + obj.file_size = obj.file.size + super().save_model(request, obj, form, change) + + diff --git a/apps/course/admin/lesson.py b/apps/course/admin/lesson.py new file mode 100644 index 0000000..0e78236 --- /dev/null +++ b/apps/course/admin/lesson.py @@ -0,0 +1,18 @@ + +from django.contrib import admin +from apps.course.models import Lesson + + + + +@admin.register(Lesson) +class LessonAdmin(admin.ModelAdmin): + list_display = ('title', 'course', 'priority', 'duration', 'content_type') + list_filter = ('course', 'content_type') + search_fields = ('title', 'course__title') + ordering = ('priority', 'title') + + def get_queryset(self, request): + qs = super().get_queryset(request) + return qs.order_by('priority') + \ No newline at end of file diff --git a/apps/course/apps.py b/apps/course/apps.py new file mode 100644 index 0000000..ddc8553 --- /dev/null +++ b/apps/course/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CourseConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.course' 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..6e8ef48 --- /dev/null +++ b/apps/course/models/__init__.py @@ -0,0 +1,2 @@ +from .course import * +from .lesson 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..b45ac89 --- /dev/null +++ b/apps/course/models/course.py @@ -0,0 +1,166 @@ +import os +from decimal import Decimal +import math +from django.db import models +from django.db.models import TextChoices +from django.utils.translation import gettext_lazy as _ +from filer.fields.image import FilerImageField +from filer.fields.file import FilerFileField + +from apps.account.models import ProfessorUser +from utils.schema import default_timing +from utils import generate_slug_for_model + + + +def course_file_upload_to(instance, filename): + return os.path.join(f"courses/{instance.slug}/videos/{filename}") + + + +def attachment_file_upload_to(instance, filename): + return os.path.join(f"courses/{instance.course.slug}/attachments/{filename}") + + + + +class CourseCategory(models.Model): + name = models.CharField(max_length=255, verbose_name='Category Name') + slug = models.SlugField(unique=True, max_length=255) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + self.slug = generate_slug_for_model(CourseCategory, self.name) + super().save(*args, **kwargs) + + @property + def course_count(self): + return self.courses.count() + + + +class Course(models.Model): + + class LevelChoices(TextChoices): + BEGINNER = 'beginner', 'Beginner' + MID = 'mid', 'Mid Level' + ADVANCED = 'advanced', 'Advanced' + + class StatusChoices(TextChoices): + INACTIVE = 'inactive', 'Inactive' # Not Active (does not show) + UPCOMING = 'upcoming', 'Upcoming' # Upcoming (visible but registration not allowed) + REGISTERING = 'registering', 'Registering' # Registering (registration is open) + ONGOING = 'ongoing', 'Ongoing' # Ongoing (course has started, registration closed) + FINISHED = 'finished', 'Finished' # Finished (course has ended) + + class VedioTypeChoices(models.TextChoices): + VIDEO_FILE = 'video_file', 'Video File' + VIDEO_LINK = 'video_link', 'Video Link' + + + title = models.CharField(max_length=255, verbose_name='Course Title') + slug = models.SlugField(allow_unicode=True, unique=True) + category = models.ForeignKey(CourseCategory, on_delete=models.CASCADE, related_name='courses', verbose_name='Category') + professor = models.ForeignKey( + ProfessorUser, + on_delete=models.CASCADE, + related_name="courses" + ) + + thumbnail = FilerImageField( + related_name='+', on_delete=models.PROTECT, null=True, blank=True, + verbose_name=_('thumbnail') + ) + video_type = models.CharField(max_length=20, choices=VedioTypeChoices.choices, verbose_name='Vedio Type') + video_file = models.FileField( + upload_to=course_file_upload_to, + null=True, + blank=True + ) + video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name='Video Link') + + is_online = models.BooleanField(default=True, verbose_name='Is Online Course') + level = models.CharField(max_length=10, choices=LevelChoices.choices, verbose_name='Course Level') + duration = models.PositiveIntegerField(verbose_name='Duration (in hours)') + lessons_count = models.PositiveIntegerField(verbose_name='Number of Lessons') + + description = models.TextField(verbose_name='Course Description') + short_description = models.CharField(max_length=500, blank=True, null=True, verbose_name="Short Description") + status = models.CharField(max_length=15, choices=StatusChoices.choices, default=StatusChoices.INACTIVE, verbose_name='Course Status') + is_free = models.BooleanField(default=True, verbose_name='Is Free') + price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, verbose_name='Course Price') + discount_percentage = models.PositiveIntegerField(default=0, verbose_name='Discount Percentage') + final_price = models.DecimalField( + verbose_name=_('Course Final Price'), decimal_places=2, max_digits=10, default=0.00, blank=True, + help_text=_('This field is automatically calculated based on the discount percentage.') + ) + + timing = models.JSONField(blank=True, null=True, default=default_timing, verbose_name=_("Timing"), help_text=_("The Timing information in JSON format.")) + features = models.JSONField(verbose_name=_('Course features'), default=dict, blank=True, null=True) + + + def __str__(self): + return self.title + + + def save(self, *args, **kwargs): + self.slug = generate_slug_for_model(Course, self.title) + + if self.discount_percentage > 0: + discount_amount = (self.price * self.discount_percentage) / 100 + final_price = self.price - discount_amount + self.final_price = Decimal(math.ceil(final_price)).quantize(Decimal('0.00')) + else: + self.final_price = Decimal(math.ceil(self.price)).quantize(Decimal('0.00')) + + super().save(*args, **kwargs) + + + class Meta: + verbose_name = "Course" + verbose_name_plural = "Courses" + + + +class Glossary(models.Model): + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='glossaries', verbose_name='Course') + title = models.CharField(max_length=555, verbose_name='Glossary Title') + description = models.TextField(verbose_name='Description') + + def __str__(self): + return f"{self.course.title} - {self.title}" + + + class Meta: + ordering = ("-id",) + verbose_name = "Glossary" + verbose_name_plural = "Glossary" + + + +class Attachment(models.Model): + course = models.ForeignKey(Course, on_delete=models.CASCADE, related_name='attachments', verbose_name='Course') + title = models.CharField(max_length=255, verbose_name='Attachment Title') + file = models.FileField( + upload_to=attachment_file_upload_to, + verbose_name='Attachment File' + ) + + file_size = models.PositiveIntegerField(verbose_name='File Size (in bytes)', null=True, blank=True) + + def save(self, *args, **kwargs): + # Calculate the file size before saving + if self.file and not self.file_size: + self.file_size = self.file.size + super().save(*args, **kwargs) + + + def __str__(self): + return f"{self.course.title} - {self.title}" + + class Meta: + ordering = ("-id",) + verbose_name = "Attachment" + verbose_name_plural = "Attachments" \ 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..e5d9084 --- /dev/null +++ b/apps/course/models/lesson.py @@ -0,0 +1,34 @@ +import os +from django.db import models + +from filer.fields.image import FilerImageField +from filer.fields.file import FilerFileField + + + +def lesson_file_upload_to(instance, filename): + return os.path.join(f"courses/{instance.course.slug}/lessons/{filename}") + + + + +class Lesson(models.Model): + class ContentTypeChoices(models.TextChoices): + LINK = 'link', 'Link' + FILE = 'file', 'File' + + course = models.ForeignKey("course.Course", on_delete=models.CASCADE, related_name='lessons', verbose_name='Course') + title = models.CharField(max_length=255, verbose_name='Lesson Title') + priority = models.IntegerField(null=True, blank=True, verbose_name='Priority') + duration = models.PositiveIntegerField(verbose_name='Duration (in minutes)') + content_type = models.CharField(max_length=10, choices=ContentTypeChoices.choices, verbose_name='Content Type') + content_file = models.FileField( + null=True, + blank=True, + upload_to=lesson_file_upload_to, + ) + video_link = models.CharField(max_length=500, null=True, blank=True, verbose_name='Video Link') + + def __str__(self): + return f"{self.course.title} - {self.title}" + diff --git a/apps/course/serializers/__init__.py b/apps/course/serializers/__init__.py new file mode 100644 index 0000000..bc1d8a6 --- /dev/null +++ b/apps/course/serializers/__init__.py @@ -0,0 +1 @@ +from .course 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..bf51772 --- /dev/null +++ b/apps/course/serializers/course.py @@ -0,0 +1,107 @@ +from rest_framework import serializers + +from dj_filer.admin import get_thumbs +from apps.course.models import Course, CourseCategory, Attachment, Glossary +from apps.account.serializers import UserProfileSerializer + + + + +class CourseCategorySerializer(serializers.ModelSerializer): + course_count = serializers.SerializerMethodField() + + class Meta: + model = CourseCategory + fields = ['name', 'slug', 'course_count'] + + def get_course_count(self, obj): + # return obj.course_count + return 25 + + +class CourseListSerializer(serializers.ModelSerializer): + category = CourseCategorySerializer() + thumbnail = serializers.SerializerMethodField() + participant_count = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = [ + 'id', + 'title', + 'slug', + 'participant_count', + 'category', + 'thumbnail', + 'is_online', + 'level', + 'duration', + 'lessons_count', + 'short_description', + 'status', + 'is_free', + 'price', + 'discount_percentage', + 'final_price', + ] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + def get_participant_count(self, obj): + return 120 + + + +class CourseDetailSerializer(serializers.ModelSerializer): + category = CourseCategorySerializer() + professor = UserProfileSerializer() + thumbnail = serializers.SerializerMethodField() + participant_count = serializers.SerializerMethodField() + + class Meta: + model = Course + fields = [ + 'id', + 'title', + 'slug', + 'category', + 'participant_count', + 'professor', + 'thumbnail', + 'video_type', + 'video_file', + 'video_link', + 'is_online', + 'level', + 'duration', + 'lessons_count', + 'short_description', + 'status', + 'is_free', + 'price', + 'discount_percentage', + 'final_price', + 'timing', + 'features', + ] + + def get_thumbnail(self, obj): + return get_thumbs(obj.thumbnail, self.context.get('request')) + + def get_participant_count(self, obj): + return 120 + + + + +class AttachmentSerializer(serializers.ModelSerializer): + class Meta: + model = Attachment + fields = ['id', 'title', 'file', 'file_size'] + + +class GlossarySerializer(serializers.ModelSerializer): + class Meta: + model = Glossary + fields = ['id', 'title', 'description'] \ No newline at end of file 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..ab0de13 --- /dev/null +++ b/apps/course/urls.py @@ -0,0 +1,15 @@ + +from django.urls import path + +from . import views + + + +urlpatterns = [ + path('categories/', views.CourseCategoryAPIView.as_view(), name='course-categories'), + path('', views.CourseListAPIView.as_view(), name='course-list'), + path('/', 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'), + +] diff --git a/apps/course/views/__init__.py b/apps/course/views/__init__.py new file mode 100644 index 0000000..bc1d8a6 --- /dev/null +++ b/apps/course/views/__init__.py @@ -0,0 +1 @@ +from .course 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..20a4d20 --- /dev/null +++ b/apps/course/views/course.py @@ -0,0 +1,98 @@ +from rest_framework.generics import ListAPIView, RetrieveAPIView +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi +from rest_framework.exceptions import NotFound + + +from apps.course.serializers import ( + CourseListSerializer, CourseCategorySerializer, CourseDetailSerializer, + AttachmentSerializer, GlossarySerializer +) +from apps.course.models import Course, CourseCategory, Attachment, Glossary + + + +class CourseCategoryAPIView(ListAPIView): + queryset = CourseCategory.objects.all() + serializer_class = CourseCategorySerializer + + + + +class CourseListAPIView(ListAPIView): + queryset = Course.objects.all().exclude(status=Course.StatusChoices.INACTIVE) + serializer_class = CourseListSerializer + # filterset_fields = ['category__slug',] + + + @swagger_auto_schema(manual_parameters=[ + openapi.Parameter( + 'category_slug', openapi.IN_QUERY, + description="Category of the Course", + type=openapi.TYPE_STRING, + enum=[category.slug for category in CourseCategory.objects.all()] + ), + ]) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + queryset = super().get_queryset() + request = self.request + filters = request.query_params + if category := filters.get('category_slug'): + queryset = queryset.filter(category__slug=category) + + return queryset + + + + +class CourseDetailAPIView(RetrieveAPIView): + queryset = Course.objects.all() + serializer_class = CourseDetailSerializer + lookup_field = "slug" + + + + + +class AttachmentListAPIView(ListAPIView): + serializer_class = AttachmentSerializer + + @swagger_auto_schema( + manual_parameters=[ + openapi.Parameter( + 'slug', openapi.IN_PATH, + description="Slug of the Course", + type=openapi.TYPE_STRING, + required=True + ) + ], + operation_description="Retrieve a list of attachments for a given course by its slug." + ) + def get(self, request, *args, **kwargs): + return super().get(request, *args, **kwargs) + + def get_queryset(self): + course_slug = self.kwargs.get('slug') + try: + course = Course.objects.get(slug=course_slug) + except Course.DoesNotExist: + raise NotFound("Course not found") + return Attachment.objects.filter(course=course) + + + + +class GlossaryListAPIView(ListAPIView): + serializer_class = GlossarySerializer + + def get_queryset(self): + course_slug = self.kwargs.get('slug') + try: + course = Course.objects.get(slug=course_slug) + except Course.DoesNotExist: + raise NotFound("Course not found") + + return Glossary.objects.filter(course=course) \ No newline at end of file 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.py b/apps/quiz/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/apps/quiz/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/apps/quiz/apps.py b/apps/quiz/apps.py new file mode 100644 index 0000000..3dc8afe --- /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 = 'quiz' 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..e69de29 diff --git a/apps/quiz/models/participant.py b/apps/quiz/models/participant.py new file mode 100644 index 0000000..8454bb5 --- /dev/null +++ b/apps/quiz/models/participant.py @@ -0,0 +1,68 @@ +from django.db import models + +from apps.account.models import User + + + + +class Participant(models.Model): + quiz = models.ForeignKey('quiz.Quiz', on_delete=models.CASCADE, related_name='participants') + user = models.ForeignKey('account.User', on_delete=models.CASCADE, verbose_name='user', related_name='uquizzes') + started_at = models.DateTimeField(verbose_name='started at') + ended_at = models.DateTimeField(verbose_name='ended at') + total_timing = models.PositiveIntegerField(help_text='Seconds take to finish the quiz') + + question_score = models.PositiveIntegerField() + timing_score = models.PositiveIntegerField() + total_score = models.PositiveIntegerField() + + class Meta: + verbose_name = "Participant" + verbose_name_plural = "Participants" + ordering = ("-id",) + + def __str__(self): + return f"Participant: {self.id}, ParticipantName: {self.user}, Quiz: {self.quiz.id}" + + def __repr__(self): + return f"Participant(id={self.id})" + + + @staticmethod + def get_user_ranks(quiz_id): + return Participant.objects.filter(quiz_id=quiz_id).annotate( + rank=Window( + expression=Rank(), + order_by=F('total_score').desc() + ) + ) + + + +class ParticipantAnswer(models.Model): + CHOICES = [ + (1, 'Option 1'), + (2, 'Option 2'), + (3, 'Option 3'), + (4, 'Option 4'), + ] + + participant = models.ForeignKey(Participant, on_delete=models.CASCADE, related_name='answers') + question = models.ForeignKey("quiz.Question", on_delete=models.CASCADE) + option_num = models.PositiveSmallIntegerField(choices=CHOICES, verbose_name='selected option') + at_time = models.DateTimeField() + answer_timing = models.PositiveSmallIntegerField(default=0, verbose_name='seconds take to answer') + + + class Meta: + verbose_name = "User Quiz Answer" + verbose_name_plural = "User Quiz Answers" + ordering = ("-id",) + + def __str__(self): + return f"Participant Answer: {self.id}" + + def __repr__(self): + return f"ParticipantAnswer(id={self.id})" + + diff --git a/apps/quiz/models/quiz.py b/apps/quiz/models/quiz.py new file mode 100644 index 0000000..ec9972c --- /dev/null +++ b/apps/quiz/models/quiz.py @@ -0,0 +1,53 @@ +from django.db import models + + + +class Quiz(models.Model): + course = models.ForeignKey("course.Course", verbose_name='course', related_name='quizzes', on_delete=models.CASCADE) + each_question_timing = models.PositiveIntegerField() + status = models.BooleanField(default=True) + + + class Meta: + verbose_name = "Quiz" + verbose_name_plural = "Quizzes" + ordering = ("-id",) + + def __str__(self): + return f"Quiz: {self.id}" + + def __repr__(self): + return f"Quiz(id={self.id})" + + + + +class Question(models.Model): + CHOICES = [ + (1, 'Option 1'), + (2, 'Option 2'), + (3, 'Option 3'), + (4, 'Option 4'), + ] + + quiz = models.ForeignKey(Quiz, verbose_name='quiz', on_delete=models.CASCADE, related_name='questions') + question = models.CharField(max_length=255) + option1 = models.CharField(max_length=255, verbose_name='option 1') + option2 = models.CharField(max_length=255, verbose_name='option 2') + option3 = models.CharField(max_length=255, verbose_name='option 3') + option4 = models.CharField(max_length=255, verbose_name='option 4') + correct_answer = models.PositiveSmallIntegerField(choices=CHOICES) + created_at = models.DateTimeField(auto_now_add=True, verbose_name='created at') + priority = models.IntegerField(null=True, blank=True) + + + class Meta: + verbose_name = "Question" + verbose_name_plural = "Questions" + ordering = ("-priority", "-id",) + + def __str__(self): + return self.question + + def __repr__(self): + return f"Question(id={self.id})" 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/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/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..49787e0 --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,308 @@ +""" +Django settings for backend project. + +Generated by 'django-admin startproject' using Django 5.0.4. + +For more information on this file, see +https://docs.djangoproject.com/en/5.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/5.0/ref/settings/ +""" +import os +from pathlib import Path + +import environ +from django.utils.translation import gettext_lazy as _ + + +env = environ.Env( + # set casting, default value + # DEBUG=(bool, False) +) +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +environ.Env.read_env(os.path.join(BASE_DIR, '.env')) + +ALLOWED_HOSTS = env('DJANGO_ALLOWED_HOSTS').split(',') + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'django-insecure-7=3it+m^28^+0c1*9-*c*6g3ej63sz(97rq1^mp=!6e(mhmysh' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +X_FRAME_OPTIONS = 'SAMEORIGIN' + +LOCAL_APPS = [ + 'apps.account.apps.AccountConfig', + 'apps.api.apps.ApiConfig', + 'apps.course.apps.CourseConfig', +] + +THIRD_PARTY_APPS = [ + 'rest_framework', + 'rest_framework.authtoken', + 'drf_yasg', + 'easy_thumbnails', + 'phonenumber_field', + 'dj_language', + 'dj_filer', + 'ajaxdatatable', + 'corsheaders', + 'django_filters', + +] +INSTALLED_APPS = [ + 'limitless_dashboard.apps.DashboardConfig', + # 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + *THIRD_PARTY_APPS, + *LOCAL_APPS, + +] +AUTHENTICATION_BACKENDS = [ + 'django.contrib.auth.backends.ModelBackend', # این خط را نگه دارید تا احراز هویت پیش‌فرض کار کند + 'apps.account.custom_user_login.CustomLoginBackend', # مسیر به کلاس سفارشی خود +] + +REDIS_URL = env('REDIS_URL') + + + +OTP_SERIVCE_KEY = "33213d78f1234e99b81f94eefda77e45" + + +PHONENUMBER_DEFAULT_REGION = "IR" +PHONENUMBER_DB_FORMAT = 'INTERNATIONAL' +PHONENUMBER_DEFAULT_FORMAT = 'INTERNATIONAL' + +AUTH_USER_MODEL = "account.User" + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'config.language_code_middleware.language_middleware', + 'config.test_auth_middleware.test_auth_middleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [ + BASE_DIR / 'templates', + ], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'django.template.context_processors.i18n', + + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + + +# django google recaptcha default keys +RECAPTCHA_PUBLIC_KEY = env('captcha_public_key') +RECAPTCHA_PRIVATE_KEY = env('captcha_private_key') + +# custom settings +APPS_REORDER = { + 'auth': { + 'icon': 'icon-shield-check', + 'name': 'Authentication' + }, + 'account': { + # 'icon': 'icon-', + 'name': 'account' + + } +} +# Database +# https://docs.djangoproject.com/en/5.0/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': env('POSTGRES_DB'), + 'USER': env('POSTGRES_USER'), + 'PASSWORD': env('POSTGRES_PASSWORD'), + 'HOST': env('POSTGRES_HOST'), + 'PORT': env('POSTGRES_PORT'), + 'ATOMIC_REQUESTS': True, + }, +} + + +CORS_ALLOW_ALL_ORIGINS = True + +THUMBNAIL_ALIASES = { + '': { + 'icon': {'size': (50, 50), 'crop': True}, + 'large': {'size': (1200, 620), 'crop': False}, + 'medium': {'size': (545, 545), 'crop': False}, + 'small': {'size': (150, 150), 'crop': False}, + }, +} + +LANGUAGES_MAP = { + 'az': ['az', 'tr', 'fa', 'ar'], + 'tr': ['tr', 'az', 'fa', 'ar'], + 'ru': ['ru', 'az', 'tr', 'fa', 'ar'], + 'ar': ['ar', 'fa'], + 'ur': ['ur', 'en', 'fa', 'ar'], + 'en': ['en', 'ur', 'fa', 'ar'], + 'de': ['de', 'en', 'fr', 'es', 'ar'], + 'fa': ['fa', 'az', 'ar', 'en', 'ur'], + + 'fr': ['fr', 'en', 'ar', 'fa'], + 'es': ['es', 'en', 'ar', 'fa'], + 'id': ['id', 'en', 'ar', 'fa'], + 'sw': ['sw', 'en', 'ar', 'fa'], +} + + +LANGUAGES = [ + ('ar', _('Arabic')), + ('az', _('Azerbaijani')), + ('fr', _('French')), + ('in', _('Indonesia')), + ('fa', _('Persian')), + ('ru', _('Russia')), + ('es', _('Spanish')), + ('sw', _('Swahili')), + ('tr', _('Turkish')), + ('de', _('German')), + ('en', _('English')), + ('fa', _('Persian')), + ('ur', _('Urdu')), + ('zh', _('Mandarin')), + ('zh', _('Chinese')), + ('he', _('Hebrew')), + ('he', _('Hebrew')), + ('bn', _('Bengali')), +] + +CELERY_BROKER_URL = env("REDIS_URL") +CELERY_RESULT_BACKEND = env("REDIS_URL") +CELERY_ACCEPT_CONTENT = ['application/json'] +CELERY_TIMEZONE = 'Asia/Tehran' +CELERY_BROKER_TRANSPORT = 'redis' +CELERY_ACCEPT_CONTENT = ['json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_BROKER_CONNECTION_RETRY_ON_STARTUP = True + +# Password validation +# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators + + +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 6, + } + }, +] + + +REST_FRAMEWORK = { + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', + 'PAGE_SIZE': 16, + # 'DEFAULT_AUTHENTICATION_CLASSES': [ + # 'apps.account.auth_back.TokenAuthentication2', + # ], + 'DEFAULT_FILTER_BACKENDS': ['django_filters.rest_framework.DjangoFilterBackend'], + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + # 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema' # or OpenAPISchema if using drf_yasg + + +} +# Internationalization +# https://docs.djangoproject.com/en/5.0/topics/i18n/ + +LANGUAGE_CODE = 'en' + +TIME_ZONE = 'Asia/Tehran' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = False + +STATIC_URL = '/static/' +MEDIA_URL = '/media/' + +STATICFILES_DIRS = [os.path.join(BASE_DIR, 'static')] +STATIC_ROOT = os.path.join(BASE_DIR, 'static', 'static') +MEDIA_ROOT = os.path.join(BASE_DIR, 'static', 'media') + +FILER_ADMIN_ICON_SIZES = ('32', '48') + +FILER_ENABLE_LOGGING = True +FILER_DEBUG = True +ADMIN_TITLE = 'Aquilah App' +ADMIN_INDEX_TITLE = 'Aquilah Administration' + + +# Dictionary with phone number ranges and corresponding countries +# If a country is in this dictionary, it indicates that the project's OTP service supports that country +SERVICE_OTP_COUNTRU_API_KEY = { + "Iran": "https://console.melipayamak.com/api/send/simple/33213d78f1234e99b81f94eefda77e45" +} +SERVICE_OTP_COUNTRY_PHONE_RANGE = { + "98": "Iran", + "+98": "Iran" +} + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/5.0/howto/static-files/ + + +# Default primary key field type +# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +DEFAULT_SHOW_CITY_GUIDE_CITY = 'mashhad' +FILE_UPLOAD_HANDLERS = [ + 'django.core.files.uploadhandler.TemporaryFileUploadHandler', +] + + + +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.gmail.com' +EMAIL_PORT = 587 +EMAIL_USE_TLS = True +EMAIL_HOST_USER = 'aliabdolahi.171@gmail.com' +EMAIL_HOST_PASSWORD = 'rkxb nnhx iave fxxt' \ No newline at end of file diff --git a/config/settings/develop.py b/config/settings/develop.py new file mode 100644 index 0000000..e347d54 --- /dev/null +++ b/config/settings/develop.py @@ -0,0 +1,17 @@ +from .base import * + +# DJANGO_REDIS_IGNORE_EXCEPTIONS = True +DEBUG = True + +CORS_ALLOW_ALL_ORIGINS = True + +# CACHES = { +# 'default': { +# "BACKEND": "django.core.cache.backends.dummy.DummyCache", +# }, +# 'memory': { +# 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', +# 'LOCATION': 'unique-snowflake', +# 'TIMEOUT': 5000, +# }, +# } \ 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..19ba241 --- /dev/null +++ b/config/test_auth_middleware.py @@ -0,0 +1,29 @@ +from django.core.exceptions import PermissionDenied +from rest_framework.authtoken.models import Token +from apps.account.models import User + + + +def test_auth_middleware(get_response): + """ + give access to swagger and api if admin is logged in + """ + + def middleware(request): + if "/admin/" not in request.path and request.META.get('HTTP_AUTHORIZATION') is None: + if request.user.is_authenticated and request.user.is_staff: + token, _ = Token.objects.get_or_create(user=request.user) + request.META['HTTP_AUTHORIZATION'] = "Token " + token.key + + + if "/swagger" in request.path or "/redoc" in request.path: + if not request.META.get('HTTP_AUTHORIZATION'): + user = User.objects.filter(is_staff=True, email="aqila@gmail.com").first() + if user: + t, _ = Token.objects.get_or_create(user=user) + request.META['HTTP_AUTHORIZATION'] = f"Token {t}" + + + return get_response(request) + + return middleware diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..3b9716e --- /dev/null +++ b/config/urls.py @@ -0,0 +1,53 @@ +""" +URL configuration for backend project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/5.0/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from django.conf.urls.i18n import i18n_patterns +from utils import UploadTmpMedia +from django.conf.urls import url +from django.http import JsonResponse +from django.shortcuts import render +from django.views.decorators.csrf import csrf_exempt +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from utils import absolute_url + + + +api_patterns = [ + path('test/', include('apps.api.urls')), + + path('account/', include('apps.account.urls')), + path('courses/', include('apps.course.urls')), + +] + + +urlpatterns = [ + # path('admin/', admin.site.urls), + path('api/', include(api_patterns)), + # path('test/', include('apps.api.urls')) +] +urlpatterns += i18n_patterns( + path('', include('limitless_dashboard.urls')), + +) +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 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..eabe08d --- /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: + - "8019:8000" + env_file: + - .env.prod + depends_on: + - postgres + links: + - postgres + networks: + - imam-javad + + postgres: + container_name: imam-javad_db + ports: + - "5575:5432" + restart: unless-stopped + image: postgres:14.0 + volumes: + - postgres_data:/var/lib/postgresql/data/ + env_file: + - .env.prod + networks: + - imam-javad + imam-javad_redis: + container_name: imam-javad_redis + image: redis:alpine + env_file: .env.prod + volumes: + - redis_data:/data + networks: + - imam-javad + + imam-javad_celery: + container_name: imam-javad_celery + build: + context: . + dockerfile: Dockerfile.celery.prod + env_file: .env.prod + command: celery -A config worker -l info + volumes: + - .:/usr/src/app/ + - static_volume:/usr/src/app/static + + depends_on: + - imam-javad_redis + networks: + - imam-javad + + + imam-javad_celery-beat: + container_name: imam-javad_celery_beat + build: + context: . + dockerfile: Dockerfile.prod + env_file: .env.prod + command: celery -A config beat -l info + volumes: + - .:/usr/src/app/ + depends_on: + - imam-javad_redis + networks: + - imam-javad + + + +volumes: + postgres_data: + static_volume: + redis_data: + +networks: + imam-javad: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..7863ba5 --- /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: + - aquilah + + postgres: + ports: + - "5444:5432" + image: postgres:13.7 + + volumes: + - ./volumes/postgres_data:/var/lib/postgresql/data + env_file: + - .env.dev + networks: + - aquilah + + +volumes: + postgres_data: + staticfiles: +networks: + imam-javad: 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..9885de1 --- /dev/null +++ b/dynamic_preferences/dynamic_preferences_registry.py @@ -0,0 +1,15 @@ +import json + +from django import forms + +from limitless_dashboard.fields.tinyeditor import TinyWidget + +from dynamic_preferences.preferences import Section +from dynamic_preferences.registries import global_preferences_registry +from dynamic_preferences.types import BasePreferenceType, BaseSerializer, LongStringPreference, StringPreference, \ + FilePreference +from utils.json_editor_field import JsonEditorWidget + + +class EditorPreferences(LongStringPreference): + widget = TinyWidget(attrs={'class': 'editor-field'}) 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..be7d11e --- /dev/null +++ b/dynamic_preferences/locale/fa/LC_MESSAGES/django.po @@ -0,0 +1,71 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-02-16 15:12+0330\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \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" + +#: admin.py:69 +msgid "Default Value" +msgstr "مقدار پیشفرض" + +#: admin.py:78 models.py:30 +msgid "Section Name" +msgstr "عنوان بخش" + +#: apps.py:10 +msgid "Dynamic Preferences" +msgstr "تنظیمات" + +#: models.py:34 +msgid "Name" +msgstr "نام" + +#: models.py:37 +msgid "Raw Value" +msgstr "مقدار" + +#: models.py:51 +msgid "Verbose Name" +msgstr "نام" + +#: models.py:57 +msgid "Help Text" +msgstr "متن راهنما" + +#: models.py:94 +msgid "Global preference" +msgstr "تنطیمات عمومی" + +#: models.py:95 +msgid "Global preferences" +msgstr "تنطیمات عمومی" + +#: templates/dynamic_preferences/form.html:11 +msgid "Submit" +msgstr "ثبت" + +#: users/apps.py:11 +msgid "Preferences - Users" +msgstr "" + +#: users/models.py:14 +msgid "user preference" +msgstr "" + +#: users/models.py:15 +msgid "user preferences" +msgstr "" 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/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..2ca4437 --- /dev/null +++ b/dynamic_preferences/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="GlobalPreferenceModel", + fields=[ + ( + "id", + models.AutoField( + primary_key=True, + serialize=False, + verbose_name="ID", + auto_created=True, + ), + ), + ( + "section", + models.CharField( + blank=True, + default=None, + null=True, + max_length=150, + db_index=True, + ), + ), + ("name", models.CharField(max_length=150, db_index=True)), + ("raw_value", models.TextField(blank=True, null=True)), + ], + options={ + "verbose_name_plural": "global preferences", + "verbose_name": "global preference", + }, + bases=(models.Model,), + ), + migrations.AlterUniqueTogether( + name="globalpreferencemodel", + unique_together=set([("section", "name")]), + ), + ] diff --git a/dynamic_preferences/migrations/0002_auto_20150712_0332.py b/dynamic_preferences/migrations/0002_auto_20150712_0332.py new file mode 100644 index 0000000..4b8730b --- /dev/null +++ b/dynamic_preferences/migrations/0002_auto_20150712_0332.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ("dynamic_preferences", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="globalpreferencemodel", + name="name", + field=models.CharField(max_length=150, db_index=True), + ), + migrations.AlterField( + model_name="globalpreferencemodel", + name="section", + field=models.CharField( + max_length=150, blank=True, db_index=True, default=None, null=True + ), + ), + ] diff --git a/dynamic_preferences/migrations/0003_auto_20151223_1407.py b/dynamic_preferences/migrations/0003_auto_20151223_1407.py new file mode 100644 index 0000000..86f74eb --- /dev/null +++ b/dynamic_preferences/migrations/0003_auto_20151223_1407.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("dynamic_preferences", "0002_auto_20150712_0332"), + ] + + operations = [ + migrations.AlterField( + model_name="globalpreferencemodel", + name="name", + field=models.CharField(max_length=150, db_index=True), + preserve_default=True, + ), + migrations.AlterField( + model_name="globalpreferencemodel", + name="section", + field=models.CharField( + max_length=150, + null=True, + default=None, + db_index=True, + blank=True, + verbose_name="Section Name", + ), + preserve_default=True, + ), + ] diff --git a/dynamic_preferences/migrations/0004_move_user_model.py b/dynamic_preferences/migrations/0004_move_user_model.py new file mode 100644 index 0000000..3889c91 --- /dev/null +++ b/dynamic_preferences/migrations/0004_move_user_model.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations +from django.conf import settings + + +class Migration(migrations.Migration): + """ + Migration to move the user preferences to a dedicated app, see #33 + Borrowed from http://stackoverflow.com/a/26472482/2844093 + """ + + dependencies = [ + ("dynamic_preferences", "0003_auto_20151223_1407"), + ] + + # cf https://github.com/agateblue/django-dynamic-preferences/pull/142 + operations = [] diff --git a/dynamic_preferences/migrations/0005_auto_20181120_0848.py b/dynamic_preferences/migrations/0005_auto_20181120_0848.py new file mode 100644 index 0000000..fd014f3 --- /dev/null +++ b/dynamic_preferences/migrations/0005_auto_20181120_0848.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("dynamic_preferences", "0004_move_user_model"), + ] + + operations = [ + migrations.AlterModelOptions( + name="globalpreferencemodel", + options={ + "verbose_name": "Global preference", + "verbose_name_plural": "Global preferences", + }, + ), + migrations.AlterField( + model_name="globalpreferencemodel", + name="name", + field=models.CharField(db_index=True, max_length=150, verbose_name="Name"), + ), + migrations.AlterField( + model_name="globalpreferencemodel", + name="raw_value", + field=models.TextField(blank=True, null=True, verbose_name="Raw Value"), + ), + ] diff --git a/dynamic_preferences/migrations/0006_auto_20191001_2236.py b/dynamic_preferences/migrations/0006_auto_20191001_2236.py new file mode 100644 index 0000000..798ae23 --- /dev/null +++ b/dynamic_preferences/migrations/0006_auto_20191001_2236.py @@ -0,0 +1,25 @@ +# Generated by Django 2.1.7 on 2019-10-01 14:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("dynamic_preferences", "0005_auto_20181120_0848"), + ] + + operations = [ + migrations.AlterField( + model_name="globalpreferencemodel", + name="section", + field=models.CharField( + blank=True, + db_index=True, + default=None, + max_length=150, + null=True, + verbose_name="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..9846ce6 --- /dev/null +++ b/dynamic_preferences/serializers.py @@ -0,0 +1,484 @@ +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 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 + + +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, 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..e98f93b --- /dev/null +++ b/dynamic_preferences/urls.py @@ -0,0 +1,33 @@ +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 + +app_name = "dynamic_preferences" + +urlpatterns = [ + 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/0001_initial.py b/dynamic_preferences/users/migrations/0001_initial.py new file mode 100644 index 0000000..aa9fe01 --- /dev/null +++ b/dynamic_preferences/users/migrations/0001_initial.py @@ -0,0 +1,59 @@ +# Generated by Django 2.0.6 on 2018-06-15 16:20 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="UserPreferenceModel", + 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, + ), + ), + ("name", models.CharField(db_index=True, max_length=150)), + ("raw_value", models.TextField(blank=True, null=True)), + ( + "instance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "user preference", + "verbose_name_plural": "user preferences", + "abstract": False, + }, + ), + migrations.AlterUniqueTogether( + name="userpreferencemodel", + unique_together={("instance", "section", "name")}, + ), + ] diff --git a/dynamic_preferences/users/migrations/0002_auto_20200821_0837.py b/dynamic_preferences/users/migrations/0002_auto_20200821_0837.py new file mode 100644 index 0000000..fdb772b --- /dev/null +++ b/dynamic_preferences/users/migrations/0002_auto_20200821_0837.py @@ -0,0 +1,35 @@ +# Generated by Django 3.1 on 2020-08-21 08:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("dynamic_preferences_users", "0001_initial"), + ] + + operations = [ + migrations.AlterField( + model_name="userpreferencemodel", + name="name", + field=models.CharField(db_index=True, max_length=150, verbose_name="Name"), + ), + migrations.AlterField( + model_name="userpreferencemodel", + name="raw_value", + field=models.TextField(blank=True, null=True, verbose_name="Raw Value"), + ), + migrations.AlterField( + model_name="userpreferencemodel", + name="section", + field=models.CharField( + blank=True, + db_index=True, + default=None, + max_length=150, + null=True, + verbose_name="Section Name", + ), + ), + ] 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..04d1acf --- /dev/null +++ b/dynamic_preferences/views.py @@ -0,0 +1,59 @@ +from django.views.generic import TemplateView, FormView +from django.http import Http404 +from .forms import preference_form_builder + + +"""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/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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..58dd0e9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,85 @@ +asgiref==3.8.1 +certifi==2024.2.2 +charset-normalizer==3.3.2 +diff-match-patch==20230430 +django-ajax-datatable==4.5.0 +django-autoslug==1.9.9 +django-cors-headers==4.3.1 +django-debug-toolbar==4.3.0 +django-environ==0.11.2 +django-filter==2.4.0 +django-import-export==4.0.3 +django-multiselectfield==0.1.12 +django-phonenumber-field==5.2.0 +django-recaptcha==2.0.6 +django==3.2.4 +djangorestframework==3.15.1 +drf-yasg==1.21.7 +gunicorn==22.0.0 +idna==3.7 +inflection==0.5.1 +packaging==24.0 +phonenumbers==8.13.37 +pillow==10.3.0 +psycopg2-binary==2.9.9 +pytz==2024.1 +geopy==2.3.0 +pyyaml==6.0.1 +requests==2.32.1 +sqlparse==0.5.0 +tablib==3.5.0 +typing-extensions==4.11.0 +uritemplate==4.1.1 +urllib3==2.2.1 +redis==4.3.4 +django-redis==5.4.0 +celery==5.2.1 +sentry-sdk==1.6.0 +outcome==1.3.0.post0 +prompt-toolkit==3.0.45 +py-moneyed==3.0 +pycparser==2.22 +pysocks==1.7.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.0.1 +selenium==4.21.0 +setuptools==70.0.0 +six==1.16.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +soupsieve==2.5 +trio-websocket==0.11.1 +trio==0.25.1 +django-mptt==0.12.0 +tzdata==2024.1 +vine==5.1.0 +wcwidth==0.2.13 +webdriver-manager==4.0.1 +wsproto==1.2.0 +django-money==3.5.2 +exceptiongroup==1.2.1 +h11==0.14.0 +kombu==5.3.7 +amqp==5.2.0 +async-timeout==4.0.3 +attrs==23.2.0 +babel==2.15.0 +beautifulsoup4==4.12.3 +python-slugify==8.0.1 +billiard==3.6.4.0 +cffi==1.16.0 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 +click==8.1.7 +colorama==0.4.6 +django-dynamic-preferences==1.16.0 +unidecode + +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-filer.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/django-modules/FastFileManager.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/templates/admin/includes/fieldset.html b/templates/admin/includes/fieldset.html new file mode 100644 index 0000000..b649951 --- /dev/null +++ b/templates/admin/includes/fieldset.html @@ -0,0 +1,95 @@ +
+
+ {% if inline_admin_formset.opts.verbose_name_plural %} + {#
#} + {#
#} + {# {{ inline_admin_formset.opts.verbose_name_plural|capfirst }}#} + {#
#} + {#
#} + {% else %} + {% if fieldset.name %} + + {% endif %} + {% endif %} +
+ + +
+
+
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..9ff5288 --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,213 @@ +{% extends 'admin/base_site.html' %} +{% load static %} +{% block content %} + {{ block.super }} +
+
+
+
+
+
Monthly User Chart
+
+ +
+
+
+
+
+
+
+
+
+{% endblock %} + +{% block scripts %} + {% if request.user.is_superuser %} + + + + + + {% endif %} +{% endblock %} \ No newline at end of file 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/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..c719710 --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,234 @@ +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 + + + + + +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 value is a file object with a URL + 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) 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/exceptions.py b/utils/exceptions.py new file mode 100644 index 0000000..89f5a89 --- /dev/null +++ b/utils/exceptions.py @@ -0,0 +1,31 @@ + + +from rest_framework.exceptions import APIException +from rest_framework import status + + + + +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..950a89c --- /dev/null +++ b/utils/json_editor_field.py @@ -0,0 +1,30 @@ +import json + +from django import forms +from django.db import models + + +class JsonEditorWidget(forms.Textarea): + template_name = 'fields/json_editor_field.html' + + +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..d46a6c3 --- /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['password'] if kwargs['password'] else None + 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..a9112b4 --- /dev/null +++ b/utils/schema.py @@ -0,0 +1,14 @@ + + + + +def default_timing(): + return { + "saturday": "", + "sunday": "", + "monday": "", + "tuesday": "", + "wednesday": "", + "thursday": "", + "friday": "" + } 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..548093d --- /dev/null +++ b/utils/validators.py @@ -0,0 +1,28 @@ + + +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): + if not value.isdigit(): + raise serializers.ValidationError('کد باید شامل اعداد باشد.') + if len(value) != 5: + raise serializers.ValidationError('کد باید ۵ رقمی باشد.') + return value \ No newline at end of file