commit d160410cd90fefca19881959ef4a0c02e78ec9ae Author: muhammadvadud Date: Fri Sep 19 15:19:32 2025 +0500 first commit diff --git a/.cruft.json b/.cruft.json new file mode 100644 index 0000000..6043c22 --- /dev/null +++ b/.cruft.json @@ -0,0 +1,28 @@ +{ + "template": "https://github.com/JscorpTech/django", + "commit": "8de1cd6688d4f4747415488a878bc6775b1c44b6", + "checkout": null, + "context": { + "cookiecutter": { + "cacheops": true, + "silk": true, + "storage": true, + "rosetta": true, + "channels": true, + "ckeditor": true, + "modeltranslation": true, + "parler": false, + "project_name": "simple-travel", + "settings_module": "config.settings.local", + "runner": "wsgi", + "script": "entrypoint.sh", + "key": "key", + "port": "8081", + "phone": "998888112309", + "password": "2309", + "max_line_length": "120", + "project_slug": "simple_travel" + } + }, + "directory": null +} \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..6c59faa --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,19 @@ +{ + "name": "Existing Dockerfile", + "build": { + "context": "..", + "dockerfile": "../Dockerfile" + }, + "features": { + "ghcr.io/devcontainers/features/python:1": {} + }, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "PKief.material-icon-theme", + "zhuangtongfa.material-theme" + ] + } + } +} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fd148e5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +venv/ +resources/staticfiles/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c0db72d --- /dev/null +++ b/.env.example @@ -0,0 +1,69 @@ +# Django configs +DJANGO_SECRET_KEY=key +DEBUG=True +DJANGO_SETTINGS_MODULE=config.settings.local +COMMAND=sh ./resources/scripts/entrypoint.sh +PORT=8081 +#! debug | prod +PROJECT_ENV=debug +PROTOCOL_HTTPS=False + +# OTP configs +OTP_SIZE=4 +OTP_PROD=false +OTP_DEFAULT=1111 + +# Databse configs +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases +DB_ENGINE=django.db.backends.postgresql_psycopg2 +DB_NAME=django +DB_USER=postgres +DB_PASSWORD=2309 +DB_HOST=db +DB_PORT=5432 + +# Cache +CACHE_BACKEND=django.core.cache.backends.redis.RedisCache +REDIS_URL=redis://redis:6379 + + +CACHE_ENABLED=False + +CACHE_TIMEOUT=120 + +# Vite settings +VITE_LIVE=False +VITE_PORT=5173 +VITE_HOST=127.0.0.1 + +# Sms service +SMS_API_URL=https://notify.eskiz.uz/api +SMS_LOGIN=admin@gmail.com +SMS_PASSWORD=key + +# Addition + +ALLOWED_HOSTS=127.0.0.1,web +CSRF_TRUSTED_ORIGINS=http://127.0.0.1:8081 + + +OTP_MODULE=core.services.otp +OTP_SERVICE=EskizService + + +# Storage +STORAGE_ID=id +STORAGE_KEY=key +STORAGE_URL=example.com + +#! MINIO | AWS | FILE +STORAGE_DEFAULT=FILE + +#! MINIO | AWS | STATIC +STORAGE_STATIC=STATIC + +STORAGE_BUCKET_MEDIA=name +STORAGE_BUCKET_STATIC=name +STORAGE_PATH=127.0.0.1:8081/bucket/ +STORAGE_PROTOCOL=http: + diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..95f57f4 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 120 +ignore = E701, E704, W503 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e46f1af --- /dev/null +++ b/.gitignore @@ -0,0 +1,158 @@ +node_modules + +# OS ignores +*.DS_Store + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +poetry.lock + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# 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/ +cover/ + +# 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 +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .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 + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.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/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +.idea/ + +# Visual Studio Code +.vscode diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..2b3ed35 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,177 @@ +pipeline { + agent any + + environment { + PROD_ENV = "/opt/env/.env.simple_travel" + IMAGE_NAME = "simple_travel" + TEST_TAG = "test" + PROD_TAG = "latest" + CONTAINER_DB = "simple_travel_db_test" + CONTAINER_WEB = "simple_travel_web_test" + CONTAINER_REDIS = "simple_travel_redis_test" + STACK_NAME = "simple_travel" + } + + stages { + stage('Check Commit Message') { + steps { + script { + def commitMsg = sh( + script: "git log -1 --pretty=%B", + returnStdout: true + ).trim() + + if (commitMsg.contains("[ci skip]")) { + echo "Commit message contains [ci skip], aborting pipeline 🚫" + currentBuild.result = 'ABORTED' + error("Pipeline aborted because of [ci skip]") + } + } + } + } + stage('Checkout Code') { + steps { + git branch: 'main', credentialsId: 'ssh', url: 'git@github.com:JscorpTech/simple_travel.git' + } + } + stage('Build Image') { + steps { + sh ''' + if [ -e ${PROD_ENV} ]; then + echo env exists + else + mkdir -p $(dirname ${PROD_ENV}) + cp ./.env.example ${PROD_ENV} + fi + cp ${PROD_ENV} ./.env + ''' + sh """ + docker build -t ${IMAGE_NAME}:${PROD_TAG} -f ./docker/Dockerfile.web . + """ + } + } + + + stage('Start Test DB') { + steps { + sh """ + docker run -d --rm --name ${CONTAINER_DB} -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=testdb postgres:16 + docker run -d --rm --name ${CONTAINER_REDIS} redis + echo "⏳ Waiting for database..." + for i in {1..30}; do + if docker exec ${CONTAINER_DB} pg_isready -U postgres >/dev/null 2>&1; then + echo "βœ… Database ready" + break + fi + echo "Database not ready yet... retrying..." + sleep 2 + done + """ + } + } + + stage('Run Migrations & Tests') { + steps { + sh """ + docker run --rm --name ${CONTAINER_WEB} --link ${CONTAINER_DB}:db --link ${CONTAINER_REDIS}:redis \ + -e DB_HOST=db \ + -e DB_PORT=5432 \ + -e DB_NAME=testdb \ + -e DB_USER=postgres \ + -e DB_PASSWORD=postgres \ + -e DJANGO_SETTINGS_MODULE=config.settings.test \ + ${IMAGE_NAME}:${PROD_TAG} \ + sh -c "python manage.py migrate && pytest -v" + """ + } + } + + stage('Publish to DockerHub') { + when { + expression { currentBuild.currentResult == "SUCCESS" } + } + steps { + withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + echo "${DOCKER_PASS}" | docker login -u "${DOCKER_USER}" --password-stdin + docker tag ${IMAGE_NAME}:${PROD_TAG} ${DOCKER_USER}/${IMAGE_NAME}:${BUILD_NUMBER} + docker tag ${IMAGE_NAME}:${PROD_TAG} ${DOCKER_USER}/${IMAGE_NAME}:${PROD_TAG} + docker push ${DOCKER_USER}/${IMAGE_NAME}:${BUILD_NUMBER} + docker push ${DOCKER_USER}/${IMAGE_NAME}:${PROD_TAG} + ''' + } + } + } + stage("Update stack.yaml") { + when { + expression { currentBuild.currentResult == "SUCCESS" } + } + steps { + withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh """ + sed -i 's|image: ${DOCKER_USER}/${IMAGE_NAME}:.*|image: ${DOCKER_USER}/${IMAGE_NAME}:${BUILD_NUMBER}|' stack.yaml + """ + // git config --global user.email "admin@jscorp.uz" + // git config --global user.name "Jenkins" + // if ! git diff --quiet stack.yaml; then + // git add stack.yaml + // git commit -m "feat(swarm) Update image tag to ${BUILD_NUMBER} [ci skip]" + // git push origin main + // else + // echo "No changes in stack.yaml" + // fi + } + + } + } + stage('Deploy stack') { + when { + expression { currentBuild.currentResult == "SUCCESS" } + } + steps { + withCredentials([usernamePassword(credentialsId: 'dockerhub', usernameVariable: 'DOCKER_USER', passwordVariable: 'DOCKER_PASS')]) { + sh ''' + docker stack deploy -c stack.yaml ${STACK_NAME} + ''' + } + } + } + + } + + post { + always { + sh """ + docker stop ${CONTAINER_DB} || true + docker stop ${CONTAINER_REDIS} || true + """ + echo "Pipeline finished: ${currentBuild.currentResult}" + } + + success { + withCredentials([ + string(credentialsId: 'bot-token', variable: 'BOT_TOKEN'), + string(credentialsId: 'chat-id', variable: 'CHAT_ID') + ]) { + sh ''' + curl -s -X POST https://api.telegram.org/bot${BOT_TOKEN}/sendMessage \ + -d chat_id=${CHAT_ID} \ + -d text="βœ… SUCCESS: ${JOB_NAME} #${BUILD_NUMBER}" + ''' + } + } + + failure { + withCredentials([ + string(credentialsId: 'bot-token', variable: 'BOT_TOKEN'), + string(credentialsId: 'chat-id', variable: 'CHAT_ID') + ]) { + sh ''' + curl -s -X POST https://api.telegram.org/bot${BOT_TOKEN}/sendMessage \ + -d chat_id=${CHAT_ID} \ + -d text="🚨 FAILED: ${JOB_NAME} #${BUILD_NUMBER}" + ''' + } + } + } +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4559c0d --- /dev/null +++ b/Makefile @@ -0,0 +1,43 @@ + +start: up seed + +up: + docker compose up -d + +down: + docker compose down + +build: + docker compose build + +rebuild: down build up + +deploy: down build up makemigrate + +deploy-prod: + docker compose -f docker-compose.prod.yml down + docker compose -f docker-compose.prod.yml up -d + docker compose -f docker-compose.prod.yml exec web python manage.py makemigrations --noinput + docker compose -f docker-compose.prod.yml exec web python manage.py migrate + +logs: + docker compose logs -f + +makemigration: + docker compose exec web python manage.py makemigrations --noinput + +migrate: + docker compose exec web python manage.py migrate + +seed: + docker compose exec web python manage.py seed + +reset_db: + docker compose exec web python manage.py reset_db --no-input + +makemigrate: makemigration migrate + +fresh: reset_db makemigrate seed + +test: + docker compose exec web pytest -v diff --git a/README.MD b/README.MD new file mode 100644 index 0000000..153f280 --- /dev/null +++ b/README.MD @@ -0,0 +1,2 @@ +# JST-DJANGO +[Docs](https://docs.jscorp.uz) diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..801fff4 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app + +__all__ = ["app"] diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..f715434 --- /dev/null +++ b/config/asgi.py @@ -0,0 +1,23 @@ +import os + +from django.core.asgi import get_asgi_application + +asgi_application = get_asgi_application() +from config.env import env # noqa + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE")) + + +from channels.routing import ProtocolTypeRouter # noqa +from channels.routing import URLRouter # noqa + +# from core.apps.websocket.urls import websocket_urlpatterns # noqa +# from core.apps.websocket.middlewares import JWTAuthMiddlewareStack # noqa + +application = ProtocolTypeRouter( + { + "http": asgi_application, + # "websocket": JWTAuthMiddlewareStack(URLRouter(websocket_urlpatterns)), + } +) + diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..1578806 --- /dev/null +++ b/config/celery.py @@ -0,0 +1,16 @@ +""" +Celery configurations +""" + +import os + +import celery +from config.env import env + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE")) + +app = celery.Celery("config") + +app.config_from_object("django.conf:settings", namespace="CELERY") + +app.autodiscover_tasks() diff --git a/config/conf/__init__.py b/config/conf/__init__.py new file mode 100644 index 0000000..d8f1296 --- /dev/null +++ b/config/conf/__init__.py @@ -0,0 +1,12 @@ +from .cache import * # noqa +from .celery import * # noqa +from .cron import * # noqa +from .jwt import * # noqa +from .logs import * # noqa +from .rest_framework import * # noqa +from .unfold import * # noqa +from .spectacular import * # noqa + +from .ckeditor import * # noqa +from .storage import * # noqa +from .channels import * # noqa \ No newline at end of file diff --git a/config/conf/apps.py b/config/conf/apps.py new file mode 100644 index 0000000..7621efe --- /dev/null +++ b/config/conf/apps.py @@ -0,0 +1,22 @@ +from config.env import env + +APPS = [ + "channels", + "cacheops", + "rosetta", + "django_ckeditor_5", + + "drf_spectacular", + "rest_framework", + "corsheaders", + "django_filters", + "django_redis", + "rest_framework_simplejwt", + "django_core", + "core.apps.accounts.apps.AccountsConfig", +] + +if env.bool("SILK_ENEBLED", False): + APPS += [ + "silk", + ] diff --git a/config/conf/cache.py b/config/conf/cache.py new file mode 100644 index 0000000..cd724b5 --- /dev/null +++ b/config/conf/cache.py @@ -0,0 +1,26 @@ +from config.env import env + +CACHES = { + "default": { + "BACKEND": env.str("CACHE_BACKEND"), + "LOCATION": env.str("REDIS_URL"), + "TIMEOUT": env.str("CACHE_TIMEOUT"), + }, +} + +CACHE_MIDDLEWARE_SECONDS = env("CACHE_TIMEOUT") + + +CACHEOPS_REDIS = env.str("REDIS_URL") +CACHEOPS_DEFAULTS = { + "timeout": env.str("CACHE_TIMEOUT"), +} +CACHEOPS = { + # !NOTE: api => "you app name" + # "api.*": { + # "ops": "all", # Barcha turdagi so'rovlarni keshga olish + # "timeout": 60 * 5, # 5 daqiqa davomida saqlash + # }, +} +CACHEOPS_DEGRADE_ON_FAILURE = True +CACHEOPS_ENABLED = env.bool("CACHE_ENABLED", False) diff --git a/config/conf/celery.py b/config/conf/celery.py new file mode 100644 index 0000000..5f46855 --- /dev/null +++ b/config/conf/celery.py @@ -0,0 +1,7 @@ +CELERY_BEAT_SCHEDULE = { + # "test": { + # "task": "core.apps.home.tasks.demo.add", + # "schedule": 5.0, + # "args": (1, 2) + # }, +} diff --git a/config/conf/channels.py b/config/conf/channels.py new file mode 100644 index 0000000..d9f0597 --- /dev/null +++ b/config/conf/channels.py @@ -0,0 +1,8 @@ +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [("redis", 6379)], + }, + }, +} \ No newline at end of file diff --git a/config/conf/ckeditor.py b/config/conf/ckeditor.py new file mode 100644 index 0000000..654019c --- /dev/null +++ b/config/conf/ckeditor.py @@ -0,0 +1,147 @@ +import os +from pathlib import Path + +STATIC_URL = "/resources/static/" +MEDIA_URL = "/resources/media/" +MEDIA_ROOT = os.path.join(Path().parent.parent, "media") + +customColorPalette = [ + {"color": "hsl(4, 90%, 58%)", "label": "Red"}, + {"color": "hsl(340, 82%, 52%)", "label": "Pink"}, + {"color": "hsl(291, 64%, 42%)", "label": "Purple"}, + {"color": "hsl(262, 52%, 47%)", "label": "Deep Purple"}, + {"color": "hsl(231, 48%, 48%)", "label": "Indigo"}, + {"color": "hsl(207, 90%, 54%)", "label": "Blue"}, +] + +CKEDITOR_5_CONFIGS = { + "default": { + "toolbar": [ + "heading", + "|", + "bold", + "italic", + "link", + "bulletedList", + "numberedList", + "blockQuote", + "imageUpload", + ], + }, + "extends": { + "blockToolbar": [ + "paragraph", + "heading1", + "heading2", + "heading3", + "|", + "bulletedList", + "numberedList", + "|", + "blockQuote", + ], + "toolbar": [ + "heading", + "|", + "outdent", + "indent", + "|", + "bold", + "italic", + "link", + "underline", + "strikethrough", + "code", + "subscript", + "superscript", + "highlight", + "|", + "codeBlock", + "sourceEditing", + "insertImage", + "bulletedList", + "numberedList", + "todoList", + "|", + "blockQuote", + "imageUpload", + "|", + "fontSize", + "fontFamily", + "fontColor", + "fontBackgroundColor", + "mediaEmbed", + "removeFormat", + "insertTable", + ], + "image": { + "toolbar": [ + "imageTextAlternative", + "|", + "imageStyle:alignLeft", + "imageStyle:alignRight", + "imageStyle:alignCenter", + "imageStyle:side", + "|", + ], + "styles": [ + "full", + "side", + "alignLeft", + "alignRight", + "alignCenter", + ], + }, + "table": { + "contentToolbar": [ + "tableColumn", + "tableRow", + "mergeTableCells", + "tableProperties", + "tableCellProperties", + ], + "tableProperties": { + "borderColors": customColorPalette, + "backgroundColors": customColorPalette, + }, + "tableCellProperties": { + "borderColors": customColorPalette, + "backgroundColors": customColorPalette, + }, + }, + "heading": { + "options": [ + { + "model": "paragraph", + "title": "Paragraph", + "class": "ck-heading_paragraph", + }, + { + "model": "heading1", + "view": "h1", + "title": "Heading 1", + "class": "ck-heading_heading1", + }, + { + "model": "heading2", + "view": "h2", + "title": "Heading 2", + "class": "ck-heading_heading2", + }, + { + "model": "heading3", + "view": "h3", + "title": "Heading 3", + "class": "ck-heading_heading3", + }, + ] + }, + }, + "list": { + "properties": { + "styles": "true", + "startIndex": "true", + "reversed": "true", + } + }, +} diff --git a/config/conf/cron.py b/config/conf/cron.py new file mode 100644 index 0000000..e69de29 diff --git a/config/conf/jwt.py b/config/conf/jwt.py new file mode 100644 index 0000000..15d8970 --- /dev/null +++ b/config/conf/jwt.py @@ -0,0 +1,36 @@ +from datetime import timedelta + +from config.env import env + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=30), + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + "UPDATE_LAST_LOGIN": False, + "ALGORITHM": "HS256", + "SIGNING_KEY": env("DJANGO_SECRET_KEY"), + "VERIFYING_KEY": "", + "AUDIENCE": None, + "ISSUER": None, + "JSON_ENCODER": None, + "JWK_URL": None, + "LEEWAY": 0, + "AUTH_HEADER_TYPES": ("Bearer",), + "AUTH_HEADER_NAME": "HTTP_AUTHORIZATION", + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "user_id", + "AUTH_TOKEN_CLASSES": ("rest_framework_simplejwt.tokens.AccessToken",), + "TOKEN_TYPE_CLAIM": "token_type", + "TOKEN_USER_CLASS": "rest_framework_simplejwt.models.TokenUser", + "JTI_CLAIM": "jti", + "SLIDING_TOKEN_REFRESH_EXP_CLAIM": "refresh_exp", + "SLIDING_TOKEN_LIFETIME": timedelta(minutes=60), + "SLIDING_TOKEN_REFRESH_LIFETIME": timedelta(days=30), + "TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainPairSerializer", + "TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSerializer", + "TOKEN_VERIFY_SERIALIZER": "rest_framework_simplejwt.serializers.TokenVerifySerializer", + "TOKEN_BLACKLIST_SERIALIZER": "rest_framework_simplejwt.serializers.TokenBlacklistSerializer", + "SLIDING_TOKEN_OBTAIN_SERIALIZER": "rest_framework_simplejwt.serializers.TokenObtainSlidingSerializer", + "SLIDING_TOKEN_REFRESH_SERIALIZER": "rest_framework_simplejwt.serializers.TokenRefreshSlidingSerializer", +} diff --git a/config/conf/logs.py b/config/conf/logs.py new file mode 100644 index 0000000..5245ac6 --- /dev/null +++ b/config/conf/logs.py @@ -0,0 +1,29 @@ +# settings.py faylida + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "{levelname} {asctime} {module} {message}", + "style": "{", + }, + }, + "handlers": { + "daily_rotating_file": { + "level": "INFO", + "class": "logging.handlers.TimedRotatingFileHandler", + "filename": "resources/logs/django.log", # Fayl nomi (kunlik fayllar uchun avtomatik yoziladi) + "when": "midnight", # Har kecha log fayli yangilanadi + "backupCount": 30, # 30 kunlik loglar saqlanadi, 1 oydan keyin eski fayllar o'chiriladi + "formatter": "verbose", + }, + }, + "loggers": { + "django": { + "handlers": ["daily_rotating_file"], + "level": "INFO", + "propagate": True, + }, + }, +} diff --git a/config/conf/modules.py b/config/conf/modules.py new file mode 100644 index 0000000..eb95b0d --- /dev/null +++ b/config/conf/modules.py @@ -0,0 +1 @@ +MODULES = ["core.apps.shared", "core.apps.tickets", "core.apps.payments", "core.apps.api", "core.apps.blog"] diff --git a/config/conf/navigation.py b/config/conf/navigation.py new file mode 100644 index 0000000..2083489 --- /dev/null +++ b/config/conf/navigation.py @@ -0,0 +1,31 @@ +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ + +PAGES = [ + { + "seperator": False, + "items": [ + { + "title": _("Home page"), + "icon": "home", + "link": reverse_lazy("admin:index"), + } + ], + }, + { + "title": _("Auth"), + "separator": True, # Top border + "items": [ + { + "title": _("Users"), + "icon": "group", + "link": reverse_lazy("admin:http_user_changelist"), + }, + { + "title": _("Group"), + "icon": "group", + "link": reverse_lazy("admin:auth_group_changelist"), + }, + ], + }, +] diff --git a/config/conf/rest_framework.py b/config/conf/rest_framework.py new file mode 100644 index 0000000..fbec6fe --- /dev/null +++ b/config/conf/rest_framework.py @@ -0,0 +1,9 @@ +from typing import Any, Union + +REST_FRAMEWORK: Union[Any] = { + "DEFAULT_AUTHENTICATION_CLASSES": ("rest_framework_simplejwt.authentication.JWTAuthentication",), + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + "DEFAULT_PAGINATION_CLASS": "django_core.paginations.CustomPagination", + "PAGE_SIZE": 10, +} diff --git a/config/conf/spectacular.py b/config/conf/spectacular.py new file mode 100644 index 0000000..2be491b --- /dev/null +++ b/config/conf/spectacular.py @@ -0,0 +1,31 @@ +SPECTACULAR_SETTINGS = { + "TITLE": "Your Project API", + "DESCRIPTION": "Your project description", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, + "CAMELIZE_NAMES": True, + "POSTPROCESSING_HOOKS": ["config.conf.spectacular.custom_postprocessing_hook"], +} + + +def custom_postprocessing_hook(result, generator, request, public): + """ + Customizes the API schema to wrap all responses in a standard format. + """ + for path, methods in result.get("paths", {}).items(): + for method, operation in methods.items(): + if "responses" in operation: + for status_code, response in operation["responses"].items(): + if "content" in response: + for content_type, content in response["content"].items(): + # Wrap original schema + original_schema = content.get("schema", {}) + response["content"][content_type]["schema"] = { + "type": "object", + "properties": { + "status": {"type": "boolean", "example": True}, + "data": original_schema, + }, + "required": ["status", "data"], + } + return result diff --git a/config/conf/storage.py b/config/conf/storage.py new file mode 100644 index 0000000..5d59a85 --- /dev/null +++ b/config/conf/storage.py @@ -0,0 +1,23 @@ +from config.env import env +from core.utils.storage import Storage + +AWS_ACCESS_KEY_ID = env.str("STORAGE_ID") +AWS_SECRET_ACCESS_KEY = env.str("STORAGE_KEY") +AWS_S3_ENDPOINT_URL = env.str("STORAGE_URL") +AWS_S3_CUSTOM_DOMAIN = env.str("STORAGE_PATH") +AWS_S3_URL_PROTOCOL = env.str("STORAGE_PROTOCOL", "https:") +AWS_S3_FILE_OVERWRITE = False + +default_storage = Storage(env.str("STORAGE_DEFAULT"), "default") +static_storage = Storage(env.str("STORAGE_STATIC"), "static") + +STORAGES = { + "default": { + "BACKEND": default_storage.get_backend(), + "OPTIONS": default_storage.get_options(), + }, + "staticfiles": { + "BACKEND": static_storage.get_backend(), + "OPTIONS": static_storage.get_options(), + }, +} diff --git a/config/conf/unfold.py b/config/conf/unfold.py new file mode 100644 index 0000000..8984f1c --- /dev/null +++ b/config/conf/unfold.py @@ -0,0 +1,95 @@ +from django.conf import settings +from django.templatetags.static import static +from django.utils.translation import gettext_lazy as _ + +from . import navigation + + +def environment_callback(request): + if settings.DEBUG: + return [_("Development"), "primary"] + + return [_("Production"), "primary"] + + +UNFOLD = { + "DASHBOARD_CALLBACK": "django_core.views.dashboard_callback", + "SITE_TITLE": "Django", + "SITE_HEADER": "Django", + "SITE_URL": "/", + # "SITE_DROPDOWN": [ + # {"icon": "local_library", "title": "Django", "link": "https://example.com"}, + # ], + "SITE_ICON": { + # "light": lambda request: static("images/pedagog.svg"), + # "dark": lambda request: static("images/pedagog.svg"), + }, + # "SITE_FAVICONS": [ + # { + # "rel": "icon", + # "sizes": "32x32", + # "type": "image/svg+xml", + # "href": lambda request: static("images/pedagog.svg"), + # }, + # ], + "SITE_SYMBOL": "speed", + "SHOW_HISTORY": True, + "SHOW_VIEW_ON_SITE": True, + "SHOW_BACK_BUTTON": True, + "SHOW_LANGUAGES": True, + "ENVIRONMENT": "core.config.unfold.environment_callback", + # "LOGIN": { + # "image": lambda request: static("images/login.png"), + # }, + "BORDER_RADIUS": "10px", + "COLORS": { + "base": { + "50": "250 250 250", + "100": "244 244 245", + "200": "228 228 231", + "300": "212 212 216", + "400": "161 161 170", + "500": "113 113 122", + "600": "82 82 91", + "700": "63 63 70", + "800": "39 39 42", + "900": "24 24 27", + "950": "9 9 11", + }, + "font": { + "subtle-light": "var(--color-base-500)", # text-base-500 + "subtle-dark": "var(--color-base-400)", # text-base-400 + "default-light": "var(--color-base-600)", # text-base-600 + "default-dark": "var(--color-base-300)", # text-base-300 + "important-light": "var(--color-base-900)", # text-base-900 + "important-dark": "var(--color-base-100)", # text-base-100 + }, + "primary": { + "50": "230 245 255", + "100": "180 225 255", + "200": "130 205 255", + "300": "80 185 255", + "400": "40 165 255", + "500": "0 145 255", + "600": "0 115 204", + "700": "0 85 153", + "800": "0 55 102", + "900": "0 30 51", + "950": "0 15 25", + }, + }, + "EXTENSIONS": { + "modeltranslation": { + "flags": { + "uz": "πŸ‡ΊπŸ‡Ώ", + "ru": "πŸ‡·πŸ‡Ί", + "en": "πŸ‡¬πŸ‡§", + }, + }, + }, + "SIDEBAR": { + "show_search": True, + "show_all_applications": True, + # "navigation": navigation.PAGES, + }, +} diff --git a/config/env.py b/config/env.py new file mode 100644 index 0000000..8665829 --- /dev/null +++ b/config/env.py @@ -0,0 +1,29 @@ +""" +Default value for environ variable +""" + +import os + +import environ + +environ.Env.read_env(os.path.join(".env")) + +env = environ.Env( + DEBUG=(bool, False), + CACHE_TIME=(int, 180), + OTP_EXPIRE_TIME=(int, 2), + VITE_LIVE=(bool, False), + ALLOWED_HOSTS=(str, "localhost"), + CSRF_TRUSTED_ORIGINS=(str, "localhost"), + DJANGO_SETTINGS_MODULE=(str, "config.settings.local"), + CACHE_TIMEOUT=(int, 120), + CACHE_ENABLED=(bool, False), + VITE_PORT=(int, 5173), + VITE_HOST=(str, "vite"), + NGROK_AUTHTOKEN=(str, "TOKEN"), + BOT_TOKEN=(str, "TOKEN"), + OTP_MODULE="core.services.otp", + OTP_SERVICE="EskizService", + PROJECT_ENV=(str, "prod"), + SILK_ENEBLED=(bool, False), +) diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/common.py b/config/settings/common.py new file mode 100644 index 0000000..f148a6f --- /dev/null +++ b/config/settings/common.py @@ -0,0 +1,174 @@ +import os +import pathlib +from typing import List, Union + +from config.conf import * # noqa +from config.conf.apps import APPS +from config.conf.modules import MODULES +from config.env import env +from django.utils.translation import gettext_lazy as _ +from rich.traceback import install + +install(show_locals=True) +BASE_DIR = pathlib.Path(__file__).resolve().parent.parent.parent + +SECRET_KEY = env.str("DJANGO_SECRET_KEY") +DEBUG = env.bool("DEBUG") + +ALLOWED_HOSTS: Union[List[str]] = ["*"] + +if env.bool("PROTOCOL_HTTPS", False): + SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +DATABASES = { + "default": { + "ENGINE": env.str("DB_ENGINE"), + "NAME": env.str("DB_NAME"), + "USER": env.str("DB_USER"), + "PASSWORD": env.str("DB_PASSWORD"), + "HOST": env.str("DB_HOST"), + "PORT": env.str("DB_PORT"), + } +} + +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.BCryptPasswordHasher", +] + +INSTALLED_APPS = [ + "modeltranslation", + "unfold", + "unfold.contrib.filters", + "unfold.contrib.forms", + "unfold.contrib.guardian", + "unfold.contrib.simple_history", + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", +] + APPS + +MODULES = [app for app in MODULES if isinstance(app, str)] + +for module_path in MODULES: + INSTALLED_APPS.append("{}.apps.ModuleConfig".format(module_path)) + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", # Cors middleware + "django.middleware.locale.LocaleMiddleware", # Locale middleware + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] +if env.bool("SILK_ENEBLED", False): + MIDDLEWARE += [ + "silk.middleware.SilkyMiddleware", + ] + + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [os.path.join(BASE_DIR, "resources/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", + ], + }, + }, +] +# fmt: off + +WSGI_APPLICATION = "config.wsgi.application" + +# fmt: on + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.{}".format(validator) + } for validator in [ + "UserAttributeSimilarityValidator", + "MinimumLengthValidator", + "CommonPasswordValidator", + "NumericPasswordValidator" + ] +] + +TIME_ZONE = "Asia/Tashkent" +USE_I18N = True +USE_TZ = True +STATIC_URL = "resources/static/" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# Date formats +## +DATE_FORMAT = "d.m.y" +TIME_FORMAT = "H:i:s" +DATE_INPUT_FORMATS = ["%d.%m.%Y", "%Y.%d.%m", "%Y.%d.%m"] + + +SEEDERS = ["core.apps.accounts.seeder.UserSeeder"] + +STATICFILES_DIRS = [ + os.path.join(BASE_DIR, "resources/static"), +] + +CORS_ORIGIN_ALLOW_ALL = True + +STATIC_ROOT = os.path.join(BASE_DIR, "resources/staticfiles") +VITE_APP_DIR = os.path.join(BASE_DIR, "resources/static/vite") + +LANGUAGES = ( + ("ru", _("Russia")), + ("en", _("English")), + ("uz", _("Uzbek")), +) +LOCALE_PATHS = [os.path.join(BASE_DIR, "resources/locale")] + +LANGUAGE_CODE = "uz" + +MEDIA_ROOT = os.path.join(BASE_DIR, "resources/media") # Media files +MEDIA_URL = "/resources/media/" + +AUTH_USER_MODEL = "accounts.User" + +CELERY_BROKER_URL = env("REDIS_URL") +CELERY_RESULT_BACKEND = env("REDIS_URL") + +ALLOWED_HOSTS += env("ALLOWED_HOSTS").split(",") +CSRF_TRUSTED_ORIGINS = env("CSRF_TRUSTED_ORIGINS").split(",") +SILKY_AUTHORISATION = True +SILKY_PYTHON_PROFILER = True + +MODELTRANSLATION_LANGUAGES = ("uz", "ru", "en") +MODELTRANSLATION_DEFAULT_LANGUAGE = "uz" + + + +JST_LANGUAGES = [ + { + "code": "uz", + "name": "Uzbek", + "is_default": True, + }, + { + "code": "en", + "name": "English", + }, + { + "code": "ru", + "name": "Russia", + } +] diff --git a/config/settings/local.py b/config/settings/local.py new file mode 100644 index 0000000..7394e18 --- /dev/null +++ b/config/settings/local.py @@ -0,0 +1,11 @@ +from config.settings.common import * # noqa +from config.settings.common import (ALLOWED_HOSTS, INSTALLED_APPS, + REST_FRAMEWORK) + +INSTALLED_APPS += ["django_extensions"] + +ALLOWED_HOSTS += ["127.0.0.1", "192.168.100.26"] + +REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = { + "user": "60/min", +} diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..d802ed7 --- /dev/null +++ b/config/settings/production.py @@ -0,0 +1,6 @@ +from config.settings.common import * # noqa +from config.settings.common import ALLOWED_HOSTS, REST_FRAMEWORK + +ALLOWED_HOSTS += ["192.168.100.26", "80.90.178.156"] + +REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {"user": "60/min"} diff --git a/config/settings/test.py b/config/settings/test.py new file mode 100644 index 0000000..cc2a481 --- /dev/null +++ b/config/settings/test.py @@ -0,0 +1,15 @@ +from config.settings.common import * # noqa + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "INFO", + }, +} diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..0e82539 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,46 @@ +""" +All urls configurations tree +""" + +from django.conf import settings +from django.contrib import admin +from django.http import HttpResponse +from django.urls import include, path, re_path +from django.views.static import serve +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + +from config.env import env + + +def home(request): + return HttpResponse("OK") + + +urlpatterns = [ + path("health/", home), + path("api/v1/", include("core.apps.accounts.urls")), + path("api/v1/", include("core.apps.api.urls")), + path("api/v1/", include("core.apps.payments.urls")), + path("api/v1/", include("core.apps.tickets.urls")), + path("api/v1/", include("core.apps.shared.urls")), + path("api/", include("core.apps.blog.urls")), +] +urlpatterns += [ + path("admin/", admin.site.urls), + path("accounts/", include("django.contrib.auth.urls")), + path("i18n/", include("django.conf.urls.i18n")), + path("rosetta/", include("rosetta.urls")), + path("ckeditor5/", include("django_ckeditor_5.urls"), name="ck_editor_5_upload_file"), +] +if env.bool("SILK_ENEBLED", False): + urlpatterns += [path("silk/", include("silk.urls", namespace="silk"))] +if env.str("PROJECT_ENV") == "debug": + urlpatterns += [ + path("schema/", SpectacularAPIView.as_view(), name="schema"), + path("swagger/", SpectacularSwaggerView.as_view(url_name="schema"), name="swagger-ui"), + path("redoc/", SpectacularRedocView.as_view(url_name="schema"), name="redoc"), + ] +urlpatterns += [ + re_path("static/(?P.*)", serve, {"document_root": settings.STATIC_ROOT}), + re_path("media/(?P.*)", serve, {"document_root": settings.MEDIA_ROOT}), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..ab1662c --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,8 @@ +import os + +from config.env import env +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE")) + +application = get_wsgi_application() diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/__init__.py b/core/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/accounts/__init__.py b/core/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/accounts/admin/__init__.py b/core/apps/accounts/admin/__init__.py new file mode 100644 index 0000000..8d11538 --- /dev/null +++ b/core/apps/accounts/admin/__init__.py @@ -0,0 +1,4 @@ +from .core import * # noqa +from .likes import * # noqa +from .participant import * # noqa +from .user import * # noqa diff --git a/core/apps/accounts/admin/core.py b/core/apps/accounts/admin/core.py new file mode 100644 index 0000000..4a807e7 --- /dev/null +++ b/core/apps/accounts/admin/core.py @@ -0,0 +1,18 @@ +""" +Admin panel register +""" + +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.contrib.auth import models as db_models +from django_core.models import SmsConfirm + +from ..admin import user +from .user import SmsConfirmAdmin + +admin.site.unregister(db_models.Group) +admin.site.register(db_models.Group, user.GroupAdmin) +admin.site.register(db_models.Permission, user.PermissionAdmin) + +admin.site.register(get_user_model(), user.CustomUserAdmin) +admin.site.register(SmsConfirm, SmsConfirmAdmin) diff --git a/core/apps/accounts/admin/likes.py b/core/apps/accounts/admin/likes.py new file mode 100644 index 0000000..43599c1 --- /dev/null +++ b/core/apps/accounts/admin/likes.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from core.apps.accounts.models import LikesModel + + +@admin.register(LikesModel) +class LikesAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) diff --git a/core/apps/accounts/admin/participant.py b/core/apps/accounts/admin/participant.py new file mode 100644 index 0000000..b84fb66 --- /dev/null +++ b/core/apps/accounts/admin/participant.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from core.apps.accounts.models import ParticipantModel + + +@admin.register(ParticipantModel) +class ParticipantAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) diff --git a/core/apps/accounts/admin/user.py b/core/apps/accounts/admin/user.py new file mode 100644 index 0000000..a71eb38 --- /dev/null +++ b/core/apps/accounts/admin/user.py @@ -0,0 +1,53 @@ +from django.contrib.auth import admin +from django.utils.translation import gettext_lazy as _ +from unfold.admin import ModelAdmin +from unfold.forms import AdminPasswordChangeForm # UserCreationForm, +from unfold.forms import UserChangeForm + + +class CustomUserAdmin(admin.UserAdmin, ModelAdmin): + change_password_form = AdminPasswordChangeForm + # add_form = UserCreationForm + form = UserChangeForm + list_display = ( + "first_name", + "last_name", + "phone", + "role", + ) + autocomplete_fields = ["groups", "user_permissions"] + fieldsets = ((None, {"fields": ("phone",)}),) + ( + (None, {"fields": ("username", "password")}), + (_("Personal info"), {"fields": ("first_name", "last_name", "email")}), + ( + _("Permissions"), + { + "fields": ( + "is_active", + "is_staff", + "is_superuser", + "groups", + "user_permissions", + "role", + "avatar", + ), + }, + ), + (_("Important dates"), {"fields": ("last_login", "date_joined")}), + ) + + +class PermissionAdmin(ModelAdmin): + list_display = ("name",) + search_fields = ("name",) + + +class GroupAdmin(ModelAdmin): + list_display = ["name"] + search_fields = ["name"] + autocomplete_fields = ("permissions",) + + +class SmsConfirmAdmin(ModelAdmin): + list_display = ["phone", "code", "resend_count", "try_count"] + search_fields = ["phone", "code"] diff --git a/core/apps/accounts/apps.py b/core/apps/accounts/apps.py new file mode 100644 index 0000000..a231531 --- /dev/null +++ b/core/apps/accounts/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class AccountsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.accounts" + + def ready(self): + from core.apps.accounts import signals # noqa diff --git a/core/apps/accounts/choices/__init__.py b/core/apps/accounts/choices/__init__.py new file mode 100644 index 0000000..1000b27 --- /dev/null +++ b/core/apps/accounts/choices/__init__.py @@ -0,0 +1 @@ +from .user import * # noqa diff --git a/core/apps/accounts/choices/user.py b/core/apps/accounts/choices/user.py new file mode 100644 index 0000000..b93b918 --- /dev/null +++ b/core/apps/accounts/choices/user.py @@ -0,0 +1,12 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class RoleChoice(models.TextChoices): + """ + User Role Choice + """ + + SUPERUSER = "superuser", _("Superuser") + ADMIN = "admin", _("Admin") + USER = "user", _("User") diff --git a/core/apps/accounts/filters/__init__.py b/core/apps/accounts/filters/__init__.py new file mode 100644 index 0000000..20a43c8 --- /dev/null +++ b/core/apps/accounts/filters/__init__.py @@ -0,0 +1,2 @@ +from .likes import * # noqa +from .participant import * # noqa diff --git a/core/apps/accounts/filters/likes.py b/core/apps/accounts/filters/likes.py new file mode 100644 index 0000000..5285d46 --- /dev/null +++ b/core/apps/accounts/filters/likes.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters + +from core.apps.accounts.models import LikesModel + + +class LikesFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = LikesModel + fields = [ + "name", + ] diff --git a/core/apps/accounts/filters/participant.py b/core/apps/accounts/filters/participant.py new file mode 100644 index 0000000..7ca0399 --- /dev/null +++ b/core/apps/accounts/filters/participant.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters + +from core.apps.accounts.models import ParticipantModel + + +class ParticipantFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = ParticipantModel + fields = [ + "name", + ] diff --git a/core/apps/accounts/forms/__init__.py b/core/apps/accounts/forms/__init__.py new file mode 100644 index 0000000..20a43c8 --- /dev/null +++ b/core/apps/accounts/forms/__init__.py @@ -0,0 +1,2 @@ +from .likes import * # noqa +from .participant import * # noqa diff --git a/core/apps/accounts/forms/likes.py b/core/apps/accounts/forms/likes.py new file mode 100644 index 0000000..dcae9c1 --- /dev/null +++ b/core/apps/accounts/forms/likes.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.accounts.models import LikesModel + + +class LikesForm(forms.ModelForm): + + class Meta: + model = LikesModel + fields = "__all__" diff --git a/core/apps/accounts/forms/participant.py b/core/apps/accounts/forms/participant.py new file mode 100644 index 0000000..2ebe41b --- /dev/null +++ b/core/apps/accounts/forms/participant.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.accounts.models import ParticipantModel + + +class ParticipantForm(forms.ModelForm): + + class Meta: + model = ParticipantModel + fields = "__all__" diff --git a/core/apps/accounts/managers/__init__.py b/core/apps/accounts/managers/__init__.py new file mode 100644 index 0000000..1000b27 --- /dev/null +++ b/core/apps/accounts/managers/__init__.py @@ -0,0 +1 @@ +from .user import * # noqa diff --git a/core/apps/accounts/managers/user.py b/core/apps/accounts/managers/user.py new file mode 100644 index 0000000..8395928 --- /dev/null +++ b/core/apps/accounts/managers/user.py @@ -0,0 +1,20 @@ +from django.contrib.auth import base_user + + +class UserManager(base_user.BaseUserManager): + def create_user(self, phone=None, password=None, **extra_fields): + user = self.model(phone=phone, **extra_fields) + user.set_password(password) + user.save(using=self._db) + return user + + def create_superuser(self, phone, password=None, **extra_fields): + extra_fields.setdefault("is_staff", True) + extra_fields.setdefault("is_superuser", True) + + if extra_fields.get("is_staff") is not True: + raise ValueError("Superuser must have is_staff=True.") + if extra_fields.get("is_superuser") is not True: + raise ValueError("Superuser must have is_superuser=True.") + + return self.create_user(phone, password, **extra_fields) diff --git a/core/apps/accounts/migrations/0001_initial.py b/core/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..84e56eb --- /dev/null +++ b/core/apps/accounts/migrations/0001_initial.py @@ -0,0 +1,103 @@ +# Generated by Django 5.1.3 on 2025-09-19 10:19 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='LikesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ], + options={ + 'verbose_name': 'LikesModel', + 'verbose_name_plural': 'LikesModels', + 'db_table': 'likes', + }, + ), + migrations.CreateModel( + name='ParticipantModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('gender', models.CharField(choices=[('Male', 'Male'), ('Female', 'Female')], default='Male', max_length=255, verbose_name='gender')), + ('first_name', models.CharField(max_length=255, verbose_name='first name')), + ('last_name', models.CharField(max_length=255, verbose_name='last name')), + ('birth_date', models.DateField(verbose_name='birth date')), + ('phone_number', models.CharField(max_length=255, verbose_name='phone number')), + ], + options={ + 'verbose_name': 'ParticipantModel', + 'verbose_name_plural': 'ParticipantModels', + 'db_table': 'participant', + }, + ), + migrations.CreateModel( + name='ParticipantPasportImageModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(upload_to='participant_images/', verbose_name='image')), + ], + options={ + 'verbose_name': 'ParticipantPasportImageModel', + 'verbose_name_plural': 'ParticipantPasportImageModels', + 'db_table': 'participant_pasport_image', + }, + ), + migrations.CreateModel( + name='ResetToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('token', models.CharField(max_length=255, unique=True)), + ], + options={ + 'verbose_name': 'Reset Token', + 'verbose_name_plural': 'Reset Tokens', + }, + ), + 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')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('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')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('phone', models.CharField(blank=True, max_length=255, null=True, unique=True)), + ('email', models.EmailField(blank=True, max_length=254, null=True, unique=True)), + ('username', models.CharField(blank=True, max_length=255, null=True)), + ('avatar', models.ImageField(default='static/images/default_avatar.jpg', upload_to='avatars/')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('validated_at', models.DateTimeField(blank=True, null=True)), + ('role', models.CharField(choices=[('superuser', 'Superuser'), ('admin', 'Admin'), ('user', 'User')], default='user', max_length=255)), + ('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')), + ('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', + 'abstract': False, + }, + ), + ] diff --git a/core/apps/accounts/migrations/0002_initial.py b/core/apps/accounts/migrations/0002_initial.py new file mode 100644 index 0000000..bf1972e --- /dev/null +++ b/core/apps/accounts/migrations/0002_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 5.1.3 on 2025-09-19 10:19 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + ('tickets', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='likesmodel', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.ticketsmodel', verbose_name='ticket'), + ), + migrations.AddField( + model_name='likesmodel', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user'), + ), + migrations.AddField( + model_name='participantpasportimagemodel', + name='participant', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participant_pasport_image', to='accounts.participantmodel'), + ), + migrations.AddField( + model_name='resettoken', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterUniqueTogether( + name='likesmodel', + unique_together={('user', 'ticket')}, + ), + ] diff --git a/core/apps/accounts/migrations/__init__.py b/core/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/accounts/models/__init__.py b/core/apps/accounts/models/__init__.py new file mode 100644 index 0000000..05cb9ea --- /dev/null +++ b/core/apps/accounts/models/__init__.py @@ -0,0 +1,5 @@ +# isort: skip_file +from .user import * # noqa +from .reset_token import * # noqa +from .likes import * # noqa +from .participant import * # noqa \ No newline at end of file diff --git a/core/apps/accounts/models/likes.py b/core/apps/accounts/models/likes.py new file mode 100644 index 0000000..3a221a7 --- /dev/null +++ b/core/apps/accounts/models/likes.py @@ -0,0 +1,27 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel +from .user import User +from core.apps.tickets.models.tickets import TicketsModel + + +class LikesModel(AbstractBaseModel): + user = models.ForeignKey(User, verbose_name=_('user'), on_delete=models.CASCADE) + ticket = models.ForeignKey(TicketsModel, verbose_name=_('ticket'), on_delete=models.CASCADE) + created_at = models.DateTimeField(verbose_name=_("created at"), auto_now_add=True) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + user=User._create_fake(), + ticket=TicketsModel._create_fake(), + ) + + class Meta: + unique_together = ('user', 'ticket') + db_table = "likes" + verbose_name = _("LikesModel") + verbose_name_plural = _("LikesModels") diff --git a/core/apps/accounts/models/participant.py b/core/apps/accounts/models/participant.py new file mode 100644 index 0000000..51fb214 --- /dev/null +++ b/core/apps/accounts/models/participant.py @@ -0,0 +1,53 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel + + +class ParticipantModel(AbstractBaseModel): + GenderChoices = ( + ('Male', 'Male'), + ('Female', 'Female'), + ) + gender = models.CharField(verbose_name=_("gender"), max_length=255, choices=GenderChoices, default='Male') + first_name = models.CharField(verbose_name=_("first name"), max_length=255) + last_name = models.CharField(verbose_name=_("last name"), max_length=255) + birth_date = models.DateField(verbose_name=_("birth date")) + phone_number = models.CharField(verbose_name=_("phone number"), max_length=255) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + first_name="mock", + last_name="mock", + birth_date="2025-09-19", + phone_number="998940105669", + ) + + class Meta: + db_table = "participant" + verbose_name = _("ParticipantModel") + verbose_name_plural = _("ParticipantModels") + + +class ParticipantPasportImageModel(AbstractBaseModel): + image = models.ImageField(verbose_name=_("image"), upload_to="participant_images/") + participant = models.ForeignKey(ParticipantModel, related_name="participant_pasport_image", + on_delete=models.CASCADE) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + image="resources/static/images/default_avatar.jpg", + participant=ParticipantModel._create_fake(), + ) + + class Meta: + db_table = "participant_pasport_image" + verbose_name = _("ParticipantPasportImageModel") + verbose_name_plural = _("ParticipantPasportImageModels") diff --git a/core/apps/accounts/models/reset_token.py b/core/apps/accounts/models/reset_token.py new file mode 100644 index 0000000..f9efafd --- /dev/null +++ b/core/apps/accounts/models/reset_token.py @@ -0,0 +1,15 @@ +from django.contrib.auth import get_user_model +from django.db import models +from django_core.models import AbstractBaseModel + + +class ResetToken(AbstractBaseModel): + token = models.CharField(max_length=255, unique=True) + user = models.ForeignKey(get_user_model(), on_delete=models.CASCADE) + + def __str__(self): + return self.token + + class Meta: + verbose_name = "Reset Token" + verbose_name_plural = "Reset Tokens" diff --git a/core/apps/accounts/models/user.py b/core/apps/accounts/models/user.py new file mode 100644 index 0000000..a1dd8e8 --- /dev/null +++ b/core/apps/accounts/models/user.py @@ -0,0 +1,33 @@ +from django.contrib.auth import models as auth_models +from django.db import models + +from ..choices import RoleChoice +from ..managers import UserManager + + +class User(auth_models.AbstractUser): + phone = models.CharField(max_length=255, unique=True, null=True, blank=True) + email = models.EmailField(unique=True, null=True, blank=True) + username = models.CharField(max_length=255, null=True, blank=True) + avatar = models.ImageField(default="static/images/default_avatar.jpg", upload_to="avatars/") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + validated_at = models.DateTimeField(null=True, blank=True) + role = models.CharField( + max_length=255, + choices=RoleChoice, + default=RoleChoice.USER, + ) + + USERNAME_FIELD = "phone" + objects = UserManager() + + def __str__(self): + return self.username + + @classmethod + def _create_fake(self): + return self.objects.create( + phone="998940105669", + username="mock", + ) diff --git a/core/apps/accounts/permissions/__init__.py b/core/apps/accounts/permissions/__init__.py new file mode 100644 index 0000000..20a43c8 --- /dev/null +++ b/core/apps/accounts/permissions/__init__.py @@ -0,0 +1,2 @@ +from .likes import * # noqa +from .participant import * # noqa diff --git a/core/apps/accounts/permissions/likes.py b/core/apps/accounts/permissions/likes.py new file mode 100644 index 0000000..8c263c1 --- /dev/null +++ b/core/apps/accounts/permissions/likes.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class LikesPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/accounts/permissions/participant.py b/core/apps/accounts/permissions/participant.py new file mode 100644 index 0000000..5a7d8e9 --- /dev/null +++ b/core/apps/accounts/permissions/participant.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class ParticipantPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/accounts/seeder/__init__.py b/core/apps/accounts/seeder/__init__.py new file mode 100644 index 0000000..151ee18 --- /dev/null +++ b/core/apps/accounts/seeder/__init__.py @@ -0,0 +1 @@ +from .core import * # noqa diff --git a/core/apps/accounts/seeder/core.py b/core/apps/accounts/seeder/core.py new file mode 100644 index 0000000..c487218 --- /dev/null +++ b/core/apps/accounts/seeder/core.py @@ -0,0 +1,10 @@ +""" +Create a new user/superuser +""" + +from django.contrib.auth import get_user_model + + +class UserSeeder: + def run(self): + get_user_model().objects.create_superuser("998888112309", "2309") diff --git a/core/apps/accounts/serializers/__init__.py b/core/apps/accounts/serializers/__init__.py new file mode 100644 index 0000000..d4191d3 --- /dev/null +++ b/core/apps/accounts/serializers/__init__.py @@ -0,0 +1,6 @@ +from .auth import * # noqa +from .change_password import * # noqa +from .likes import * # noqa +from .participant import * # noqa +from .set_password import * # noqa +from .user import * # noqa diff --git a/core/apps/accounts/serializers/auth.py b/core/apps/accounts/serializers/auth.py new file mode 100644 index 0000000..325eff6 --- /dev/null +++ b/core/apps/accounts/serializers/auth.py @@ -0,0 +1,60 @@ +from config.env import env +from django.contrib.auth import get_user_model +from django.utils.translation import gettext as _ +from rest_framework import exceptions, serializers + +OTP_SIZE = env.int("OTP_SIZE", 4) +class LoginSerializer(serializers.Serializer): + username = serializers.CharField(max_length=255) + password = serializers.CharField(max_length=255) + + +class RegisterSerializer(serializers.ModelSerializer): + phone = serializers.CharField(max_length=255) + + def validate_phone(self, value): + user = get_user_model().objects.filter(phone=value, validated_at__isnull=False) + if user.exists(): + raise exceptions.ValidationError(_("Phone number already registered."), code="unique") + return value + + class Meta: + model = get_user_model() + fields = ["first_name", "last_name", "phone", "password"] + extra_kwargs = { + "first_name": { + "required": True, + }, + "last_name": {"required": True}, + } + + +class ConfirmSerializer(serializers.Serializer): + code = serializers.CharField(max_length=OTP_SIZE, min_length=OTP_SIZE) + phone = serializers.CharField(max_length=255) + + +class ResetPasswordSerializer(serializers.Serializer): + phone = serializers.CharField(max_length=255) + + def validate_phone(self, value): + user = get_user_model().objects.filter(phone=value) + if user.exists(): + return value + + raise serializers.ValidationError(_("User does not exist")) + + +class ResetConfirmationSerializer(serializers.Serializer): + code = serializers.CharField(min_length=OTP_SIZE, max_length=OTP_SIZE) + phone = serializers.CharField(max_length=255) + + def validate_phone(self, value): + user = get_user_model().objects.filter(phone=value) + if user.exists(): + return value + raise serializers.ValidationError(_("User does not exist")) + + +class ResendSerializer(serializers.Serializer): + phone = serializers.CharField(max_length=255) diff --git a/core/apps/accounts/serializers/change_password.py b/core/apps/accounts/serializers/change_password.py new file mode 100644 index 0000000..79f4559 --- /dev/null +++ b/core/apps/accounts/serializers/change_password.py @@ -0,0 +1,6 @@ +from rest_framework import serializers + + +class ChangePasswordSerializer(serializers.Serializer): + old_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True, min_length=8) diff --git a/core/apps/accounts/serializers/likes/__init__.py b/core/apps/accounts/serializers/likes/__init__.py new file mode 100644 index 0000000..e870492 --- /dev/null +++ b/core/apps/accounts/serializers/likes/__init__.py @@ -0,0 +1 @@ +from .likes import * # noqa diff --git a/core/apps/accounts/serializers/likes/likes.py b/core/apps/accounts/serializers/likes/likes.py new file mode 100644 index 0000000..4aee9cb --- /dev/null +++ b/core/apps/accounts/serializers/likes/likes.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.accounts.models import LikesModel + + +class BaseLikesSerializer(serializers.ModelSerializer): + class Meta: + model = LikesModel + fields = [ + "id", + "user", + ] + + +class ListLikesSerializer(BaseLikesSerializer): + class Meta(BaseLikesSerializer.Meta): ... + + +class RetrieveLikesSerializer(BaseLikesSerializer): + class Meta(BaseLikesSerializer.Meta): ... + + +class CreateLikesSerializer(BaseLikesSerializer): + class Meta(BaseLikesSerializer.Meta): + fields = [ + "id", + "user", + ] diff --git a/core/apps/accounts/serializers/participant/__init__.py b/core/apps/accounts/serializers/participant/__init__.py new file mode 100644 index 0000000..e031d58 --- /dev/null +++ b/core/apps/accounts/serializers/participant/__init__.py @@ -0,0 +1 @@ +from .participant import * # noqa diff --git a/core/apps/accounts/serializers/participant/participant.py b/core/apps/accounts/serializers/participant/participant.py new file mode 100644 index 0000000..f0cfd67 --- /dev/null +++ b/core/apps/accounts/serializers/participant/participant.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.accounts.models import ParticipantModel + + +class BaseParticipantSerializer(serializers.ModelSerializer): + class Meta: + model = ParticipantModel + fields = [ + "id", + "name", + ] + + +class ListParticipantSerializer(BaseParticipantSerializer): + class Meta(BaseParticipantSerializer.Meta): ... + + +class RetrieveParticipantSerializer(BaseParticipantSerializer): + class Meta(BaseParticipantSerializer.Meta): ... + + +class CreateParticipantSerializer(BaseParticipantSerializer): + class Meta(BaseParticipantSerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/accounts/serializers/set_password.py b/core/apps/accounts/serializers/set_password.py new file mode 100644 index 0000000..556d530 --- /dev/null +++ b/core/apps/accounts/serializers/set_password.py @@ -0,0 +1,6 @@ +from rest_framework import serializers + + +class SetPasswordSerializer(serializers.Serializer): + password = serializers.CharField() + token = serializers.CharField(max_length=255) diff --git a/core/apps/accounts/serializers/user.py b/core/apps/accounts/serializers/user.py new file mode 100644 index 0000000..60f10d7 --- /dev/null +++ b/core/apps/accounts/serializers/user.py @@ -0,0 +1,23 @@ +from django.contrib.auth import get_user_model +from rest_framework import serializers + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + exclude = [ + "created_at", + "updated_at", + "password", + "groups", + "user_permissions" + ] + model = get_user_model() + + +class UserUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = get_user_model() + fields = [ + "first_name", + "last_name" + ] diff --git a/core/apps/accounts/signals/__init__.py b/core/apps/accounts/signals/__init__.py new file mode 100644 index 0000000..d953cf6 --- /dev/null +++ b/core/apps/accounts/signals/__init__.py @@ -0,0 +1,3 @@ +from .likes import * # noqa +from .participant import * # noqa +from .user import * # noqa diff --git a/core/apps/accounts/signals/likes.py b/core/apps/accounts/signals/likes.py new file mode 100644 index 0000000..3dc1b19 --- /dev/null +++ b/core/apps/accounts/signals/likes.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.accounts.models import LikesModel + + +@receiver(post_save, sender=LikesModel) +def LikesSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/accounts/signals/participant.py b/core/apps/accounts/signals/participant.py new file mode 100644 index 0000000..43c13a5 --- /dev/null +++ b/core/apps/accounts/signals/participant.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.accounts.models import ParticipantModel + + +@receiver(post_save, sender=ParticipantModel) +def ParticipantSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/accounts/signals/user.py b/core/apps/accounts/signals/user.py new file mode 100644 index 0000000..414a614 --- /dev/null +++ b/core/apps/accounts/signals/user.py @@ -0,0 +1,10 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.contrib.auth import get_user_model + + +@receiver(post_save, sender=get_user_model()) +def user_signal(sender, created, instance, **kwargs): + if created and instance.username is None: + instance.username = "U%(id)s" % {"id": 1000 + instance.id} + instance.save() diff --git a/core/apps/accounts/tasks/__init__.py b/core/apps/accounts/tasks/__init__.py new file mode 100644 index 0000000..23f58fb --- /dev/null +++ b/core/apps/accounts/tasks/__init__.py @@ -0,0 +1 @@ +from .sms import * # noqa diff --git a/core/apps/accounts/tasks/sms.py b/core/apps/accounts/tasks/sms.py new file mode 100644 index 0000000..d7e8499 --- /dev/null +++ b/core/apps/accounts/tasks/sms.py @@ -0,0 +1,28 @@ +""" +Base celery tasks +""" + +import logging +import os +from importlib import import_module + +from celery import shared_task +from config.env import env +from django.utils.translation import gettext as _ + + +@shared_task +def SendConfirm(phone, code): + try: + service = getattr( + import_module(os.getenv("OTP_MODULE")), os.getenv("OTP_SERVICE") + )() + service.send_sms( + phone, env.str("OTP_MESSAGE", _("Sizning Tasdiqlash ko'dingiz: %(code)s")) % {"code": code} + ) + logging.info("Sms send: %s-%s" % (phone, code)) + except Exception as e: + logging.error( + "Error: {phone}-{code}\n\n{error}".format(phone=phone, code=code, error=e) + ) # noqa + raise Exception diff --git a/core/apps/accounts/test/__init__.py b/core/apps/accounts/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/accounts/test/test_auth.py b/core/apps/accounts/test/test_auth.py new file mode 100644 index 0000000..648c235 --- /dev/null +++ b/core/apps/accounts/test/test_auth.py @@ -0,0 +1,116 @@ +import logging +from unittest.mock import patch + +from django.test import TestCase +from django.urls import reverse +from pydantic import BaseModel +from rest_framework import status +from rest_framework.test import APIClient + +from core.apps.accounts.models import ResetToken +from django_core.models import SmsConfirm +from core.services import SmsService +from django.contrib.auth import get_user_model + + +class TokenModel(BaseModel): + access: str + refresh: str + + +class SmsViewTest(TestCase): + def setUp(self): + self.client = APIClient() + self.phone = "998999999999" + self.password = "password" + self.code = "1111" + self.token = "token" + self.user = get_user_model().objects.create_user( + phone=self.phone, first_name="John", last_name="Doe", password=self.password + ) + SmsConfirm.objects.create(phone=self.phone, code=self.code) + + def test_reg_view(self): + """Test register view.""" + data = { + "phone": "998999999991", + "first_name": "John", + "last_name": "Doe", + "password": "password", + } + with patch.object(SmsService, "send_confirm", return_value=True): + response = self.client.post(reverse("auth-register"), data=data) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertEqual( + response.data["data"]["detail"], + "Sms %(phone)s raqamiga yuborildi" % {"phone": data["phone"]}, + ) + + def test_confirm_view(self): + """Test confirm view.""" + data = {"phone": self.phone, "code": self.code} + response = self.client.post(reverse("auth-confirm"), data=data) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + + def test_invalid_confirm_view(self): + """Test confirm view.""" + data = {"phone": self.phone, "code": "1112"} + response = self.client.post(reverse("auth-confirm"), data=data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_reset_confirmation_code_view(self): + """Test reset confirmation code view.""" + data = {"phone": self.phone, "code": self.code} + response = self.client.post(reverse("auth-confirm"), data=data) + self.assertEqual(response.status_code, status.HTTP_202_ACCEPTED) + self.assertIn("token", response.data["data"]) + + def test_reset_confirmation_code_view_invalid_code(self): + """Test reset confirmation code view with invalid code.""" + data = {"phone": self.phone, "code": "123456"} + response = self.client.post(reverse("auth-confirm"), data=data) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_reset_set_password_view(self): + """Test reset set password view.""" + token = ResetToken.objects.create(user=self.user, token=self.token) + data = {"token": token.token, "password": "new_password"} + response = self.client.post(reverse("reset-password-reset-password-set"), data=data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_reset_set_password_view_invalid_token(self): + """Test reset set password view with invalid token.""" + token = "test_token" + data = {"token": token, "password": "new_password"} + with patch.object(get_user_model().objects, "filter", return_value=get_user_model().objects.none()): + response = self.client.post(reverse("reset-password-reset-password-set"), data=data) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.data["data"]["detail"], "Invalid token") + + def test_resend_view(self): + """Test resend view.""" + data = {"phone": self.phone} + response = self.client.post(reverse("auth-resend"), data=data) + logging.error(response.json()) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_reset_password_view(self): + """Test reset password view.""" + data = {"phone": self.phone} + response = self.client.post(reverse("reset-password-reset-password"), data=data) + logging.error(response.json()) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_me_view(self): + """Test me view.""" + self.client.force_authenticate(user=self.user) + response = self.client.get(reverse("me-me")) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_me_update_view(self): + """Test me update view.""" + self.client.force_authenticate(user=self.user) + data = {"first_name": "Updated"} + response = self.client.patch(reverse("me-user-update"), data=data) + logging.error(response.json()) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/core/apps/accounts/test/test_change_password.py b/core/apps/accounts/test/test_change_password.py new file mode 100644 index 0000000..1e8b136 --- /dev/null +++ b/core/apps/accounts/test/test_change_password.py @@ -0,0 +1,58 @@ +from core.apps.accounts.serializers import ChangePasswordSerializer +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + + +class ChangePasswordViewTest(TestCase): + def setUp(self): + self.client = APIClient() + self.phone = "9981111111" + self.password = "12345670" + self.path = reverse("change-password-change-password") + + self.user = get_user_model().objects.create_user( + phone=self.phone, password=self.password, email="test@example.com" + ) + self.client.force_authenticate(user=self.user) + + def test_change_password_success(self): + data = { + "old_password": self.password, + "new_password": "newpassword", + } + response = self.client.post(self.path, data=data, format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['data']["detail"], "password changed successfully") + self.assertTrue(self.user.check_password("newpassword")) + + def test_change_password_invalid_old_password(self): + data = { + "old_password": "wrongpassword", + "new_password": "newpassword", + } + response = self.client.post(self.path, data=data, format="json") + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.data['data']["detail"], "invalida password") + + def test_change_password_serializer_validation(self): + data = { + "old_password": self.password, + "new_password": "newpassword", + } + serializer = ChangePasswordSerializer(data=data) + self.assertTrue(serializer.is_valid()) + + data = { + "old_password": self.password, + "new_password": "123", + } + serializer = ChangePasswordSerializer(data=data) + self.assertFalse(serializer.is_valid()) + + def test_change_password_view_permissions(self): + self.client.force_authenticate(user=None) + response = self.client.post(self.path, data={}, format="json") + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) diff --git a/core/apps/accounts/tests/__init__.py b/core/apps/accounts/tests/__init__.py new file mode 100644 index 0000000..34821e2 --- /dev/null +++ b/core/apps/accounts/tests/__init__.py @@ -0,0 +1,2 @@ +from .test_likes import * # noqa +from .test_participant import * # noqa diff --git a/core/apps/accounts/tests/test_likes.py b/core/apps/accounts/tests/test_likes.py new file mode 100644 index 0000000..908ab4b --- /dev/null +++ b/core/apps/accounts/tests/test_likes.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.accounts.models import LikesModel + + +class LikesTest(TestCase): + + def _create_data(self): + return LikesModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("likes-list"), + "retrieve": reverse("likes-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("likes-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) diff --git a/core/apps/accounts/tests/test_participant.py b/core/apps/accounts/tests/test_participant.py new file mode 100644 index 0000000..1538f1a --- /dev/null +++ b/core/apps/accounts/tests/test_participant.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.accounts.models import ParticipantModel + + +class ParticipantTest(TestCase): + + def _create_data(self): + return ParticipantModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("participant-list"), + "retrieve": reverse("participant-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("participant-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) diff --git a/core/apps/accounts/translation/__init__.py b/core/apps/accounts/translation/__init__.py new file mode 100644 index 0000000..20a43c8 --- /dev/null +++ b/core/apps/accounts/translation/__init__.py @@ -0,0 +1,2 @@ +from .likes import * # noqa +from .participant import * # noqa diff --git a/core/apps/accounts/translation/likes.py b/core/apps/accounts/translation/likes.py new file mode 100644 index 0000000..2da1279 --- /dev/null +++ b/core/apps/accounts/translation/likes.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.accounts.models import LikesModel + + +@register(LikesModel) +class LikesTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/accounts/translation/participant.py b/core/apps/accounts/translation/participant.py new file mode 100644 index 0000000..c9cbc02 --- /dev/null +++ b/core/apps/accounts/translation/participant.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.accounts.models import ParticipantModel + + +@register(ParticipantModel) +class ParticipantTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/accounts/urls.py b/core/apps/accounts/urls.py new file mode 100644 index 0000000..ef45730 --- /dev/null +++ b/core/apps/accounts/urls.py @@ -0,0 +1,25 @@ +from .views import ParticipantView + +""" +Accounts app urls +""" +from django.urls import include, path +from rest_framework.routers import DefaultRouter +from rest_framework_simplejwt import views as jwt_views + +from .views import ChangePasswordView, MeView, RegisterView, ResetPasswordView +from .views.likes import LikesView + +router = DefaultRouter() +router.register("participant", ParticipantView, basename="participant") +router.register("auth", RegisterView, basename="auth") +router.register("auth", ResetPasswordView, basename="reset-password") +router.register("auth", MeView, basename="me") +router.register("auth", ChangePasswordView, basename="change-password") +router.register("likes", LikesView, basename="likes") +urlpatterns = [ + path("", include(router.urls)), + path("auth/token/", jwt_views.TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("auth/token/verify/", jwt_views.TokenVerifyView.as_view(), name="token_verify"), + path("auth/token/refresh/", jwt_views.TokenRefreshView.as_view(), name="token_refresh"), +] diff --git a/core/apps/accounts/validators/__init__.py b/core/apps/accounts/validators/__init__.py new file mode 100644 index 0000000..20a43c8 --- /dev/null +++ b/core/apps/accounts/validators/__init__.py @@ -0,0 +1,2 @@ +from .likes import * # noqa +from .participant import * # noqa diff --git a/core/apps/accounts/validators/likes.py b/core/apps/accounts/validators/likes.py new file mode 100644 index 0000000..8d3baba --- /dev/null +++ b/core/apps/accounts/validators/likes.py @@ -0,0 +1,8 @@ +# from django.core.exceptions import ValidationError + + +class LikesValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/accounts/validators/participant.py b/core/apps/accounts/validators/participant.py new file mode 100644 index 0000000..6a6c759 --- /dev/null +++ b/core/apps/accounts/validators/participant.py @@ -0,0 +1,8 @@ +# from django.core.exceptions import ValidationError + + +class ParticipantValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/accounts/views/__init__.py b/core/apps/accounts/views/__init__.py new file mode 100644 index 0000000..47abd40 --- /dev/null +++ b/core/apps/accounts/views/__init__.py @@ -0,0 +1,3 @@ +from .auth import * # noqa +from .likes import * # noqa +from .participant import * # noqa diff --git a/core/apps/accounts/views/auth.py b/core/apps/accounts/views/auth.py new file mode 100644 index 0000000..fff686d --- /dev/null +++ b/core/apps/accounts/views/auth.py @@ -0,0 +1,209 @@ +import uuid +from typing import Type + +from core.services import UserService, SmsService +from django.contrib.auth import get_user_model +from django.utils.translation import gettext_lazy as _ +from django_core import exceptions +from drf_spectacular.utils import extend_schema +from rest_framework import status, throttling, request +from rest_framework.response import Response +from rest_framework.exceptions import PermissionDenied +from rest_framework.viewsets import GenericViewSet +from django_core.mixins import BaseViewSetMixin +from rest_framework.decorators import action +from ..serializers import ( + RegisterSerializer, + ConfirmSerializer, + ResendSerializer, + ResetPasswordSerializer, + ResetConfirmationSerializer, + SetPasswordSerializer, + UserSerializer, + UserUpdateSerializer, +) +from rest_framework.permissions import AllowAny +from django.contrib.auth.hashers import make_password +from drf_spectacular.utils import OpenApiResponse +from rest_framework.permissions import IsAuthenticated +from ..serializers import ChangePasswordSerializer + +from .. import models + + +@extend_schema(tags=["register"]) +class RegisterView(BaseViewSetMixin, GenericViewSet, UserService): + throttle_classes = [throttling.UserRateThrottle] + permission_classes = [AllowAny] + + def get_serializer_class(self): + match self.action: + case "register": + return RegisterSerializer + case "confirm": + return ConfirmSerializer + case "resend": + return ResendSerializer + case _: + return RegisterSerializer + + @action(methods=["POST"], detail=False, url_path="register") + def register(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.data + phone = data.get("phone") + # Create pending user + self.create_user(phone, data.get("first_name"), data.get("last_name"), data.get("password")) + self.send_confirmation(phone) # Send confirmation code for sms eskiz.uz + return Response( + {"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}}, + status=status.HTTP_202_ACCEPTED, + ) + + @extend_schema(summary="Auth confirm.", description="Auth confirm user.") + @action(methods=["POST"], detail=False, url_path="confirm") + def confirm(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.data + phone, code = data.get("phone"), data.get("code") + try: + if SmsService.check_confirm(phone, code=code): + token = self.validate_user(get_user_model().objects.filter(phone=phone).first()) + return Response( + data={ + "detail": _("Tasdiqlash ko'di qabul qilindi"), + "token": token, + }, + status=status.HTTP_202_ACCEPTED, + ) + except exceptions.SmsException as e: + raise PermissionDenied(e) # Response exception for APIException + except Exception as e: + raise PermissionDenied(e) # Api exception for APIException + + @action(methods=["POST"], detail=False, url_path="resend") + def resend(self, rq: Type[request.Request]): + ser = self.get_serializer(data=rq.data) + ser.is_valid(raise_exception=True) + phone = ser.data.get("phone") + self.send_confirmation(phone) + return Response({"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}}) + + +@extend_schema(tags=["reset-password"]) +class ResetPasswordView(BaseViewSetMixin, GenericViewSet, UserService): + permission_classes = [AllowAny] + + def get_serializer_class(self): + match self.action: + case "reset_password": + return ResetPasswordSerializer + case "reset_confirm": + return ResetConfirmationSerializer + case "reset_password_set": + return SetPasswordSerializer + case _: + return None + + @action(methods=["POST"], detail=False, url_path="reset-password") + def reset_password(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + phone = ser.data.get("phone") + self.send_confirmation(phone) + return Response({"detail": _("Sms %(phone)s raqamiga yuborildi") % {"phone": phone}}) + + @action(methods=["POST"], detail=False, url_path="reset-password-confirm") + def reset_confirm(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + + data = ser.data + code, phone = data.get("code"), data.get("phone") + try: + SmsService.check_confirm(phone, code) + token = models.ResetToken.objects.create( + user=get_user_model().objects.filter(phone=phone).first(), + token=str(uuid.uuid4()), + ) + return Response( + data={ + "token": token.token, + "created_at": token.created_at, + "updated_at": token.updated_at, + }, + status=status.HTTP_200_OK, + ) + except exceptions.SmsException as e: + raise PermissionDenied(str(e)) + except Exception as e: + raise PermissionDenied(str(e)) + + @action(methods=["POST"], detail=False, url_path="reset-password-set") + def reset_password_set(self, request): + ser = self.get_serializer(data=request.data) + ser.is_valid(raise_exception=True) + data = ser.data + token = data.get("token") + password = data.get("password") + token = models.ResetToken.objects.filter(token=token) + if not token.exists(): + raise PermissionDenied(_("Invalid token")) + phone = token.first().user.phone + token.delete() + self.change_password(phone, password) + return Response({"detail": _("password updated")}, status=status.HTTP_200_OK) + + +@extend_schema(tags=["me"]) +class MeView(BaseViewSetMixin, GenericViewSet, UserService): + permission_classes = [IsAuthenticated] + + def get_serializer_class(self): + match self.action: + case "me": + return UserSerializer + case "user_update": + return UserUpdateSerializer + case _: + return None + + @action(methods=["GET", "OPTIONS"], detail=False, url_path="me") + def me(self, request): + return Response(self.get_serializer(request.user).data) + + @action(methods=["PATCH", "PUT"], detail=False, url_path="user-update") + def user_update(self, request): + ser = self.get_serializer(instance=request.user, data=request.data, partial=True) + ser.is_valid(raise_exception=True) + ser.save() + return Response({"detail": _("Malumotlar yangilandi")}) + + +@extend_schema(tags=["change-password"], description="Parolni o'zgartirish uchun") +class ChangePasswordView(BaseViewSetMixin, GenericViewSet): + serializer_class = ChangePasswordSerializer + permission_classes = (IsAuthenticated,) + + @extend_schema( + request=serializer_class, + responses={200: OpenApiResponse(ChangePasswordSerializer)}, + summary="Change user password.", + description="Change password of the authenticated user.", + ) + @action(methods=["POST"], detail=False, url_path="change-password") + def change_password(self, request, *args, **kwargs): + user = self.request.user + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + + if user.check_password(request.data["old_password"]): + user.password = make_password(request.data["new_password"]) + user.save() + return Response( + data={"detail": "password changed successfully"}, + status=status.HTTP_200_OK, + ) + raise PermissionDenied(_("invalida password")) diff --git a/core/apps/accounts/views/likes.py b/core/apps/accounts/views/likes.py new file mode 100644 index 0000000..37ae43b --- /dev/null +++ b/core/apps/accounts/views/likes.py @@ -0,0 +1,21 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ReadOnlyModelViewSet + +from core.apps.accounts.models import LikesModel +from core.apps.accounts.serializers.likes import CreateLikesSerializer, ListLikesSerializer, RetrieveLikesSerializer + + +@extend_schema(tags=["likes"]) +class LikesView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = LikesModel.objects.all() + serializer_class = ListLikesSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListLikesSerializer, + "retrieve": RetrieveLikesSerializer, + "create": CreateLikesSerializer, + } diff --git a/core/apps/accounts/views/participant.py b/core/apps/accounts/views/participant.py new file mode 100644 index 0000000..40f6800 --- /dev/null +++ b/core/apps/accounts/views/participant.py @@ -0,0 +1,25 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ReadOnlyModelViewSet + +from core.apps.accounts.models import ParticipantModel +from core.apps.accounts.serializers.participant import ( + CreateParticipantSerializer, + ListParticipantSerializer, + RetrieveParticipantSerializer, +) + + +@extend_schema(tags=["participant"]) +class ParticipantView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = ParticipantModel.objects.all() + serializer_class = ListParticipantSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListParticipantSerializer, + "retrieve": RetrieveParticipantSerializer, + "create": CreateParticipantSerializer, + } diff --git a/core/apps/api/__init__.py b/core/apps/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/api/admin/__init__.py b/core/apps/api/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/api/apps.py b/core/apps/api/apps.py new file mode 100644 index 0000000..02f0401 --- /dev/null +++ b/core/apps/api/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.api" diff --git a/core/apps/api/migrations/__init__.py b/core/apps/api/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/api/models/__init__.py b/core/apps/api/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/api/serializers/__init__.py b/core/apps/api/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/api/tests/__init__.py b/core/apps/api/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/api/urls.py b/core/apps/api/urls.py new file mode 100644 index 0000000..5fa41be --- /dev/null +++ b/core/apps/api/urls.py @@ -0,0 +1,9 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +router = DefaultRouter() + + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/core/apps/api/views/__init__.py b/core/apps/api/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/blog/__init__.py b/core/apps/blog/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/blog/admin/__init__.py b/core/apps/blog/admin/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/admin/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/admin/post.py b/core/apps/blog/admin/post.py new file mode 100644 index 0000000..e12325b --- /dev/null +++ b/core/apps/blog/admin/post.py @@ -0,0 +1,36 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from core.apps.blog.models import CategoryModel, PostimagesModel, PostModel, TagModel + + +@admin.register(PostModel) +class PostAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) + + +@admin.register(TagModel) +class TagAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) + + +@admin.register(CategoryModel) +class CategoryAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) + + +@admin.register(PostimagesModel) +class PostimagesAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) diff --git a/core/apps/blog/apps.py b/core/apps/blog/apps.py new file mode 100644 index 0000000..f9dd9fe --- /dev/null +++ b/core/apps/blog/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.blog" diff --git a/core/apps/blog/filters/__init__.py b/core/apps/blog/filters/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/filters/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/filters/post.py b/core/apps/blog/filters/post.py new file mode 100644 index 0000000..ae60be8 --- /dev/null +++ b/core/apps/blog/filters/post.py @@ -0,0 +1,43 @@ +from django_filters import rest_framework as filters + +from core.apps.blog.models import CategoryModel, PostimagesModel, PostModel, TagModel + + +class PostFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = PostModel + fields = [ + "name", + ] + + +class TagFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = TagModel + fields = [ + "name", + ] + + +class CategoryFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = CategoryModel + fields = [ + "name", + ] + + +class PostimagesFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = PostimagesModel + fields = [ + "name", + ] diff --git a/core/apps/blog/forms/__init__.py b/core/apps/blog/forms/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/forms/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/forms/post.py b/core/apps/blog/forms/post.py new file mode 100644 index 0000000..a80ffba --- /dev/null +++ b/core/apps/blog/forms/post.py @@ -0,0 +1,31 @@ +from django import forms + +from core.apps.blog.models import CategoryModel, PostimagesModel, PostModel, TagModel + + +class PostForm(forms.ModelForm): + + class Meta: + model = PostModel + fields = "__all__" + + +class TagForm(forms.ModelForm): + + class Meta: + model = TagModel + fields = "__all__" + + +class CategoryForm(forms.ModelForm): + + class Meta: + model = CategoryModel + fields = "__all__" + + +class PostimagesForm(forms.ModelForm): + + class Meta: + model = PostimagesModel + fields = "__all__" diff --git a/core/apps/blog/migrations/0001_initial.py b/core/apps/blog/migrations/0001_initial.py new file mode 100644 index 0000000..d0af5a2 --- /dev/null +++ b/core/apps/blog/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 5.1.3 on 2025-09-19 10:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='CategoryModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ], + options={ + 'verbose_name': 'CategoryModel', + 'verbose_name_plural': 'CategoryModels', + 'db_table': 'category', + }, + ), + migrations.CreateModel( + name='PostModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=255, verbose_name='name')), + ('text', models.TextField(verbose_name='text')), + ('slug', models.SlugField(max_length=255, unique=True, verbose_name='slug')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')), + ('updated', models.DateTimeField(auto_now=True, verbose_name='updated')), + ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_category', to='blog.categorymodel', verbose_name='category')), + ], + options={ + 'verbose_name': 'PostModel', + 'verbose_name_plural': 'PostModels', + 'db_table': 'post', + }, + ), + migrations.CreateModel( + name='PostimagesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(upload_to='post/images/', verbose_name='image')), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_images', to='blog.postmodel', verbose_name='post')), + ], + options={ + 'verbose_name': 'PostimagesModel', + 'verbose_name_plural': 'PostimagesModels', + 'db_table': 'PostImages', + }, + ), + migrations.CreateModel( + name='TagModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='post_tags', to='blog.postmodel', verbose_name='post')), + ], + options={ + 'verbose_name': 'TagModel', + 'verbose_name_plural': 'TagModels', + 'db_table': 'tag', + }, + ), + ] diff --git a/core/apps/blog/migrations/__init__.py b/core/apps/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/blog/models/__init__.py b/core/apps/blog/models/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/models/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/models/post.py b/core/apps/blog/models/post.py new file mode 100644 index 0000000..5877076 --- /dev/null +++ b/core/apps/blog/models/post.py @@ -0,0 +1,92 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel +from django.utils.text import slugify + + +class CategoryModel(AbstractBaseModel): + name = models.CharField(verbose_name=_("name"), max_length=255) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + ) + + class Meta: + db_table = "category" + verbose_name = _("CategoryModel") + verbose_name_plural = _("CategoryModels") + + +class PostModel(AbstractBaseModel): + title = models.CharField(verbose_name=_("name"), max_length=255) + text = models.TextField(verbose_name=_("text")) + category = models.ForeignKey(CategoryModel, related_name="post_category", verbose_name=_("category"), + on_delete=models.CASCADE) + slug = models.SlugField(verbose_name=_("slug"), max_length=255, unique=True) + created = models.DateTimeField(verbose_name=_("created"), auto_now_add=True) + updated = models.DateTimeField(verbose_name=_("updated"), auto_now=True) + + def __str__(self): + return str(self.pk) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title) + super().save(*args, **kwargs) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + text="mock", + ) + + class Meta: + db_table = "post" + verbose_name = _("PostModel") + verbose_name_plural = _("PostModels") + + +class TagModel(AbstractBaseModel): + name = models.CharField(verbose_name=_("name"), max_length=255) + post = models.ForeignKey(PostModel, verbose_name=_("post"), related_name="post_tags", on_delete=models.CASCADE) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + post=PostModel._create_fake(), + ) + + class Meta: + db_table = "tag" + verbose_name = _("TagModel") + verbose_name_plural = _("TagModels") + + +class PostimagesModel(AbstractBaseModel): + image = models.ImageField(verbose_name=_("image"), upload_to="post/images/") + post = models.ForeignKey(PostModel, verbose_name=_("post"), related_name="post_images", on_delete=models.CASCADE) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + image="resources/static/images/default_avatar.jpg", + post=PostModel._create_fake(), + ) + + class Meta: + db_table = "PostImages" + verbose_name = _("PostimagesModel") + verbose_name_plural = _("PostimagesModels") diff --git a/core/apps/blog/permissions/__init__.py b/core/apps/blog/permissions/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/permissions/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/permissions/post.py b/core/apps/blog/permissions/post.py new file mode 100644 index 0000000..b5b2998 --- /dev/null +++ b/core/apps/blog/permissions/post.py @@ -0,0 +1,45 @@ +from rest_framework import permissions + + +class PostPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True + + +class TagPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True + + +class CategoryPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True + + +class PostimagesPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/blog/serializers/__init__.py b/core/apps/blog/serializers/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/serializers/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/serializers/post/__init__.py b/core/apps/blog/serializers/post/__init__.py new file mode 100644 index 0000000..efc52e0 --- /dev/null +++ b/core/apps/blog/serializers/post/__init__.py @@ -0,0 +1,3 @@ +from .category import * # noqa +from .post import * # noqa +from .tag import * # noqa diff --git a/core/apps/blog/serializers/post/category.py b/core/apps/blog/serializers/post/category.py new file mode 100644 index 0000000..bf47b9c --- /dev/null +++ b/core/apps/blog/serializers/post/category.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.blog.models import CategoryModel + + +class BaseCategorySerializer(serializers.ModelSerializer): + class Meta: + model = CategoryModel + fields = [ + "id", + "name", + ] + + +class ListCategorySerializer(BaseCategorySerializer): + class Meta(BaseCategorySerializer.Meta): ... + + +class RetrieveCategorySerializer(BaseCategorySerializer): + class Meta(BaseCategorySerializer.Meta): ... + + +class CreateCategorySerializer(BaseCategorySerializer): + class Meta(BaseCategorySerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/blog/serializers/post/post.py b/core/apps/blog/serializers/post/post.py new file mode 100644 index 0000000..9630ebe --- /dev/null +++ b/core/apps/blog/serializers/post/post.py @@ -0,0 +1,29 @@ +from core.apps.blog.models import PostModel +from rest_framework import serializers + + +class BasePostSerializer(serializers.ModelSerializer): + class Meta: + model = PostModel + fields = [ + "id", + "name", + + ] + + +class ListPostSerializer(BasePostSerializer): + class Meta(BasePostSerializer.Meta): ... + + +class RetrievePostSerializer(BasePostSerializer): + class Meta(BasePostSerializer.Meta): ... + + +class CreatePostSerializer(BasePostSerializer): + class Meta(BasePostSerializer.Meta): + fields = [ + "id", + "name", + + ] diff --git a/core/apps/blog/serializers/post/tag.py b/core/apps/blog/serializers/post/tag.py new file mode 100644 index 0000000..21245cf --- /dev/null +++ b/core/apps/blog/serializers/post/tag.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.blog.models import TagModel + + +class BaseTagSerializer(serializers.ModelSerializer): + class Meta: + model = TagModel + fields = [ + "id", + "name", + ] + + +class ListTagSerializer(BaseTagSerializer): + class Meta(BaseTagSerializer.Meta): ... + + +class RetrieveTagSerializer(BaseTagSerializer): + class Meta(BaseTagSerializer.Meta): ... + + +class CreateTagSerializer(BaseTagSerializer): + class Meta(BaseTagSerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/blog/signals/__init__.py b/core/apps/blog/signals/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/signals/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/signals/post.py b/core/apps/blog/signals/post.py new file mode 100644 index 0000000..c906be3 --- /dev/null +++ b/core/apps/blog/signals/post.py @@ -0,0 +1,20 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.blog.models import CategoryModel, PostimagesModel, PostModel, TagModel + + +@receiver(post_save, sender=PostModel) +def PostSignal(sender, instance, created, **kwargs): ... + + +@receiver(post_save, sender=TagModel) +def TagSignal(sender, instance, created, **kwargs): ... + + +@receiver(post_save, sender=CategoryModel) +def CategorySignal(sender, instance, created, **kwargs): ... + + +@receiver(post_save, sender=PostimagesModel) +def PostimagesSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/blog/tests/__init__.py b/core/apps/blog/tests/__init__.py new file mode 100644 index 0000000..3331203 --- /dev/null +++ b/core/apps/blog/tests/__init__.py @@ -0,0 +1 @@ +from .test_post import * # noqa diff --git a/core/apps/blog/tests/test_post.py b/core/apps/blog/tests/test_post.py new file mode 100644 index 0000000..352e2f4 --- /dev/null +++ b/core/apps/blog/tests/test_post.py @@ -0,0 +1,173 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.blog.models import CategoryModel, PostimagesModel, PostModel, TagModel + + +class PostTest(TestCase): + + def _create_data(self): + return PostModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("post -list"), + "retrieve": reverse("post -detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("post -detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) + + +class TagTest(TestCase): + + def _create_data(self): + return TagModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("tag-list"), + "retrieve": reverse("tag-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("tag-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) + + +class CategoryTest(TestCase): + + def _create_data(self): + return CategoryModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("category-list"), + "retrieve": reverse("category-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("category-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) + + +class PostimagesTest(TestCase): + + def _create_data(self): + return PostimagesModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("PostImages-list"), + "retrieve": reverse("PostImages-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("PostImages-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) diff --git a/core/apps/blog/translation/__init__.py b/core/apps/blog/translation/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/translation/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/translation/post.py b/core/apps/blog/translation/post.py new file mode 100644 index 0000000..b08bbde --- /dev/null +++ b/core/apps/blog/translation/post.py @@ -0,0 +1,23 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.blog.models import CategoryModel, PostimagesModel, PostModel, TagModel + + +@register(PostModel) +class PostTranslation(TranslationOptions): + fields = [] + + +@register(TagModel) +class TagTranslation(TranslationOptions): + fields = [] + + +@register(CategoryModel) +class CategoryTranslation(TranslationOptions): + fields = [] + + +@register(PostimagesModel) +class PostimagesTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/blog/urls.py b/core/apps/blog/urls.py new file mode 100644 index 0000000..5cfe54d --- /dev/null +++ b/core/apps/blog/urls.py @@ -0,0 +1,9 @@ +from .views import CategoryView, TagView, PostView +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +router = DefaultRouter() +router.register("post", PostView, basename="post") +router.register("category", CategoryView, basename="category") +router.register("tag", TagView, basename="tag") +urlpatterns = [path("", include(router.urls))] diff --git a/core/apps/blog/validators/__init__.py b/core/apps/blog/validators/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/validators/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/validators/post.py b/core/apps/blog/validators/post.py new file mode 100644 index 0000000..fcb3fe4 --- /dev/null +++ b/core/apps/blog/validators/post.py @@ -0,0 +1,29 @@ +# from django.core.exceptions import ValidationError + + +class PostValidator: + def __init__(self): ... + + def __call__(self): + return True + + +class TagValidator: + def __init__(self): ... + + def __call__(self): + return True + + +class CategoryValidator: + def __init__(self): ... + + def __call__(self): + return True + + +class PostimagesValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/blog/views/__init__.py b/core/apps/blog/views/__init__.py new file mode 100644 index 0000000..970613c --- /dev/null +++ b/core/apps/blog/views/__init__.py @@ -0,0 +1 @@ +from .post import * # noqa diff --git a/core/apps/blog/views/post.py b/core/apps/blog/views/post.py new file mode 100644 index 0000000..2031adb --- /dev/null +++ b/core/apps/blog/views/post.py @@ -0,0 +1,59 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ReadOnlyModelViewSet + +from core.apps.blog.models import CategoryModel, PostModel, TagModel +from core.apps.blog.serializers.post import ( + CreateCategorySerializer, + CreatePostSerializer, + CreateTagSerializer, + ListCategorySerializer, + ListPostSerializer, + ListTagSerializer, + RetrieveCategorySerializer, + RetrievePostSerializer, + RetrieveTagSerializer, +) + + +@extend_schema(tags=["post"]) +class PostView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = PostModel.objects.all() + serializer_class = ListPostSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListPostSerializer, + "retrieve": RetrievePostSerializer, + "create": CreatePostSerializer, + } + + +@extend_schema(tags=["tag"]) +class TagView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = TagModel.objects.all() + serializer_class = ListTagSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListTagSerializer, + "retrieve": RetrieveTagSerializer, + "create": CreateTagSerializer, + } + + +@extend_schema(tags=["category"]) +class CategoryView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = CategoryModel.objects.all() + serializer_class = ListCategorySerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListCategorySerializer, + "retrieve": RetrieveCategorySerializer, + "create": CreateCategorySerializer, + } diff --git a/core/apps/logs/.gitignore b/core/apps/logs/.gitignore new file mode 100644 index 0000000..a3a0c8b --- /dev/null +++ b/core/apps/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/core/apps/payments/__init__.py b/core/apps/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/payments/admin/__init__.py b/core/apps/payments/admin/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/payments/apps.py b/core/apps/payments/apps.py new file mode 100644 index 0000000..9aab28f --- /dev/null +++ b/core/apps/payments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.payments" diff --git a/core/apps/payments/migrations/__init__.py b/core/apps/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/payments/models/__init__.py b/core/apps/payments/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/payments/serializers/__init__.py b/core/apps/payments/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/payments/tests/__init__.py b/core/apps/payments/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/payments/urls.py b/core/apps/payments/urls.py new file mode 100644 index 0000000..5fa41be --- /dev/null +++ b/core/apps/payments/urls.py @@ -0,0 +1,9 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +router = DefaultRouter() + + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/core/apps/payments/views/__init__.py b/core/apps/payments/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/shared/__init__.py b/core/apps/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/shared/admin/__init__.py b/core/apps/shared/admin/__init__.py new file mode 100644 index 0000000..134e613 --- /dev/null +++ b/core/apps/shared/admin/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa \ No newline at end of file diff --git a/core/apps/shared/admin/settings.py b/core/apps/shared/admin/settings.py new file mode 100644 index 0000000..e07e246 --- /dev/null +++ b/core/apps/shared/admin/settings.py @@ -0,0 +1,20 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin, StackedInline +from core.apps.shared.models import SettingsModel, OptionsModel +from unfold.contrib.forms.widgets import ArrayWidget +from django.contrib.postgres.fields import ArrayField + + +class OptionsInline(StackedInline): + model = OptionsModel + extra = 1 + formfield_overrides = { + ArrayField: {"widget": ArrayWidget}, + } + + +@admin.register(SettingsModel) +class SettingsAdmin(ModelAdmin): + list_display = ["id", "key"] + inlines = [OptionsInline] + diff --git a/core/apps/shared/apps.py b/core/apps/shared/apps.py new file mode 100644 index 0000000..534230a --- /dev/null +++ b/core/apps/shared/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.shared" diff --git a/core/apps/shared/enums/__init__.py b/core/apps/shared/enums/__init__.py new file mode 100644 index 0000000..7e6f430 --- /dev/null +++ b/core/apps/shared/enums/__init__.py @@ -0,0 +1,17 @@ +from enum import Enum + + +class BaseEnum(Enum): + + def choices(self): + return [(x.name, x.value) for x in self] + + +class GenderEnum(BaseEnum): + MALE = "male" + FEMALE = "female" + + +class RoleEnum(BaseEnum): + ADMIN = "admin" + USER = "user" diff --git a/core/apps/shared/migrations/0001_initial.py b/core/apps/shared/migrations/0001_initial.py new file mode 100644 index 0000000..2991c7c --- /dev/null +++ b/core/apps/shared/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.3 on 2025-09-19 10:19 + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='SettingsModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('key', models.CharField(verbose_name='key')), + ('is_public', models.BooleanField(default=False, verbose_name='is public')), + ('description', models.TextField(blank=True, null=True, verbose_name='description')), + ], + options={ + 'verbose_name': 'Settings', + 'verbose_name_plural': 'Settings', + 'db_table': 'settings', + }, + ), + migrations.CreateModel( + name='OptionsModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key', models.CharField(max_length=255, verbose_name='key')), + ('value', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255, verbose_name='value'), size=None, verbose_name='value')), + ('settings', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='options', to='shared.settingsmodel', verbose_name='settings')), + ], + options={ + 'verbose_name': 'Options', + 'verbose_name_plural': 'Options', + 'db_table': 'options', + }, + ), + ] diff --git a/core/apps/shared/migrations/__init__.py b/core/apps/shared/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/shared/models/__init__.py b/core/apps/shared/models/__init__.py new file mode 100644 index 0000000..134e613 --- /dev/null +++ b/core/apps/shared/models/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa \ No newline at end of file diff --git a/core/apps/shared/models/settings.py b/core/apps/shared/models/settings.py new file mode 100644 index 0000000..98537a0 --- /dev/null +++ b/core/apps/shared/models/settings.py @@ -0,0 +1,31 @@ +from django.contrib.postgres.fields import ArrayField +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel + + +class SettingsModel(AbstractBaseModel): + key = models.CharField(_("key")) + is_public = models.BooleanField(_("is public"), default=False) + description = models.TextField(_("description"), blank=True, null=True) + + class Meta: + db_table = "settings" + verbose_name = _("Settings") + verbose_name_plural = _("Settings") + + +class OptionsModel(models.Model): + settings = models.ForeignKey( + "SettingsModel", verbose_name=_("settings"), on_delete=models.CASCADE, related_name="options" + ) + key = models.CharField(_("key"), max_length=255) + value = ArrayField( + models.CharField(_("value"), max_length=255), + verbose_name=_("value"), + ) + + class Meta: + db_table = "options" + verbose_name = _("Options") + verbose_name_plural = _("Options") diff --git a/core/apps/shared/serializers/__init__.py b/core/apps/shared/serializers/__init__.py new file mode 100644 index 0000000..fdf02cf --- /dev/null +++ b/core/apps/shared/serializers/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa \ No newline at end of file diff --git a/core/apps/shared/serializers/settings/__init__.py b/core/apps/shared/serializers/settings/__init__.py new file mode 100644 index 0000000..134e613 --- /dev/null +++ b/core/apps/shared/serializers/settings/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa \ No newline at end of file diff --git a/core/apps/shared/serializers/settings/settings.py b/core/apps/shared/serializers/settings/settings.py new file mode 100644 index 0000000..37fd78d --- /dev/null +++ b/core/apps/shared/serializers/settings/settings.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + + +class ListLanguageSerializer(serializers.Serializer): + code = serializers.CharField(read_only=True) + name = serializers.CharField(read_only=True) + is_default = serializers.BooleanField(read_only=True, default=False) diff --git a/core/apps/shared/tests/__init__.py b/core/apps/shared/tests/__init__.py new file mode 100644 index 0000000..838c01f --- /dev/null +++ b/core/apps/shared/tests/__init__.py @@ -0,0 +1 @@ +from .test_settings import * # noqa diff --git a/core/apps/shared/tests/test_settings.py b/core/apps/shared/tests/test_settings.py new file mode 100644 index 0000000..6516a52 --- /dev/null +++ b/core/apps/shared/tests/test_settings.py @@ -0,0 +1,16 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + + +class SettingsTest(TestCase): + + def setUp(self): + self.client = APIClient() + self.urls = { + "languages": reverse("settings-languages"), + } + + def test_languages(self): + response = self.client.get(self.urls["languages"]) + self.assertEqual(response.status_code, 200) diff --git a/core/apps/shared/urls.py b/core/apps/shared/urls.py new file mode 100644 index 0000000..bc256db --- /dev/null +++ b/core/apps/shared/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import SettingsView + +router = DefaultRouter() +router.register("settings", SettingsView, basename="settings") + + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/core/apps/shared/utils/__init__.py b/core/apps/shared/utils/__init__.py new file mode 100644 index 0000000..134e613 --- /dev/null +++ b/core/apps/shared/utils/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa \ No newline at end of file diff --git a/core/apps/shared/utils/settings.py b/core/apps/shared/utils/settings.py new file mode 100644 index 0000000..ff0c229 --- /dev/null +++ b/core/apps/shared/utils/settings.py @@ -0,0 +1,17 @@ +from core.apps.shared.models import OptionsModel +from typing import Optional +from django.utils.translation import gettext_lazy as _ + + +def get_config(settings: str, key: str, default=None) -> Optional[str]: + config = OptionsModel.objects.filter(settings__key=settings, key=key) + if not config.exists(): + return default + return config.first().value + + +def get_exchange_rate(): + exchange_rate = get_config("currency", "exchange_rate") + if exchange_rate is None: + raise Exception(_("USD kursi kiritilmagan iltimos adminga murojat qiling")) + return float(exchange_rate[0]) diff --git a/core/apps/shared/views/__init__.py b/core/apps/shared/views/__init__.py new file mode 100644 index 0000000..edbb5e5 --- /dev/null +++ b/core/apps/shared/views/__init__.py @@ -0,0 +1 @@ +from .settings import * # noqa diff --git a/core/apps/shared/views/settings.py b/core/apps/shared/views/settings.py new file mode 100644 index 0000000..d55f5c3 --- /dev/null +++ b/core/apps/shared/views/settings.py @@ -0,0 +1,53 @@ +from django_core.mixins import BaseViewSetMixin +from rest_framework.permissions import AllowAny +from rest_framework.decorators import action +from rest_framework.viewsets import GenericViewSet +from django.conf import settings +from rest_framework.response import Response +from ..serializers import ListLanguageSerializer +from drf_spectacular.utils import extend_schema, OpenApiResponse +from core.apps.shared.models import SettingsModel + + +@extend_schema(tags=["settings"]) +class SettingsView(BaseViewSetMixin, GenericViewSet): + permission_classes = [AllowAny] + + def get_serializer_class(self): + if self.action in ["languages"]: + return ListLanguageSerializer + return ListLanguageSerializer + + @extend_schema(responses={200: OpenApiResponse(response=ListLanguageSerializer(many=True))}) + @action(methods=["GET"], detail=False, url_path="languages", url_name="languages") + def languages(self, request): + return Response(self.get_serializer(settings.JST_LANGUAGES, many=True).data) + + @extend_schema( + summary="Get public settings", + responses={ + 200: OpenApiResponse( + response={ + "type": "object", + "properties": { + "example_key": { + "type": "object", + "properties": { + "example_key": {"type": "array", "items": {"type": "string"}, "example": [12300.50]} + }, + } + }, + } + ) + }, + ) + @action(methods=["GET"], detail=False, url_path="config", url_name="config") + def config(self, request): + config = SettingsModel.objects.filter(is_public=True) + response = {} + for item in config: + config_value = {} + for option in item.options.all(): + config_value[option.key] = option.value + response[item.key] = config_value + return Response(data=response) diff --git a/core/apps/tickets/__init__.py b/core/apps/tickets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/tickets/admin/__init__.py b/core/apps/tickets/admin/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/admin/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/admin/extra_services.py b/core/apps/tickets/admin/extra_services.py new file mode 100644 index 0000000..7596e86 --- /dev/null +++ b/core/apps/tickets/admin/extra_services.py @@ -0,0 +1,23 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from core.apps.tickets.models import ExtraServicesModel, PaidServicesModel + + +@admin.register(ExtraServicesModel) +class ExtraServocesAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) + + + + + +@admin.register(PaidServicesModel) +class PaidServicesAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) diff --git a/core/apps/tickets/admin/hotel.py b/core/apps/tickets/admin/hotel.py new file mode 100644 index 0000000..e684bee --- /dev/null +++ b/core/apps/tickets/admin/hotel.py @@ -0,0 +1,20 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from core.apps.tickets.models import HotelModel, HotelImagesModel + + +class HotelImagesInline(admin.TabularInline): + model = HotelImagesModel + extra = 1 + + +@admin.register(HotelModel) +class HotelAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) + inlines = ( + HotelImagesInline, + ) diff --git a/core/apps/tickets/admin/tariff.py b/core/apps/tickets/admin/tariff.py new file mode 100644 index 0000000..c98f63f --- /dev/null +++ b/core/apps/tickets/admin/tariff.py @@ -0,0 +1,12 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from core.apps.tickets.models import TariffModel + + +@admin.register(TariffModel) +class TariffAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) diff --git a/core/apps/tickets/admin/tickets.py b/core/apps/tickets/admin/tickets.py new file mode 100644 index 0000000..16c5633 --- /dev/null +++ b/core/apps/tickets/admin/tickets.py @@ -0,0 +1,80 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from core.apps.tickets.models import ( + TicketorderModel, + TicketsAmenitiesModel, + TicketsCommentsModel, + TicketsHotelMealsModel, + TicketsImagesModel, + TicketsIncludedServicesModel, + TicketsItineraryDestinationsModel, + TicketsItineraryImagesModel, + TicketsItineraryModel, + TicketsModel, +) + + +class TicketsImagesInline(admin.TabularInline): + model = TicketsImagesModel + extra = 1 + + +class TicketsAmenitiesInline(admin.TabularInline): + model = TicketsAmenitiesModel + extra = 1 + + +class TicketsIncludedServicesInline(admin.TabularInline): + model = TicketsIncludedServicesModel + extra = 1 + + +class TicketsHotelMealsInline(admin.TabularInline): + model = TicketsHotelMealsModel + extra = 1 + + +@admin.register(TicketsModel) +class TicketsAdmin(ModelAdmin): + list_display = ("id", "title", "price", "departure_date", "destination", "passenger_count", "rating") + inlines = [ + TicketsImagesInline, + TicketsAmenitiesInline, + TicketsIncludedServicesInline, + TicketsHotelMealsInline, + ] + + +class TicketsItineraryImagesInline(admin.TabularInline): + model = TicketsItineraryImagesModel + extra = 1 + + +class TicketsItineraryDestinationsInline(admin.TabularInline): + model = TicketsItineraryDestinationsModel + extra = 1 + + +@admin.register(TicketsItineraryModel) +class TicketsItineraryAdmin(ModelAdmin): + list_display = ("id", "ticket", "title", "duration") + inlines = [TicketsItineraryImagesInline, TicketsItineraryDestinationsInline] + + +@admin.register(TicketsCommentsModel) +class TicketsCommentsAdmin(ModelAdmin): + list_display = ( + "user", + "text", + "rating", + "ticket", + ) + + +@admin.register(TicketorderModel) +class TicketorderAdmin(ModelAdmin): + list_display = ( + "id", + "__str__", + ) diff --git a/core/apps/tickets/apps.py b/core/apps/tickets/apps.py new file mode 100644 index 0000000..48b1764 --- /dev/null +++ b/core/apps/tickets/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ModuleConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core.apps.tickets" + label = "tickets" diff --git a/core/apps/tickets/filters/__init__.py b/core/apps/tickets/filters/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/filters/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/filters/extra_services.py b/core/apps/tickets/filters/extra_services.py new file mode 100644 index 0000000..c2123bf --- /dev/null +++ b/core/apps/tickets/filters/extra_services.py @@ -0,0 +1,23 @@ +from django_filters import rest_framework as filters + +from core.apps.tickets.models import ExtraServicesModel, PaidServicesModel + + +class ExtraServicesFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = ExtraServicesModel + fields = [ + "name", + ] + + +class PaidServicesFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = PaidServicesModel + fields = [ + "name", + ] diff --git a/core/apps/tickets/filters/hotel.py b/core/apps/tickets/filters/hotel.py new file mode 100644 index 0000000..5f26e5f --- /dev/null +++ b/core/apps/tickets/filters/hotel.py @@ -0,0 +1,14 @@ +from django_filters import rest_framework as filters + +from core.apps.tickets.models import HotelModel, HotelImagesModel + + +class HotelFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = HotelModel + fields = [ + "name", + ] + diff --git a/core/apps/tickets/filters/tariff.py b/core/apps/tickets/filters/tariff.py new file mode 100644 index 0000000..0f9c29f --- /dev/null +++ b/core/apps/tickets/filters/tariff.py @@ -0,0 +1,13 @@ +from django_filters import rest_framework as filters + +from core.apps.tickets.models import TariffModel + + +class TariffFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = TariffModel + fields = [ + "name", + ] diff --git a/core/apps/tickets/filters/tickets.py b/core/apps/tickets/filters/tickets.py new file mode 100644 index 0000000..ceaf113 --- /dev/null +++ b/core/apps/tickets/filters/tickets.py @@ -0,0 +1,23 @@ +from django_filters import rest_framework as filters + +from core.apps.tickets.models import TicketorderModel, TicketsModel + + +class TicketsFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = TicketsModel + fields = [ + "name", + ] + + +class TicketorderFilter(filters.FilterSet): + # name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + class Meta: + model = TicketorderModel + fields = [ + "name", + ] diff --git a/core/apps/tickets/forms/__init__.py b/core/apps/tickets/forms/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/forms/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/forms/extra_services.py b/core/apps/tickets/forms/extra_services.py new file mode 100644 index 0000000..3d53445 --- /dev/null +++ b/core/apps/tickets/forms/extra_services.py @@ -0,0 +1,17 @@ +from django import forms + +from core.apps.tickets.models import ExtraServicesModel, PaidServicesModel + + +class ExtraServicesForm(forms.ModelForm): + + class Meta: + model = ExtraServicesModel + fields = "__all__" + + +class PaidServicesForm(forms.ModelForm): + + class Meta: + model = PaidServicesModel + fields = "__all__" diff --git a/core/apps/tickets/forms/hotel.py b/core/apps/tickets/forms/hotel.py new file mode 100644 index 0000000..35798ff --- /dev/null +++ b/core/apps/tickets/forms/hotel.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.tickets.models import HotelModel + + +class HotelForm(forms.ModelForm): + + class Meta: + model = HotelModel + fields = "__all__" diff --git a/core/apps/tickets/forms/paid_services.py b/core/apps/tickets/forms/paid_services.py new file mode 100644 index 0000000..dc455f3 --- /dev/null +++ b/core/apps/tickets/forms/paid_services.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.tickets.models import PaidServicesModel + + +class PaidServicesForm(forms.ModelForm): + + class Meta: + model = PaidServicesModel + fields = "__all__" diff --git a/core/apps/tickets/forms/tariff.py b/core/apps/tickets/forms/tariff.py new file mode 100644 index 0000000..eab73d8 --- /dev/null +++ b/core/apps/tickets/forms/tariff.py @@ -0,0 +1,10 @@ +from django import forms + +from core.apps.tickets.models import TariffModel + + +class TariffForm(forms.ModelForm): + + class Meta: + model = TariffModel + fields = "__all__" diff --git a/core/apps/tickets/forms/tickets.py b/core/apps/tickets/forms/tickets.py new file mode 100644 index 0000000..de47152 --- /dev/null +++ b/core/apps/tickets/forms/tickets.py @@ -0,0 +1,17 @@ +from django import forms + +from core.apps.tickets.models import TicketorderModel, TicketsModel + + +class TicketsForm(forms.ModelForm): + + class Meta: + model = TicketsModel + fields = "__all__" + + +class TicketorderForm(forms.ModelForm): + + class Meta: + model = TicketorderModel + fields = "__all__" diff --git a/core/apps/tickets/migrations/0001_initial.py b/core/apps/tickets/migrations/0001_initial.py new file mode 100644 index 0000000..4cf07fe --- /dev/null +++ b/core/apps/tickets/migrations/0001_initial.py @@ -0,0 +1,298 @@ +# Generated by Django 5.1.3 on 2025-09-19 10:19 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('accounts', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ExtraServicesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ], + options={ + 'verbose_name': 'ExtraServicesModel', + 'verbose_name_plural': 'ExtraServicesModels', + 'db_table': 'extra_services', + }, + ), + migrations.CreateModel( + name='HotelModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('address', models.TextField(verbose_name='address')), + ('city', models.CharField(max_length=100, verbose_name='city')), + ('country', models.CharField(max_length=100, verbose_name='city')), + ('desc', models.TextField(blank=True, null=True, verbose_name='description')), + ('phone', models.CharField(blank=True, max_length=50, null=True, verbose_name='phone number')), + ('website', models.URLField(blank=True, null=True, verbose_name='hotel website')), + ('rating', models.FloatField(blank=True, null=True, verbose_name='rating')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': 'HotelModel', + 'verbose_name_plural': 'HotelModels', + 'db_table': 'hotel', + }, + ), + migrations.CreateModel( + name='PaidServicesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('price', models.IntegerField(verbose_name='price')), + ], + options={ + 'verbose_name': 'PaidServicesModel', + 'verbose_name_plural': 'PaidServicesModels', + 'db_table': 'paid_services', + }, + ), + migrations.CreateModel( + name='TariffModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(choices=[('3', 'Standart'), ('4', 'Comfort'), ('5', 'Luxury')], default=('3', 'Standart'), max_length=255)), + ], + options={ + 'verbose_name': 'TariffModel', + 'verbose_name_plural': 'TariffModels', + 'db_table': 'tariff', + }, + ), + migrations.CreateModel( + name='TicketsItineraryModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('duration', models.IntegerField(verbose_name='duration')), + ], + options={ + 'verbose_name': 'TicketsItineraryModel', + 'verbose_name_plural': 'TicketsItineraryModel', + 'db_table': 'tickets_itinerary', + }, + ), + migrations.CreateModel( + name='HotelImagesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(upload_to='hotel_images/', verbose_name='image')), + ('hotel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.hotelmodel', verbose_name='hotel')), + ], + options={ + 'verbose_name': 'HotelImagesModel', + 'verbose_name_plural': 'HotelImagesModels', + 'db_table': 'hotel_images', + }, + ), + migrations.CreateModel( + name='TicketsItineraryImagesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(upload_to='ticket-included-services-images/', verbose_name='image')), + ('tickets_itinerary', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_itinerary_image', to='tickets.ticketsitinerarymodel', verbose_name='tickets_itinerary')), + ], + options={ + 'verbose_name': 'TicketsItineraryImagesModel', + 'verbose_name_plural': 'TicketsItineraryImagesModel', + 'db_table': 'tickets_itinerary_images', + }, + ), + migrations.CreateModel( + name='TicketsItineraryDestinationsModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('tickets_itinerary', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_itinerary_destinations', to='tickets.ticketsitinerarymodel', verbose_name='tickets_itinerary')), + ], + options={ + 'verbose_name': 'TicketsItineraryDestinationsModel', + 'verbose_name_plural': 'TicketsItineraryDestinationsModel', + 'db_table': 'tickets_itinerary_destinations', + }, + ), + migrations.CreateModel( + name='TicketsModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('price', models.BigIntegerField(verbose_name='price')), + ('departure_date', models.DateField(verbose_name='departure date')), + ('departure', models.CharField(max_length=255, verbose_name='departure')), + ('destination', models.CharField(max_length=255, verbose_name='destination')), + ('departure_time', models.DateTimeField(verbose_name='departure time')), + ('travel_time', models.DateTimeField(verbose_name='travel time')), + ('passenger_count', models.IntegerField(verbose_name='passenger count')), + ('languages', models.CharField(max_length=255, verbose_name='languages')), + ('rating', models.FloatField(verbose_name='rating')), + ('hotel_info', models.TextField(verbose_name='hotel info')), + ('duration_days', models.IntegerField(verbose_name='duration days')), + ('hotel_meals', models.TextField(blank=True, null=True, verbose_name='hotel meals')), + ('slug', models.SlugField(max_length=255, unique=True, verbose_name='slug')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='updated at')), + ('tariff', models.ManyToManyField(related_name='ticket_tariffs', to='tickets.tariffmodel', verbose_name='tariff')), + ], + options={ + 'verbose_name': 'TicketsModel', + 'verbose_name_plural': 'TicketsModels', + 'db_table': 'tickets', + }, + ), + migrations.AddField( + model_name='ticketsitinerarymodel', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_itinerary', to='tickets.ticketsmodel', verbose_name='ticket'), + ), + migrations.CreateModel( + name='TicketsIncludedServicesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(upload_to='ticket-included-services-images/', verbose_name='image')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ('desc', models.TextField(verbose_name='description')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_included_services', to='tickets.ticketsmodel', verbose_name='ticket')), + ], + options={ + 'verbose_name': 'TicketsIncludedServicesModel', + 'verbose_name_plural': 'TicketsIncludedServicesModel', + 'db_table': 'tickets_included_services', + }, + ), + migrations.CreateModel( + name='TicketsImagesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(upload_to='ticket-images/', verbose_name='image')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_images', to='tickets.ticketsmodel', verbose_name='ticket')), + ], + options={ + 'verbose_name': 'TicketsImagesModel', + 'verbose_name_plural': 'TicketsImagesModel', + 'db_table': 'tickets_images', + }, + ), + migrations.CreateModel( + name='TicketsHotelMealsModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('image', models.ImageField(upload_to='ticket-hotel-meals/', verbose_name='image')), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('desc', models.TextField(verbose_name='description')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_hotel_meals', to='tickets.ticketsmodel', verbose_name='ticket')), + ], + options={ + 'verbose_name': 'TicketsHotelMealsModel', + 'verbose_name_plural': 'TicketsHotelMealsModel', + 'db_table': 'tickets_hotel_meals', + }, + ), + migrations.CreateModel( + name='TicketsCommentsModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('text', models.TextField(verbose_name='text')), + ('rating', models.FloatField(verbose_name='rating')), + ('created', models.DateTimeField(auto_now_add=True, verbose_name='created')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_comments', to='tickets.ticketsmodel', verbose_name='ticket')), + ], + options={ + 'verbose_name': 'TicketsCommentsModel', + 'verbose_name_plural': 'TicketsCommentsModel', + 'db_table': 'tickets_comments', + }, + ), + migrations.CreateModel( + name='TicketsAmenitiesModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=255, verbose_name='name')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_amenities', to='tickets.ticketsmodel', verbose_name='ticket')), + ], + options={ + 'verbose_name': 'TicketsAmenitiesModel', + 'verbose_name_plural': 'TicketsAmenitiesModel', + 'db_table': 'tickets_amenities', + }, + ), + migrations.CreateModel( + name='TicketorderModel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('departure', models.CharField(max_length=255, verbose_name='departure')), + ('destination', models.CharField(max_length=255, verbose_name='destination')), + ('departure_date', models.DateField(verbose_name='departure date')), + ('arrival_time', models.DateField(verbose_name='arrival time')), + ('tariff', models.CharField(max_length=255, verbose_name='tariff')), + ('transport', models.CharField(max_length=255, verbose_name='transport')), + ('order_status', models.CharField(choices=[('pending_payment', 'Pending Payment'), ('pending_confirmation', 'Pending Confirmation'), ('cancelled', 'Cancelled'), ('confirmed', 'Confirmed'), ('completed', 'Completed')], default='pending_payment', max_length=255, verbose_name='order status')), + ('total_price', models.BigIntegerField(verbose_name='total price')), + ('extra_paid_service', models.ManyToManyField(to='tickets.paidservicesmodel', verbose_name='extra paid services')), + ('extra_service', models.ManyToManyField(to='tickets.extraservicesmodel', verbose_name='extra services')), + ('participant', models.ManyToManyField(blank=True, null=True, to='accounts.participantmodel', verbose_name='participant')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='tickets.ticketsmodel', verbose_name='ticket')), + ], + options={ + 'verbose_name': 'TicketorderModel', + 'verbose_name_plural': 'TicketorderModels', + 'db_table': 'ticketorder', + }, + ), + migrations.AddField( + model_name='paidservicesmodel', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_extra_paid_service', to='tickets.ticketsmodel', verbose_name='ticket'), + ), + migrations.AddField( + model_name='hotelmodel', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_hotel', to='tickets.ticketsmodel', verbose_name='ticket'), + ), + migrations.AddField( + model_name='extraservicesmodel', + name='ticket', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_extra_service', to='tickets.ticketsmodel', verbose_name='ticket'), + ), + ] diff --git a/core/apps/tickets/migrations/__init__.py b/core/apps/tickets/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/apps/tickets/models/__init__.py b/core/apps/tickets/models/__init__.py new file mode 100644 index 0000000..78aded8 --- /dev/null +++ b/core/apps/tickets/models/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa \ No newline at end of file diff --git a/core/apps/tickets/models/extra_services.py b/core/apps/tickets/models/extra_services.py new file mode 100644 index 0000000..af8e70b --- /dev/null +++ b/core/apps/tickets/models/extra_services.py @@ -0,0 +1,49 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel +from ..models.tickets import TicketsModel + + +class ExtraServicesModel(AbstractBaseModel): + name = models.CharField(verbose_name=_("name"), max_length=255) + ticket = models.ForeignKey(TicketsModel, verbose_name=_("ticket"), related_name="ticket_extra_service", + on_delete=models.CASCADE) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + ticket=TicketsModel._create_fake(), + ) + + class Meta: + db_table = "extra_services" + verbose_name = _("ExtraServicesModel") + verbose_name_plural = _("ExtraServicesModels") + + +class PaidServicesModel(AbstractBaseModel): + name = models.CharField(verbose_name=_("name"), max_length=255) + price = models.IntegerField(verbose_name=_("price")) + ticket = models.ForeignKey(TicketsModel, verbose_name=_("ticket"), related_name="ticket_extra_paid_service", + on_delete=models.CASCADE) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + price=40, + ticket=TicketsModel._create_fake(), + + ) + + class Meta: + db_table = "paid_services" + verbose_name = _("PaidServicesModel") + verbose_name_plural = _("PaidServicesModels") diff --git a/core/apps/tickets/models/hotel.py b/core/apps/tickets/models/hotel.py new file mode 100644 index 0000000..b7d6a25 --- /dev/null +++ b/core/apps/tickets/models/hotel.py @@ -0,0 +1,60 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel +from .tickets import TicketsModel + + +class HotelModel(AbstractBaseModel): + ticket = models.ForeignKey(TicketsModel, related_name="ticket_hotel", verbose_name=_("ticket"), + on_delete=models.CASCADE) + name = models.CharField(verbose_name=_("name"), max_length=255) + address = models.TextField(verbose_name=_("address")) + city = models.CharField(verbose_name=_("city"), max_length=100) + country = models.CharField(verbose_name=_("city"), max_length=100) + desc = models.TextField(verbose_name=_("description"), blank=True, null=True) + phone = models.CharField(verbose_name=_("phone number"), max_length=50, blank=True, null=True) + website = models.URLField(verbose_name=_("hotel website"), blank=True, null=True) + rating = models.FloatField(verbose_name=_("rating"), blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + address="mock", + city="mock", + country="mock", + desc="mock", + phone="mock", + website="https://www.default.com/", + rating=4.5, + ) + + class Meta: + db_table = "hotel" + verbose_name = _("HotelModel") + verbose_name_plural = _("HotelModels") + + +class HotelImagesModel(AbstractBaseModel): + image = models.ImageField(verbose_name=_("image"), upload_to="hotel_images/") + hotel = models.ForeignKey(HotelModel, verbose_name=_("hotel"), on_delete=models.CASCADE) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + image="resources/static/images/logo.png", + hotel=HotelModel._create_fake(), + ) + + class Meta: + db_table = "hotel_images" + verbose_name = _("HotelImagesModel") + verbose_name_plural = _("HotelImagesModels") diff --git a/core/apps/tickets/models/tariff.py b/core/apps/tickets/models/tariff.py new file mode 100644 index 0000000..70c2763 --- /dev/null +++ b/core/apps/tickets/models/tariff.py @@ -0,0 +1,27 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel + + +class TariffModel(AbstractBaseModel): + Tariff_Choise = ( + ("3", "Standart"), + ("4", "Comfort"), + ("5", "Luxury"), + ) + + name = models.CharField(choices=Tariff_Choise, max_length=255, default=Tariff_Choise[0]) + + def __str__(self): + return str(self.name) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + ) + + class Meta: + db_table = "tariff" + verbose_name = _("TariffModel") + verbose_name_plural = _("TariffModels") diff --git a/core/apps/tickets/models/tickets.py b/core/apps/tickets/models/tickets.py new file mode 100644 index 0000000..be23254 --- /dev/null +++ b/core/apps/tickets/models/tickets.py @@ -0,0 +1,322 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django_core.models import AbstractBaseModel + +from core.apps.accounts.models import User +from core.apps.accounts.models.participant import ParticipantModel + +from .tariff import TariffModel +from django.utils.text import slugify + + +class TicketsModel(AbstractBaseModel): + """Chiptalar haqidagi asosiy ma’lumotlarni saqlaydi.""" + + title = models.CharField(verbose_name=_("title"), max_length=255) + price = models.BigIntegerField(verbose_name=_("price")) + departure_date = models.DateField(verbose_name=_("departure date")) + departure = models.CharField(verbose_name=_("departure"), max_length=255) + destination = models.CharField(verbose_name=_("destination"), max_length=255) + ############# + # Bu ikkisi aniq bo'lgandan keyin yo olib tashlanadi yoki qoladi ! + departure_time = models.DateTimeField(verbose_name=_("departure time")) + travel_time = models.DateTimeField(verbose_name=_("travel time")) + ############## + passenger_count = models.IntegerField(verbose_name=_("passenger count")) + languages = models.CharField(verbose_name=_("languages"), max_length=255) + rating = models.FloatField(verbose_name=_("rating")) + hotel_info = models.TextField(verbose_name=_("hotel info")) + duration_days = models.IntegerField(verbose_name=_("duration days")) + hotel_meals = models.TextField(verbose_name=_("hotel meals"), null=True, blank=True) + tariff = models.ManyToManyField(TariffModel, related_name="ticket_tariffs", verbose_name=_("tariff")) + slug = models.SlugField(verbose_name=_("slug"), max_length=255, unique=True) + created_at = models.DateTimeField(verbose_name=_("created at"), auto_now_add=True) + updated_at = models.DateTimeField(verbose_name=_("updated at"), auto_now=True) + + def __str__(self): + return str(self.pk) + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title) + super().save(*args, **kwargs) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + price=150000, + departure_date="2025-10-01", + destination="mock", + departure="mock", + passenger_count=4, + rating=4.5, + hotel_info="mock", + duration_days=15, + ) + + class Meta: + db_table = "tickets" + verbose_name = _("TicketsModel") + verbose_name_plural = _("TicketsModels") + + +class TicketsImagesModel(AbstractBaseModel): + """Chipta bilan bogβ€˜liq rasmlarni saqlaydi.""" + + image = models.ImageField(verbose_name=_("image"), upload_to="ticket-images/") + ticket = models.ForeignKey( + TicketsModel, verbose_name=_("ticket"), related_name="ticket_images", on_delete=models.CASCADE + ) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + image="resources/static/images/logo.png", + ticket=TicketsModel._create_fake(), + ) + + class Meta: + db_table = "tickets_images" + verbose_name = _("TicketsImagesModel") + verbose_name_plural = _("TicketsImagesModel") + + +class TicketsAmenitiesModel(AbstractBaseModel): + """Chipta ichidagi qulayliklarni saqlaydi.""" + + name = models.CharField(verbose_name=_("name"), max_length=255) + ticket = models.ForeignKey( + TicketsModel, verbose_name=_("ticket"), related_name="ticket_amenities", on_delete=models.CASCADE + ) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + ticket=TicketsModel._create_fake(), + ) + + class Meta: + db_table = "tickets_amenities" + verbose_name = _("TicketsAmenitiesModel") + verbose_name_plural = _("TicketsAmenitiesModel") + + +class TicketsIncludedServicesModel(AbstractBaseModel): + """Chipta narxiga kiradigan xizmatlarni saqlaydi.""" + + image = models.ImageField(verbose_name=_("image"), upload_to="ticket-included-services-images/") + title = models.CharField(verbose_name=_("title"), max_length=255) + desc = models.TextField(verbose_name=_("description")) + ticket = models.ForeignKey( + TicketsModel, verbose_name=_("ticket"), related_name="ticket_included_services", on_delete=models.CASCADE + ) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + image="resources/static/images/logo.png", + title="mock", + desc="mock", + ticket=TicketsModel._create_fake(), + ) + + class Meta: + db_table = "tickets_included_services" + verbose_name = _("TicketsIncludedServicesModel") + verbose_name_plural = _("TicketsIncludedServicesModel") + + +class TicketsItineraryModel(AbstractBaseModel): + """Chipta boβ€˜yicha safar rejasini saqlaydi.""" + + title = models.CharField(verbose_name=_("title"), max_length=255) + duration = models.IntegerField(verbose_name=_("duration")) + ticket = models.ForeignKey( + TicketsModel, verbose_name=_("ticket"), related_name="ticket_itinerary", on_delete=models.CASCADE + ) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + title="mock", + duration=4, + ticket=TicketsModel._create_fake(), + ) + + class Meta: + db_table = "tickets_itinerary" + verbose_name = _("TicketsItineraryModel") + verbose_name_plural = _("TicketsItineraryModel") + + +class TicketsItineraryImagesModel(AbstractBaseModel): + """Safar rejasiga oid rasmlarni saqlaydi.""" + + image = models.ImageField(verbose_name=_("image"), upload_to="ticket-included-services-images/") + tickets_itinerary = models.ForeignKey( + TicketsItineraryModel, + verbose_name=_("tickets_itinerary"), + related_name="ticket_itinerary_image", + on_delete=models.CASCADE, + ) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + image="resources/static/images/logo.png", + tickets_itinerary=TicketsItineraryModel._create_fake(), + ) + + class Meta: + db_table = "tickets_itinerary_images" + verbose_name = _("TicketsItineraryImagesModel") + verbose_name_plural = _("TicketsItineraryImagesModel") + + +class TicketsItineraryDestinationsModel(AbstractBaseModel): + """Safar davomida boriladigan manzillarni saqlaydi.""" + + name = models.CharField(verbose_name=_("name"), max_length=255) + tickets_itinerary = models.ForeignKey( + TicketsItineraryModel, + verbose_name=_("tickets_itinerary"), + related_name="ticket_itinerary_destinations", + on_delete=models.CASCADE, + ) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + tickets_itinerary=TicketsItineraryModel._create_fake(), + ) + + class Meta: + db_table = "tickets_itinerary_destinations" + verbose_name = _("TicketsItineraryDestinationsModel") + verbose_name_plural = _("TicketsItineraryDestinationsModel") + + +class TicketsHotelMealsModel(AbstractBaseModel): + """Mehmonxonada beriladigan taomlarni saqlaydi.""" + + image = models.ImageField(verbose_name=_("image"), upload_to="ticket-hotel-meals/") + name = models.CharField(verbose_name=_("name"), max_length=255) + desc = models.TextField(verbose_name=_("description")) + ticket = models.ForeignKey( + TicketsModel, verbose_name=_("ticket"), related_name="ticket_hotel_meals", on_delete=models.CASCADE + ) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + name="mock", + desc="mock", + ticket=TicketsModel._create_fake(), + ) + + class Meta: + db_table = "tickets_hotel_meals" + verbose_name = _("TicketsHotelMealsModel") + verbose_name_plural = _("TicketsHotelMealsModel") + + +class TicketsCommentsModel(AbstractBaseModel): + """Chiptaga yozilgan sharh va baholarni saqlaydi.""" + + user = models.ForeignKey(User, verbose_name=_("user"), on_delete=models.CASCADE) + text = models.TextField(verbose_name=_("text")) + rating = models.FloatField(verbose_name=_("rating")) + ticket = models.ForeignKey( + TicketsModel, verbose_name=_("ticket"), related_name="ticket_comments", on_delete=models.CASCADE + ) + created = models.DateTimeField(verbose_name=_("created"), auto_now_add=True) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + user=User._create_fake(), + text="mock", + rating=4.5, + tickets_itinerary=TicketsModel._create_fake(), + ) + + class Meta: + db_table = "tickets_comments" + verbose_name = _("TicketsCommentsModel") + verbose_name_plural = _("TicketsCommentsModel") + + +class TicketorderModel(AbstractBaseModel): + STATUS_CHOICES = [ + ("pending_payment", "Pending Payment"), + ("pending_confirmation", "Pending Confirmation"), + ("cancelled", "Cancelled"), + ("confirmed", "Confirmed"), + ("completed", "Completed"), + ] + user = models.ForeignKey(User, verbose_name=_("user"), on_delete=models.CASCADE) + departure = models.CharField(verbose_name=_("departure"), max_length=255) + destination = models.CharField(verbose_name=_("destination"), max_length=255) + departure_date = models.DateField(verbose_name=_("departure date")) + arrival_time = models.DateField(verbose_name=_("arrival time")) + participant = models.ManyToManyField(ParticipantModel, verbose_name=_("participant"), null=True, blank=True) + ticket = models.ForeignKey(TicketsModel, verbose_name=_("ticket"), on_delete=models.CASCADE) + tariff = models.CharField(verbose_name=_("tariff"), max_length=255) + transport = models.CharField(verbose_name=_("transport"), max_length=255) + extra_service = models.ManyToManyField("tickets.ExtraServicesModel", verbose_name=_("extra services")) + extra_paid_service = models.ManyToManyField("tickets.PaidServicesModel", verbose_name=_("extra paid services")) + order_status = models.CharField(verbose_name=_("order status"), max_length=255, choices=STATUS_CHOICES, + default="pending_payment") + total_price = models.BigIntegerField(verbose_name=_("total price")) + + def __str__(self): + return str(self.pk) + + @classmethod + def _create_fake(self): + return self.objects.create( + user=User._create_fake(), + departure="mock", + destination="mock", + departure_date="2025-12-01", + arrival_time="2025-12-01", + participant=ParticipantModel._create_fake(), + ticket=TicketsModel._create_fake(), + tariff="mock", + transport="mock", + extra_service=ExtraServicesModel._create_fake(), + extra_paid_service=PaidServicesModel._create_fake(), + total_price=40, + ) + + class Meta: + db_table = "ticketorder" + verbose_name = _("TicketorderModel") + verbose_name_plural = _("TicketorderModels") diff --git a/core/apps/tickets/permissions/__init__.py b/core/apps/tickets/permissions/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/permissions/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/permissions/extra_services.py b/core/apps/tickets/permissions/extra_services.py new file mode 100644 index 0000000..f998155 --- /dev/null +++ b/core/apps/tickets/permissions/extra_services.py @@ -0,0 +1,23 @@ +from rest_framework import permissions + + +class ExtraServicesPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True + + +class PaidServicesPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/tickets/permissions/hotel.py b/core/apps/tickets/permissions/hotel.py new file mode 100644 index 0000000..c0c92b9 --- /dev/null +++ b/core/apps/tickets/permissions/hotel.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class HotelPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/tickets/permissions/tariff.py b/core/apps/tickets/permissions/tariff.py new file mode 100644 index 0000000..440d83d --- /dev/null +++ b/core/apps/tickets/permissions/tariff.py @@ -0,0 +1,12 @@ +from rest_framework import permissions + + +class TariffPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/tickets/permissions/tickets.py b/core/apps/tickets/permissions/tickets.py new file mode 100644 index 0000000..9cb05f1 --- /dev/null +++ b/core/apps/tickets/permissions/tickets.py @@ -0,0 +1,23 @@ +from rest_framework import permissions + + +class TicketsPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True + + +class TicketorderPermission(permissions.BasePermission): + + def __init__(self) -> None: ... + + def __call__(self, *args, **kwargs): + return self + + def has_permission(self, request, view): + return True diff --git a/core/apps/tickets/serializers/__init__.py b/core/apps/tickets/serializers/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/serializers/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/serializers/extra_services/__init__.py b/core/apps/tickets/serializers/extra_services/__init__.py new file mode 100644 index 0000000..bb6bf69 --- /dev/null +++ b/core/apps/tickets/serializers/extra_services/__init__.py @@ -0,0 +1,2 @@ +from .extra_services import * # noqa +from .paid_services import * # noqa diff --git a/core/apps/tickets/serializers/extra_services/extra_services.py b/core/apps/tickets/serializers/extra_services/extra_services.py new file mode 100644 index 0000000..80e61d0 --- /dev/null +++ b/core/apps/tickets/serializers/extra_services/extra_services.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.tickets.models import ExtraServicesModel + + +class BaseExtraServicesSerializer(serializers.ModelSerializer): + class Meta: + model = ExtraServicesModel + fields = [ + "id", + "name", + ] + + +class ListExtraServicesSerializer(BaseExtraServicesSerializer): + class Meta(BaseExtraServicesSerializer.Meta): ... + + +class RetrieveExtraServicesSerializer(BaseExtraServicesSerializer): + class Meta(BaseExtraServicesSerializer.Meta): ... + + +class CreateExtraServicesSerializer(BaseExtraServicesSerializer): + class Meta(BaseExtraServicesSerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/tickets/serializers/extra_services/paid_services.py b/core/apps/tickets/serializers/extra_services/paid_services.py new file mode 100644 index 0000000..781e205 --- /dev/null +++ b/core/apps/tickets/serializers/extra_services/paid_services.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.tickets.models import PaidServicesModel + + +class BasePaidServicesSerializer(serializers.ModelSerializer): + class Meta: + model = PaidServicesModel + fields = [ + "id", + "name", + ] + + +class ListPaidServicesSerializer(BasePaidServicesSerializer): + class Meta(BasePaidServicesSerializer.Meta): ... + + +class RetrievePaidServicesSerializer(BasePaidServicesSerializer): + class Meta(BasePaidServicesSerializer.Meta): ... + + +class CreatePaidServicesSerializer(BasePaidServicesSerializer): + class Meta(BasePaidServicesSerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/tickets/serializers/hotel/__init__.py b/core/apps/tickets/serializers/hotel/__init__.py new file mode 100644 index 0000000..e8ca454 --- /dev/null +++ b/core/apps/tickets/serializers/hotel/__init__.py @@ -0,0 +1 @@ +from .hotel import * # noqa diff --git a/core/apps/tickets/serializers/hotel/hotel.py b/core/apps/tickets/serializers/hotel/hotel.py new file mode 100644 index 0000000..9e57abd --- /dev/null +++ b/core/apps/tickets/serializers/hotel/hotel.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.tickets.models import HotelModel + + +class BaseHotelSerializer(serializers.ModelSerializer): + class Meta: + model = HotelModel + fields = [ + "id", + "name", + ] + + +class ListHotelSerializer(BaseHotelSerializer): + class Meta(BaseHotelSerializer.Meta): ... + + +class RetrieveHotelSerializer(BaseHotelSerializer): + class Meta(BaseHotelSerializer.Meta): ... + + +class CreateHotelSerializer(BaseHotelSerializer): + class Meta(BaseHotelSerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/tickets/serializers/tariff/__init__.py b/core/apps/tickets/serializers/tariff/__init__.py new file mode 100644 index 0000000..d78761d --- /dev/null +++ b/core/apps/tickets/serializers/tariff/__init__.py @@ -0,0 +1 @@ +from .tariff import * # noqa diff --git a/core/apps/tickets/serializers/tariff/tariff.py b/core/apps/tickets/serializers/tariff/tariff.py new file mode 100644 index 0000000..1c56cc9 --- /dev/null +++ b/core/apps/tickets/serializers/tariff/tariff.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.tickets.models import TariffModel + + +class BaseTariffSerializer(serializers.ModelSerializer): + class Meta: + model = TariffModel + fields = [ + "id", + "name", + ] + + +class ListTariffSerializer(BaseTariffSerializer): + class Meta(BaseTariffSerializer.Meta): ... + + +class RetrieveTariffSerializer(BaseTariffSerializer): + class Meta(BaseTariffSerializer.Meta): ... + + +class CreateTariffSerializer(BaseTariffSerializer): + class Meta(BaseTariffSerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/tickets/serializers/tickets/__init__.py b/core/apps/tickets/serializers/tickets/__init__.py new file mode 100644 index 0000000..8205739 --- /dev/null +++ b/core/apps/tickets/serializers/tickets/__init__.py @@ -0,0 +1,2 @@ +from .ticketorder import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/serializers/tickets/ticketorder.py b/core/apps/tickets/serializers/tickets/ticketorder.py new file mode 100644 index 0000000..e0943b5 --- /dev/null +++ b/core/apps/tickets/serializers/tickets/ticketorder.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from core.apps.tickets.models import TicketorderModel + + +class BaseTicketorderSerializer(serializers.ModelSerializer): + class Meta: + model = TicketorderModel + fields = [ + "id", + "name", + ] + + +class ListTicketorderSerializer(BaseTicketorderSerializer): + class Meta(BaseTicketorderSerializer.Meta): ... + + +class RetrieveTicketorderSerializer(BaseTicketorderSerializer): + class Meta(BaseTicketorderSerializer.Meta): ... + + +class CreateTicketorderSerializer(BaseTicketorderSerializer): + class Meta(BaseTicketorderSerializer.Meta): + fields = [ + "id", + "name", + ] diff --git a/core/apps/tickets/serializers/tickets/tickets.py b/core/apps/tickets/serializers/tickets/tickets.py new file mode 100644 index 0000000..560a90a --- /dev/null +++ b/core/apps/tickets/serializers/tickets/tickets.py @@ -0,0 +1,132 @@ +from rest_framework import serializers +from core.apps.tickets.models import TicketsModel, TicketsImagesModel, TicketsAmenitiesModel, \ + TicketsIncludedServicesModel, TicketsItineraryModel, TicketsItineraryImagesModel, TicketsItineraryDestinationsModel, \ + TicketsHotelMealsModel, TicketsCommentsModel +from core.apps.accounts.models import User + +from core.apps.accounts.serializers.user import UserSerializer +from core.apps.tickets.models.tariff import TariffModel + + +class TicketsTariffSerializer(serializers.ModelSerializer): + class Meta: + model = TariffModel + fields = ['name'] + + +class CommentUserSerializer(UserSerializer): + class Meta: + model = User + fields = ["id", "username"] + + +class TicketsCommentsSerializer(serializers.ModelSerializer): + user = CommentUserSerializer(read_only=True) + + class Meta: + model = TicketsCommentsModel + fields = ["user", "text", "rating"] + + +class TicketsHotelMealsSerializer(serializers.ModelSerializer): + class Meta: + model = TicketsHotelMealsModel + fields = ["image", "name", "desc"] + + +class TicketsItineraryDestinationsSerializer(serializers.ModelSerializer): + class Meta: + model = TicketsItineraryDestinationsModel + fields = ['name'] + + +class TicketsItineraryImagesSerializer(serializers.ModelSerializer): + class Meta: + model = TicketsItineraryImagesModel + fields = ['image'] + + +class TicketsItinerarySerializer(serializers.ModelSerializer): + ticket_itinerary_image = TicketsItineraryImagesSerializer(many=True, read_only=True) + ticket_itinerary_destinations = TicketsItineraryDestinationsSerializer(many=True, read_only=True) + + class Meta: + model = TicketsItineraryModel + fields = ["title", "duration", "ticket_itinerary_image", "ticket_itinerary_destinations"] + + +class TicketsIncludedServicesSerializer(serializers.ModelSerializer): + class Meta: + model = TicketsIncludedServicesModel + fields = ["image", "title", "desc", ] + + +class TicketsAmenitiesSerializer(serializers.ModelSerializer): + class Meta: + model = TicketsAmenitiesModel + fields = ["name"] + + +class TicketsImageSerializer(serializers.ModelSerializer): + class Meta: + model = TicketsImagesModel + fields = ["image"] + + +class BaseTicketsSerializer(serializers.ModelSerializer): + ticket_images = TicketsImageSerializer(many=True, read_only=True) + ticket_amenities = TicketsAmenitiesSerializer(many=True, read_only=True) + ticket_included_services = TicketsIncludedServicesSerializer(many=True, read_only=True) + ticket_itinerary = TicketsItinerarySerializer(many=True, read_only=True) + ticket_hotel_meals = TicketsHotelMealsSerializer(many=True, read_only=True) + ticket_comments = TicketsCommentsSerializer(many=True, read_only=True) + tariff = TicketsTariffSerializer(many=True, read_only=True) + + class Meta: + model = TicketsModel + fields = [ + "id", + "title", + "price", + "departure_date", + "departure", + "destination", + "passenger_count", + "rating", + "hotel_info", + "duration_days", + "hotel_meals", + "ticket_images", + "ticket_amenities", + "ticket_included_services", + "ticket_itinerary", + "ticket_hotel_meals", + "ticket_comments", + "tariff", + + ] + + +class ListTicketsSerializer(BaseTicketsSerializer): + class Meta(BaseTicketsSerializer.Meta): ... + + +class RetrieveTicketsSerializer(BaseTicketsSerializer): + class Meta(BaseTicketsSerializer.Meta): ... + + +class CreateTicketsSerializer(BaseTicketsSerializer): + class Meta(BaseTicketsSerializer.Meta): + fields = [ + "id", + "title", + "price", + "departure_date", + "departure", + "destination", + "passenger_count", + "rating", + "hotel_info", + "duration_days", + "hotel_meals", + ] diff --git a/core/apps/tickets/signals/__init__.py b/core/apps/tickets/signals/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/signals/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/signals/extra_services.py b/core/apps/tickets/signals/extra_services.py new file mode 100644 index 0000000..8844aee --- /dev/null +++ b/core/apps/tickets/signals/extra_services.py @@ -0,0 +1,12 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.tickets.models import ExtraServicesModel, PaidServicesModel + + +@receiver(post_save, sender=ExtraServicesModel) +def ExtraServicesSignal(sender, instance, created, **kwargs): ... + + +@receiver(post_save, sender=PaidServicesModel) +def PaidServicesSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/tickets/signals/hotel.py b/core/apps/tickets/signals/hotel.py new file mode 100644 index 0000000..9f749df --- /dev/null +++ b/core/apps/tickets/signals/hotel.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.tickets.models import HotelModel + + +@receiver(post_save, sender=HotelModel) +def HotelSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/tickets/signals/tariff.py b/core/apps/tickets/signals/tariff.py new file mode 100644 index 0000000..140d30a --- /dev/null +++ b/core/apps/tickets/signals/tariff.py @@ -0,0 +1,8 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.tickets.models import TariffModel + + +@receiver(post_save, sender=TariffModel) +def TariffSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/tickets/signals/tickets.py b/core/apps/tickets/signals/tickets.py new file mode 100644 index 0000000..42ad96c --- /dev/null +++ b/core/apps/tickets/signals/tickets.py @@ -0,0 +1,12 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver + +from core.apps.tickets.models import TicketorderModel, TicketsModel + + +@receiver(post_save, sender=TicketsModel) +def TicketsSignal(sender, instance, created, **kwargs): ... + + +@receiver(post_save, sender=TicketorderModel) +def TicketorderSignal(sender, instance, created, **kwargs): ... diff --git a/core/apps/tickets/tests/__init__.py b/core/apps/tickets/tests/__init__.py new file mode 100644 index 0000000..4f4ee37 --- /dev/null +++ b/core/apps/tickets/tests/__init__.py @@ -0,0 +1,5 @@ +from .test_extra_services import * # noqa +from .test_hotel import * # noqa +from .test_paid_services import * # noqa +from .test_tariff import * # noqa +from .test_tickets import * # noqa diff --git a/core/apps/tickets/tests/test_extra_services.py b/core/apps/tickets/tests/test_extra_services.py new file mode 100644 index 0000000..bc72990 --- /dev/null +++ b/core/apps/tickets/tests/test_extra_services.py @@ -0,0 +1,89 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.tickets.models import ExtraServicesModel, PaidServicesModel + + +class ExtraServicesTest(TestCase): + + def _create_data(self): + return ExtraServicesModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("extra_servoces-list"), + "retrieve": reverse("extra_servoces-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("extra_servoces-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) + + +class PaidServicesTest(TestCase): + + def _create_data(self): + return PaidServicesModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("paid_services-list"), + "retrieve": reverse("paid_services-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("paid_services-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) diff --git a/core/apps/tickets/tests/test_hotel.py b/core/apps/tickets/tests/test_hotel.py new file mode 100644 index 0000000..fa64d0b --- /dev/null +++ b/core/apps/tickets/tests/test_hotel.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.tickets.models import HotelModel + + +class HotelTest(TestCase): + + def _create_data(self): + return HotelModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("hotel-list"), + "retrieve": reverse("hotel-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("hotel-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) diff --git a/core/apps/tickets/tests/test_tariff.py b/core/apps/tickets/tests/test_tariff.py new file mode 100644 index 0000000..651ea1c --- /dev/null +++ b/core/apps/tickets/tests/test_tariff.py @@ -0,0 +1,47 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.tickets.models import TariffModel + + +class TariffTest(TestCase): + + def _create_data(self): + return TariffModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("tariff-list"), + "retrieve": reverse("tariff-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("tariff-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) diff --git a/core/apps/tickets/tests/test_tickets.py b/core/apps/tickets/tests/test_tickets.py new file mode 100644 index 0000000..fa03179 --- /dev/null +++ b/core/apps/tickets/tests/test_tickets.py @@ -0,0 +1,89 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + +from core.apps.tickets.models import TicketorderModel, TicketsModel + + +class TicketsTest(TestCase): + + def _create_data(self): + return TicketsModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("tickets-list"), + "retrieve": reverse("tickets-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("tickets-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) + + +class TicketorderTest(TestCase): + + def _create_data(self): + return TicketorderModel._create_fake() + + def setUp(self): + self.client = APIClient() + self.instance = self._create_data() + self.urls = { + "list": reverse("ticketorder-list"), + "retrieve": reverse("ticketorder-detail", kwargs={"pk": self.instance.pk}), + "retrieve-not-found": reverse("ticketorder-detail", kwargs={"pk": 1000}), + } + + def test_create(self): + self.assertTrue(True) + + def test_update(self): + self.assertTrue(True) + + def test_partial_update(self): + self.assertTrue(True) + + def test_destroy(self): + self.assertTrue(True) + + def test_list(self): + response = self.client.get(self.urls["list"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve(self): + response = self.client.get(self.urls["retrieve"]) + self.assertTrue(response.json()["status"]) + self.assertEqual(response.status_code, 200) + + def test_retrieve_not_found(self): + response = self.client.get(self.urls["retrieve-not-found"]) + self.assertFalse(response.json()["status"]) + self.assertEqual(response.status_code, 404) diff --git a/core/apps/tickets/translation/__init__.py b/core/apps/tickets/translation/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/translation/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/translation/extra_services.py b/core/apps/tickets/translation/extra_services.py new file mode 100644 index 0000000..3fe33fd --- /dev/null +++ b/core/apps/tickets/translation/extra_services.py @@ -0,0 +1,13 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.tickets.models import ExtraServicesModel, PaidServicesModel + + +@register(ExtraServicesModel) +class ExtraServicesTranslation(TranslationOptions): + fields = [] + + +@register(PaidServicesModel) +class PaidServicesTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/tickets/translation/hotel.py b/core/apps/tickets/translation/hotel.py new file mode 100644 index 0000000..c369c87 --- /dev/null +++ b/core/apps/tickets/translation/hotel.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.tickets.models import HotelModel + + +@register(HotelModel) +class HotelTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/tickets/translation/tariff.py b/core/apps/tickets/translation/tariff.py new file mode 100644 index 0000000..49f0743 --- /dev/null +++ b/core/apps/tickets/translation/tariff.py @@ -0,0 +1,8 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.tickets.models import TariffModel + + +@register(TariffModel) +class TariffTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/tickets/translation/tickets.py b/core/apps/tickets/translation/tickets.py new file mode 100644 index 0000000..15a510e --- /dev/null +++ b/core/apps/tickets/translation/tickets.py @@ -0,0 +1,13 @@ +from modeltranslation.translator import TranslationOptions, register + +from core.apps.tickets.models import TicketorderModel, TicketsModel + + +@register(TicketsModel) +class TicketsTranslation(TranslationOptions): + fields = [] + + +@register(TicketorderModel) +class TicketorderTranslation(TranslationOptions): + fields = [] diff --git a/core/apps/tickets/urls.py b/core/apps/tickets/urls.py new file mode 100644 index 0000000..35d80a4 --- /dev/null +++ b/core/apps/tickets/urls.py @@ -0,0 +1,15 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ExtraServicesView, PaidServicesView, TariffView, TicketorderView +from .views.hotel import HotelView +from .views.tickets import TicketsView + +router = DefaultRouter() +router.register("ticketorder", TicketorderView, basename="ticketorder") +router.register("paid_services", PaidServicesView, basename="paid_services") +router.register("extra_services", ExtraServicesView, basename="extra_services") +router.register("tariff", TariffView, basename="tariff") +router.register("tickets", TicketsView, basename="tickets") +router.register("hotels", HotelView, basename="hotel") +urlpatterns = [path("", include(router.urls))] diff --git a/core/apps/tickets/validators/__init__.py b/core/apps/tickets/validators/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/validators/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/validators/extra_services.py b/core/apps/tickets/validators/extra_services.py new file mode 100644 index 0000000..764b859 --- /dev/null +++ b/core/apps/tickets/validators/extra_services.py @@ -0,0 +1,15 @@ +# from django.core.exceptions import ValidationError + + +class ExtraServicesValidator: + def __init__(self): ... + + def __call__(self): + return True + + +class PaidServicesValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/tickets/validators/hotel.py b/core/apps/tickets/validators/hotel.py new file mode 100644 index 0000000..c5b41d7 --- /dev/null +++ b/core/apps/tickets/validators/hotel.py @@ -0,0 +1,8 @@ +# from django.core.exceptions import ValidationError + + +class HotelValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/tickets/validators/tariff.py b/core/apps/tickets/validators/tariff.py new file mode 100644 index 0000000..2834f6c --- /dev/null +++ b/core/apps/tickets/validators/tariff.py @@ -0,0 +1,8 @@ +# from django.core.exceptions import ValidationError + + +class TariffValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/tickets/validators/tickets.py b/core/apps/tickets/validators/tickets.py new file mode 100644 index 0000000..a375ea8 --- /dev/null +++ b/core/apps/tickets/validators/tickets.py @@ -0,0 +1,15 @@ +# from django.core.exceptions import ValidationError + + +class TicketsValidator: + def __init__(self): ... + + def __call__(self): + return True + + +class TicketorderValidator: + def __init__(self): ... + + def __call__(self): + return True diff --git a/core/apps/tickets/views/__init__.py b/core/apps/tickets/views/__init__.py new file mode 100644 index 0000000..a9ccb1e --- /dev/null +++ b/core/apps/tickets/views/__init__.py @@ -0,0 +1,4 @@ +from .extra_services import * # noqa +from .hotel import * # noqa +from .tariff import * # noqa +from .tickets import * # noqa diff --git a/core/apps/tickets/views/extra_services.py b/core/apps/tickets/views/extra_services.py new file mode 100644 index 0000000..312f4f7 --- /dev/null +++ b/core/apps/tickets/views/extra_services.py @@ -0,0 +1,42 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ReadOnlyModelViewSet + +from core.apps.tickets.models import ExtraServicesModel, PaidServicesModel +from core.apps.tickets.serializers.extra_services import ( + CreateExtraServicesSerializer, + CreatePaidServicesSerializer, + ListExtraServicesSerializer, + ListPaidServicesSerializer, + RetrieveExtraServicesSerializer, + RetrievePaidServicesSerializer, +) + + +@extend_schema(tags=["extra_servoces"]) +class ExtraServicesView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = ExtraServicesModel.objects.all() + serializer_class = ListExtraServicesSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListExtraServicesSerializer, + "retrieve": RetrieveExtraServicesSerializer, + "create": CreateExtraServicesSerializer, + } + + +@extend_schema(tags=["paid_services"]) +class PaidServicesView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = PaidServicesModel.objects.all() + serializer_class = ListPaidServicesSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListPaidServicesSerializer, + "retrieve": RetrievePaidServicesSerializer, + "create": CreatePaidServicesSerializer, + } diff --git a/core/apps/tickets/views/hotel.py b/core/apps/tickets/views/hotel.py new file mode 100644 index 0000000..c93d45f --- /dev/null +++ b/core/apps/tickets/views/hotel.py @@ -0,0 +1,21 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ReadOnlyModelViewSet + +from core.apps.tickets.models import HotelModel +from core.apps.tickets.serializers.hotel import CreateHotelSerializer, ListHotelSerializer, RetrieveHotelSerializer + + +@extend_schema(tags=["hotel"]) +class HotelView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = HotelModel.objects.all() + serializer_class = ListHotelSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListHotelSerializer, + "retrieve": RetrieveHotelSerializer, + "create": CreateHotelSerializer, + } diff --git a/core/apps/tickets/views/tariff.py b/core/apps/tickets/views/tariff.py new file mode 100644 index 0000000..2de31b0 --- /dev/null +++ b/core/apps/tickets/views/tariff.py @@ -0,0 +1,21 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ReadOnlyModelViewSet + +from core.apps.tickets.models import TariffModel +from core.apps.tickets.serializers.tariff import CreateTariffSerializer, ListTariffSerializer, RetrieveTariffSerializer + + +@extend_schema(tags=["tariff"]) +class TariffView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = TariffModel.objects.all() + serializer_class = ListTariffSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListTariffSerializer, + "retrieve": RetrieveTariffSerializer, + "create": CreateTariffSerializer, + } diff --git a/core/apps/tickets/views/tickets.py b/core/apps/tickets/views/tickets.py new file mode 100644 index 0000000..dccfdc5 --- /dev/null +++ b/core/apps/tickets/views/tickets.py @@ -0,0 +1,42 @@ +from django_core.mixins import BaseViewSetMixin +from drf_spectacular.utils import extend_schema +from rest_framework.permissions import AllowAny +from rest_framework.viewsets import ReadOnlyModelViewSet + +from core.apps.tickets.models import TicketorderModel, TicketsModel +from core.apps.tickets.serializers.tickets import ( + CreateTicketorderSerializer, + CreateTicketsSerializer, + ListTicketorderSerializer, + ListTicketsSerializer, + RetrieveTicketorderSerializer, + RetrieveTicketsSerializer, +) + + +@extend_schema(tags=["tickets"]) +class TicketsView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = TicketsModel.objects.all() + serializer_class = ListTicketsSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListTicketsSerializer, + "retrieve": RetrieveTicketsSerializer, + "create": CreateTicketsSerializer, + } + + +@extend_schema(tags=["ticketorder"]) +class TicketorderView(BaseViewSetMixin, ReadOnlyModelViewSet): + queryset = TicketorderModel.objects.all() + serializer_class = ListTicketorderSerializer + permission_classes = [AllowAny] + + action_permission_classes = {} + action_serializer_class = { + "list": ListTicketorderSerializer, + "retrieve": RetrieveTicketorderSerializer, + "create": CreateTicketorderSerializer, + } diff --git a/core/services/__init__.py b/core/services/__init__.py new file mode 100644 index 0000000..fdaf3ce --- /dev/null +++ b/core/services/__init__.py @@ -0,0 +1,3 @@ +from .otp import * # noqa +from .sms import * # noqa +from .user import * # noqa diff --git a/core/services/otp.py b/core/services/otp.py new file mode 100644 index 0000000..c54a66c --- /dev/null +++ b/core/services/otp.py @@ -0,0 +1,136 @@ +import requests +from config.env import env + + +class ConsoleService: + + def __init__(self) -> None: ... + + def send_sms(self, phone_number, message): + + print(phone_number, message) + + +class EskizService: + GET = "GET" + POST = "POST" + PATCH = "PATCH" + CONTACT = "contact" + + def __init__(self, api_url=None, email=None, password=None, callback_url=None): + self.api_url = api_url or env("SMS_API_URL") + self.email = email or env("SMS_LOGIN") + self.password = password or env("SMS_PASSWORD") + self.callback_url = callback_url + self.headers = {} + + self.methods = { + "auth_user": "auth/user", + "auth_login": "auth/login", + "auth_refresh": "auth/refresh", + "send_message": "message/sms/send", + } + + def request(self, api_path, data=None, method=None, headers=None): + incoming_data = {"status": "error"} + + try: + response = requests.request( + method, + f"{self.api_url}/{api_path}", + data=data, + headers=headers, + ) + + if api_path == self.methods["auth_refresh"]: + if response.status_code == 200: + incoming_data["status"] = "success" + else: + incoming_data = response.json() + except requests.RequestException as error: + raise Exception(str(error)) + + return incoming_data + + def auth(self): + data = {"email": self.email, "password": self.password} + + return self.request(self.methods["auth_login"], data=data, method=self.POST) + + def refresh_token(self): + token = self.auth()["data"]["token"] + self.headers["Authorization"] = "Bearer " + token + + context = { + "headers": self.headers, + "method": self.PATCH, + "api_path": self.methods["auth_refresh"], + } + + return self.request( + context["api_path"], + method=context["method"], + headers=context["headers"], + ) + + def get_my_user_info(self): + token = self.auth()["data"]["token"] + self.headers["Authorization"] = "Bearer " + token + + data = { + "headers": self.headers, + "method": self.GET, + "api_path": self.methods["auth_user"], + } + + return self.request(data["api_path"], method=data["method"], headers=data["headers"]) + + def add_sms_contact(self, first_name, phone_number, group): + token = self.auth()["data"]["token"] + self.headers["Authorization"] = "Bearer " + token + + data = { + "name": first_name, + "email": self.email, + "group": group, + "mobile_phone": phone_number, + } + + context = { + "headers": self.headers, + "method": self.POST, + "api_path": self.CONTACT, + "data": data, + } + + return self.request( + context["api_path"], + data=context["data"], + method=context["method"], + headers=context["headers"], + ) + + def send_sms(self, phone_number, message): + token = self.auth()["data"]["token"] + self.headers["Authorization"] = "Bearer " + token + + data = { + "from": 4546, + "mobile_phone": phone_number, + "callback_url": self.callback_url, + "message": message, + } + + context = { + "headers": self.headers, + "method": self.POST, + "api_path": self.methods["send_message"], + "data": data, + } + + return self.request( + context["api_path"], + data=context["data"], + method=context["method"], + headers=context["headers"], + ) diff --git a/core/services/sms.py b/core/services/sms.py new file mode 100644 index 0000000..4d9d2b9 --- /dev/null +++ b/core/services/sms.py @@ -0,0 +1,63 @@ +import random +from datetime import datetime, timedelta + +from config.env import env +from core.apps.accounts.tasks.sms import SendConfirm +from django_core import exceptions, models + + +class SmsService: + @staticmethod + def send_confirm(phone): + # TODO: Deploy this change when deploying -> code = random.randint(1000, 9999) # noqa + if env.bool("OTP_PROD", False): + code = "".join(str(random.randint(0, 9)) for _ in range(env.int("OTP_SIZE", 4))) + else: + code = env.int("OTP_DEFAULT", 1111) + + sms_confirm, status = models.SmsConfirm.objects.get_or_create(phone=phone, defaults={"code": code}) + + sms_confirm.sync_limits() + + if sms_confirm.resend_unlock_time is not None: + expired = sms_confirm.interval(sms_confirm.resend_unlock_time) + exception = exceptions.SmsException(f"Resend blocked, try again in {expired}", expired=expired) + raise exception + + sms_confirm.code = code + sms_confirm.try_count = 0 + sms_confirm.resend_count += 1 + sms_confirm.phone = phone + sms_confirm.expired_time = datetime.now() + timedelta(seconds=models.SmsConfirm.SMS_EXPIRY_SECONDS) # noqa + sms_confirm.resend_unlock_time = datetime.now() + timedelta( + seconds=models.SmsConfirm.SMS_EXPIRY_SECONDS + ) # noqa + sms_confirm.save() + + SendConfirm.delay(phone, code) + return True + + @staticmethod + def check_confirm(phone, code): + sms_confirm = models.SmsConfirm.objects.filter(phone=phone).first() + + if sms_confirm is None: + raise exceptions.SmsException("Invalid confirmation code") + + sms_confirm.sync_limits() + + if sms_confirm.is_expired(): + raise exceptions.SmsException("Time for confirmation has expired") + + if sms_confirm.is_block(): + expired = sms_confirm.interval(sms_confirm.unlock_time) + raise exceptions.SmsException(f"Try again in {expired}") + + if str(sms_confirm.code) == str(code): + sms_confirm.delete() + return True + + sms_confirm.try_count += 1 + sms_confirm.save() + + raise exceptions.SmsException("Invalid confirmation code") diff --git a/core/services/user.py b/core/services/user.py new file mode 100644 index 0000000..31e4830 --- /dev/null +++ b/core/services/user.py @@ -0,0 +1,64 @@ +from datetime import datetime + +from core.services import sms +from django.contrib.auth import get_user_model, hashers +from django.utils.translation import gettext as _ +from django_core import exceptions +from rest_framework.exceptions import PermissionDenied +from rest_framework_simplejwt import tokens + + +class UserService(sms.SmsService): + def get_token(self, user): + refresh = tokens.RefreshToken.for_user(user) + + return { + "refresh": str(refresh), + "access": str(refresh.access_token), + } + + def create_user(self, phone, first_name, last_name, password): + get_user_model().objects.update_or_create( + phone=phone, + defaults={ + "phone": phone, + "first_name": first_name, + "last_name": last_name, + "password": hashers.make_password(password), + }, + ) + + def send_confirmation(self, phone) -> bool: + try: + self.send_confirm(phone) + return True + except exceptions.SmsException as e: + raise PermissionDenied(_("Qayta sms yuborish uchun kuting: {}").format(e.kwargs.get("expired"))) + except Exception: + raise PermissionDenied(_("Serverda xatolik yuz berdi")) + + def validate_user(self, user) -> dict: + """ + Create user if user not found + """ + if user.validated_at is None: + user.validated_at = datetime.now() + user.save() + token = self.get_token(user) + return token + + def is_validated(self, user) -> bool: + """ + User is validated check + """ + if user.validated_at is not None: + return True + return False + + def change_password(self, phone, password): + """ + Change password + """ + user = get_user_model().objects.filter(phone=phone).first() + user.set_password(password) + user.save() diff --git a/core/utils/__init__.py b/core/utils/__init__.py new file mode 100644 index 0000000..f10075c --- /dev/null +++ b/core/utils/__init__.py @@ -0,0 +1,3 @@ +from .cache import * # noqa +from .console import * # noqa +from .core import * # noqa diff --git a/core/utils/cache.py b/core/utils/cache.py new file mode 100644 index 0000000..2c00899 --- /dev/null +++ b/core/utils/cache.py @@ -0,0 +1,18 @@ +import hashlib + +from django.core.cache import cache + +from config.env import env + + +class Cache: + def remember(self, func, key: str, timeout=None, *args, **kwargs): + cache_enabled = env.bool("CACHE_ENABLED") + key = hashlib.md5(key.encode("utf-8")).hexdigest() + response = cache.get(key) + if not cache_enabled: + return func(*args, **kwargs) + elif response is None: + response = func(*args, **kwargs) + cache.set(key, response, env.int("CACHE_TIME") if timeout is None else timeout) + return response diff --git a/core/utils/console.py b/core/utils/console.py new file mode 100644 index 0000000..a296d66 --- /dev/null +++ b/core/utils/console.py @@ -0,0 +1,79 @@ +import logging +import os +from typing import Any, Union + +from django.conf import settings +from django.core import management + + +class Console(management.BaseCommand): + """ + Console logging class + """ + + def get_stdout(self): + base_command = management.BaseCommand() + return base_command.stdout + + def get_style(self): + base_command = management.BaseCommand() + return base_command.style + + def success(self, message): + logging.debug(message) + self.get_stdout().write(self.get_style().SUCCESS(message)) + + def error(self, message): + logging.error(message) + self.get_stdout().write(self.get_style().ERROR(message)) + + def log(self, message): + self.get_stdout().write( + self.get_style().ERROR( + "\n{line}\n{message}\n{line}\n".format( + message=message, line="=" * len(message) + ) + ) + ) + + +class BaseMake(management.BaseCommand): + path: str + + def __init__(self, *args, **options): + super().__init__(*args, **options) + self.console = Console() + + def add_arguments(self, parser): + parser.add_argument("name") + + def handle(self, *args, **options): + name = options.get("name") + if name is None: + name = "" + + stub = open(os.path.join(settings.BASE_DIR, f"resources/stub/{self.path}.stub")) + data: Union[Any] = stub.read() + stub.close() + + stub = data.replace("{{name}}", name or "") + + + core_http_path = os.path.join(settings.BASE_DIR, "core/http") + if os.path.exists( + os.path.join(core_http_path, f"{self.path}/{name.lower()}.py") + ): # noqa + self.console.error(f"{self.name} already exists") + return + + if not os.path.exists(os.path.join(core_http_path, self.path)): + os.makedirs(os.path.join(core_http_path, self.path)) + + file = open( + os.path.join(core_http_path, f"{self.path}/{name.lower()}.py"), + "w+", + ) + file.write(stub) # type: ignore + file.close() + + self.console.success(f"{self.name} created") diff --git a/core/utils/core.py b/core/utils/core.py new file mode 100644 index 0000000..04fdbed --- /dev/null +++ b/core/utils/core.py @@ -0,0 +1,6 @@ +class Helper: + """ + Helper class to handle index + """ + + pass diff --git a/core/utils/storage.py b/core/utils/storage.py new file mode 100644 index 0000000..50e6d33 --- /dev/null +++ b/core/utils/storage.py @@ -0,0 +1,33 @@ +from typing import Optional, Union + +from config.env import env + + +class Storage: + + storages = ["AWS", "MINIO", "FILE", "STATIC"] + + def __init__(self, storage: Union[str], storage_type: Union[str] = "default") -> None: + self.storage = storage + self.sorage_type = storage_type + if storage not in self.storages: + raise ValueError(f"Invalid storage type: {storage}") + + def get_backend(self) -> Optional[str]: + match self.storage: + case "AWS" | "MINIO": + return "storages.backends.s3boto3.S3Boto3Storage" + case "FILE": + return "django.core.files.storage.FileSystemStorage" + case "STATIC": + return "django.contrib.staticfiles.storage.StaticFilesStorage" + + def get_options(self) -> Optional[str]: + match self.storage: + case "AWS" | "MINIO": + if self.sorage_type == "default": + return {"bucket_name": env.str("STORAGE_BUCKET_MEDIA")} + elif self.sorage_type == "static": + return {"bucket_name": env.str("STORAGE_BUCKET_STATIC")} + case _: + return {} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..2625bf3 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,59 @@ +networks: + simple_travel: + driver: bridge + +volumes: + pg_data: null + pycache: null + media: null + static: null + +services: + nginx: + networks: + - simple_travel + ports: + - ${PORT:-8001}:80 + volumes: + - ./resources/layout/nginx.conf:/etc/nginx/nginx.conf + - media:/usr/share/nginx/html/resources/media/:ro + - static:/usr/share/nginx/html/resources/staticfiles/:ro + build: + context: . + dockerfile: ./docker/Dockerfile.nginx + depends_on: + - web + web: + networks: + - simple_travel + build: + context: . + dockerfile: ./docker/Dockerfile.web + restart: always + environment: + - PYTHONPYCACHEPREFIX=/var/cache/pycache + - SCRIPT=${SCRIPT:-entrypoint.sh} + volumes: + - media:/code/resources/media/ + - static:/code/resources/staticfiles/ + - pycache:/var/cache/pycache + depends_on: + - db + - redis + db: + networks: + - simple_travel + image: postgres:16 + restart: always + environment: + POSTGRES_DB: django + POSTGRES_USER: postgres + POSTGRES_PASSWORD: '2309' + volumes: + - pg_data:/var/lib/postgresql/data + redis: + networks: + - simple_travel + restart: always + + image: redis diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000..8c18d6f --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,44 @@ +networks: + simple_travel: + driver: bridge + +volumes: + pg_data: null + pycache: null + +services: + web: + networks: + - simple_travel + build: + context: . + dockerfile: ./docker/Dockerfile.web + restart: always + container_name: test_web + environment: + - PYTHONPYCACHEPREFIX=/var/cache/pycache + - SCRIPT=${SCRIPT:-entrypoint.sh} + volumes: + - pycache:/var/cache/pycache + depends_on: + - db + - redis + db: + networks: + - simple_travel + image: postgres:16 + restart: always + container_name: test_db + environment: + POSTGRES_DB: django + POSTGRES_USER: postgres + POSTGRES_PASSWORD: '2309' + volumes: + - pg_data:/var/lib/postgresql/data + redis: + container_name: test_redis + networks: + - simple_travel + restart: always + + image: redis diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..23d2842 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,56 @@ +networks: + simple_travel: + driver: bridge + +volumes: + pg_data: null + rabbitmq: null + pycache: null + +services: + nginx: + networks: + - simple_travel + ports: + - ${PORT:-8001}:80 + volumes: + - ./resources/layout/nginx.conf:/etc/nginx/nginx.conf + - ./resources/:/usr/share/nginx/html/resources/ + build: + context: . + dockerfile: ./docker/Dockerfile.nginx + depends_on: + - web + web: + networks: + - simple_travel + build: + context: . + dockerfile: ./docker/Dockerfile.web + restart: always + environment: + - PYTHONPYCACHEPREFIX=/var/cache/pycache + - SCRIPT=${SCRIPT:-entrypoint.sh} + volumes: + - .:/code + - pycache:/var/cache/pycache + depends_on: + - db + - redis + db: + networks: + - simple_travel + image: postgres:16 + restart: always + environment: + POSTGRES_DB: django + POSTGRES_USER: postgres + POSTGRES_PASSWORD: '2309' + volumes: + - pg_data:/var/lib/postgresql/data + redis: + networks: + - simple_travel + restart: always + + image: redis diff --git a/docker/Dockerfile.nginx b/docker/Dockerfile.nginx new file mode 100644 index 0000000..22a1599 --- /dev/null +++ b/docker/Dockerfile.nginx @@ -0,0 +1,3 @@ +FROM nginx:alpine + +COPY ./resources/layout/nginx.conf /etc/nginx/nginx.conf diff --git a/docker/Dockerfile.web b/docker/Dockerfile.web new file mode 100644 index 0000000..44a3db8 --- /dev/null +++ b/docker/Dockerfile.web @@ -0,0 +1,22 @@ +FROM jscorptech/django:v0.5 + +ARG SCRIPT="entrypoint.sh" +ENV SCRIPT=$SCRIPT + + +WORKDIR /code + +COPY ./ /code + +RUN apk add --no-cache curl || apk add --no-cache openbsd-netcat + + +RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt + +COPY ./resources/scripts/$SCRIPT /code/$SCRIPT + +RUN chmod +x /code/resources/scripts/$SCRIPT + +CMD sh /code/resources/scripts/$SCRIPT + + diff --git a/jst.json b/jst.json new file mode 100644 index 0000000..e87c706 --- /dev/null +++ b/jst.json @@ -0,0 +1,8 @@ +{ + "dirs": { + "apps": "./core/apps/", + "locale": "./resources/locale/" + }, + "stubs": {}, + "apps": "core.apps." +} \ No newline at end of file diff --git a/k8s/config.yaml b/k8s/config.yaml new file mode 100644 index 0000000..76e98b9 --- /dev/null +++ b/k8s/config.yaml @@ -0,0 +1,60 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: nginx-config +data: + nginx.conf: | + worker_processes 1; + + events { + worker_connections 1024; + } + + http { + include mime.types; + default_type application/octet-stream; + + # Logging settings (optional) + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 1024M; + + # Server block for handling requests + server { + listen 80; + + server_name _; + + location / { + proxy_pass http://django:8000; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Host $http_host; + } + location /ws/ { + proxy_pass http://django:8000; # Uvicorn serveri ishga tushadigan port + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $http_host; + } + + location /resources/static/ { + alias /usr/share/nginx/html/resources/staticfiles/; + } + + location /resources/media/ { + alias /usr/share/nginx/html/resources/media/; + } + } + } + diff --git a/k8s/db-deployment.yaml b/k8s/db-deployment.yaml new file mode 100644 index 0000000..4600325 --- /dev/null +++ b/k8s/db-deployment.yaml @@ -0,0 +1,33 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: db +spec: + replicas: 1 + selector: + matchLabels: + app: db + template: + metadata: + labels: + app: db + spec: + containers: + - name: db + image: postgres:16 + env: + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_PASSWORD + value: "2309" + - name: POSTGRES_DB + value: django + ports: + - containerPort: 5432 + volumeMounts: + - name: db + mountPath: /var/lib/postgresql/data + volumes: + - name: db + persistentVolumeClaim: + claimName: db diff --git a/k8s/db-service.yaml b/k8s/db-service.yaml new file mode 100644 index 0000000..15131d2 --- /dev/null +++ b/k8s/db-service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: db +spec: + type: ClusterIP + selector: + app: db + ports: + - port: 5432 + targetPort: 5432 diff --git a/k8s/django-deployment.yaml b/k8s/django-deployment.yaml new file mode 100644 index 0000000..c6618eb --- /dev/null +++ b/k8s/django-deployment.yaml @@ -0,0 +1,33 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: django +spec: + replicas: 1 + selector: + matchLabels: + app: django + template: + metadata: + labels: + app: django + spec: + containers: + - name: django + image: "2.0" + ports: + - containerPort: 8000 + volumeMounts: + - name: assets + mountPath: /code/resources/staticfiles + - name: media + mountPath: /code/resources/media + volumes: + - name: assets + persistentVolumeClaim: + claimName: assets + - name: media + persistentVolumeClaim: + claimName: media + + diff --git a/k8s/django-service.yaml b/k8s/django-service.yaml new file mode 100644 index 0000000..9652d67 --- /dev/null +++ b/k8s/django-service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: django +spec: + type: ClusterIP + selector: + app: django + ports: + - port: 8000 + targetPort: 8000 diff --git a/k8s/nginx-deployment.yaml b/k8s/nginx-deployment.yaml new file mode 100644 index 0000000..4203284 --- /dev/null +++ b/k8s/nginx-deployment.yaml @@ -0,0 +1,39 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx +spec: + replicas: 2 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:latest + ports: + - containerPort: 80 + volumeMounts: + - name: nginx-config-volume + mountPath: /etc/nginx/nginx.conf + subPath: nginx.conf + - name: assets + mountPath: /usr/share/nginx/html/resources/staticfiles + readOnly: true + - name: media + mountPath: /usr/share/nginx/html/resources/media + readOnly: true + volumes: + - name: assets + persistentVolumeClaim: + claimName: assets + - name: media + persistentVolumeClaim: + claimName: media + - name: nginx-config-volume + configMap: + name: nginx-config diff --git a/k8s/nginx-service.yaml b/k8s/nginx-service.yaml new file mode 100644 index 0000000..479cadf --- /dev/null +++ b/k8s/nginx-service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: nginx +spec: + type: NodePort + selector: + app: nginx + ports: + - port: 80 + targetPort: 80 + nodePort: 30000 diff --git a/k8s/volume.yaml b/k8s/volume.yaml new file mode 100644 index 0000000..675b7bb --- /dev/null +++ b/k8s/volume.yaml @@ -0,0 +1,35 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: assets +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: media +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: db +spec: + accessModes: + - ReadWriteOnce + storageClassName: local-path + resources: + requests: + storage: 10Gi diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..67c98de --- /dev/null +++ b/manage.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + +from config.env import env + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE")) + 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/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3947207 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,27 @@ +[tool.black] +line-length = 120 + +[tool.isort] +profile = "black" +line_length = 120 + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings.local" +python_files = "tests.py test_*.py *_tests.py" +filterwarnings = [ + "ignore::DeprecationWarning", + "ignore::PendingDeprecationWarning", + "ignore::ResourceWarning", + "ignore::Warning" # This line will ignore all warnings +] + + +[tool.flake8] +max-line-length = 120 +ignore = ["E701", "E704", "W503"] + +[tool.pyright] +typeCheckingMode = "basic" +reportMissingImports = false +reportMissingTypeStubs = false +pythonVersion = "3.12" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d0e73e8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,46 @@ +backports.tarfile==1.2.0 +celery==5.4.0 +django-cors-headers==4.6.0 +django-environ==0.11.2 +django-extensions==3.2.3 +django-filter==24.3 +django-redis==5.4.0 +django-unfold==0.65.0 +djangorestframework-simplejwt==5.3.1 +drf-spectacular==0.28.0 +importlib-metadata==8.5.0 +importlib-resources==6.4.5 +inflect==7.3.1 +jaraco.collections==5.1.0 +packaging==24.2 +pip-chill==1.0.3 +platformdirs==4.3.6 +psycopg2-binary==2.9.10 +tomli==2.2.1 +uvicorn==0.32.1 +jst-django-core~=1.2.2 +rich +pydantic +bcrypt +pytest-django + + + +django-modeltranslation~=0.19.11 +django-ckeditor-5==0.2.15 +channels==4.2.0 +django-rosetta==0.10.1 +django-cacheops~=7.1 +django-silk + +# !NOTE: on-server +# gunicorn + + +django-storages +boto3 + + +# !NOTE: on-websocket +# websockets +# channels-redis diff --git a/resources/.gitignore b/resources/.gitignore new file mode 100644 index 0000000..7627088 --- /dev/null +++ b/resources/.gitignore @@ -0,0 +1 @@ +staticfiles/ \ No newline at end of file diff --git a/resources/layout/.flake8 b/resources/layout/.flake8 new file mode 100644 index 0000000..4088dec --- /dev/null +++ b/resources/layout/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 120 +ignore = E701, E704, W503 diff --git a/resources/layout/Dockerfile.alpine b/resources/layout/Dockerfile.alpine new file mode 100644 index 0000000..f57c7bc --- /dev/null +++ b/resources/layout/Dockerfile.alpine @@ -0,0 +1,13 @@ +FROM python:3.13-alpine + +ENV PYTHONPYCACHEPREFIX=/dev/null + +RUN apk update && apk add git gettext + +WORKDIR /code + +COPY requirements.txt /code/requirements.txt + +RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt + +CMD ["sh", "./entrypoint.sh"] diff --git a/resources/layout/Dockerfile.nginx b/resources/layout/Dockerfile.nginx new file mode 100644 index 0000000..22a1599 --- /dev/null +++ b/resources/layout/Dockerfile.nginx @@ -0,0 +1,3 @@ +FROM nginx:alpine + +COPY ./resources/layout/nginx.conf /etc/nginx/nginx.conf diff --git a/resources/layout/mypy.ini b/resources/layout/mypy.ini new file mode 100644 index 0000000..6c53a26 --- /dev/null +++ b/resources/layout/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +check_untyped_defs = True + +[mypy-requests.*] +ignore_missing_imports = True \ No newline at end of file diff --git a/resources/layout/nginx.conf b/resources/layout/nginx.conf new file mode 100644 index 0000000..e86631b --- /dev/null +++ b/resources/layout/nginx.conf @@ -0,0 +1,54 @@ +# Main configuration block +worker_processes 1; + +events { + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + + # Logging settings (optional) + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + access_log /var/log/nginx/access.log main; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 1024M; + + # Server block for handling requests + server { + listen 80; + + server_name _; + + location / { + proxy_pass http://web:8000; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Host $http_host; + } + location /ws/ { + proxy_pass http://web:8000; # Uvicorn serveri ishga tushadigan port + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $http_host; + } + + location /resources/static/ { + alias /usr/share/nginx/html/resources/staticfiles/; + } + + location /resources/media/ { + alias /usr/share/nginx/html/resources/media/; + } + } +} diff --git a/resources/locale/.gitkeep b/resources/locale/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/resources/locale/en/LC_MESSAGES/django.po b/resources/locale/en/LC_MESSAGES/django.po new file mode 100644 index 0000000..acc28e8 --- /dev/null +++ b/resources/locale/en/LC_MESSAGES/django.po @@ -0,0 +1,49 @@ +# 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: 2024-02-15 16:40+0500\n" +"PO-Revision-Date: 2024-02-09 15:09+0500\n" +"Last-Translator: \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" +"X-Translated-Using: django-rosetta 0.10.0\n" + +#: config/settings/common.py:148 +msgid "Russia" +msgstr "" + +#: config/settings/common.py:149 +msgid "English" +msgstr "" + +#: config/settings/common.py:150 +msgid "Uzbek" +msgstr "" + +#: core/http/admin/index.py:20 +msgid "Custom Field" +msgstr "" + +#: core/http/tasks/index.py:13 +#, python-format +msgid "Sizning Tasdiqlash ko'dingiz: %(code)s" +msgstr "" + +#: resources/templates/user/home.html:18 +msgid "Django" +msgstr "" + +#: resources/templates/user/home.html:19 +msgid "Assalomu aleykum" +msgstr "" diff --git a/resources/locale/ru/LC_MESSAGES/django.po b/resources/locale/ru/LC_MESSAGES/django.po new file mode 100644 index 0000000..a039baa --- /dev/null +++ b/resources/locale/ru/LC_MESSAGES/django.po @@ -0,0 +1,51 @@ +# 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: 2024-02-15 16:40+0500\n" +"PO-Revision-Date: 2024-02-09 15:09+0500\n" +"Last-Translator: \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " +"n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || " +"(n%100>=11 && n%100<=14)? 2 : 3);\n" +"X-Translated-Using: django-rosetta 0.10.0\n" + +#: config/settings/common.py:148 +msgid "Russia" +msgstr "" + +#: config/settings/common.py:149 +msgid "English" +msgstr "" + +#: config/settings/common.py:150 +msgid "Uzbek" +msgstr "" + +#: core/http/admin/index.py:20 +msgid "Custom Field" +msgstr "" + +#: core/http/tasks/index.py:13 +#, python-format +msgid "Sizning Tasdiqlash ko'dingiz: %(code)s" +msgstr "" + +#: resources/templates/user/home.html:18 +msgid "Django" +msgstr "" + +#: resources/templates/user/home.html:19 +msgid "Assalomu aleykum" +msgstr "" diff --git a/resources/locale/uz/LC_MESSAGES/django.po b/resources/locale/uz/LC_MESSAGES/django.po new file mode 100644 index 0000000..c3a7ef5 --- /dev/null +++ b/resources/locale/uz/LC_MESSAGES/django.po @@ -0,0 +1,55 @@ +# 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: 2024-02-15 16:40+0500\n" +"PO-Revision-Date: 2024-02-10 22:46+0500\n" +"Last-Translator: \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=1; plural=0;\n" +"X-Translated-Using: django-rosetta 0.10.0\n" + +#: config/settings/common.py:148 +msgid "Russia" +msgstr "" + +#: config/settings/common.py:149 +msgid "English" +msgstr "" + +#: config/settings/common.py:150 +msgid "Uzbek" +msgstr "" + +#: core/http/admin/index.py:20 +msgid "Custom Field" +msgstr "" + +#: core/http/tasks/index.py:13 +#, python-format +msgid "Sizning Tasdiqlash ko'dingiz: %(code)s" +msgstr "" + +#: resources/templates/user/home.html:18 +msgid "Django" +msgstr "" + +#: resources/templates/user/home.html:19 +msgid "Assalomu aleykum" +msgstr "" + +#~ msgid "Home" +#~ msgstr "Bosh sahifa" + +#~ msgid "Homes" +#~ msgstr "Bosh sahifa" diff --git a/resources/logs/.gitignore b/resources/logs/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/resources/logs/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/resources/media/.gitignore b/resources/media/.gitignore new file mode 100644 index 0000000..a3a0c8b --- /dev/null +++ b/resources/media/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/resources/scripts/backup.sh b/resources/scripts/backup.sh new file mode 100644 index 0000000..5bac180 --- /dev/null +++ b/resources/scripts/backup.sh @@ -0,0 +1,5 @@ +file=/tmp/db-$(/usr/bin/date +\%Y-%m-%d-%H:%M:%S).sql +container=postgres +/usr/bin/docker container exec $container pg_dump -U postgres django > $file +mc cp $file b2/buket-name +rm $file \ No newline at end of file diff --git a/resources/scripts/entrypoint-server.sh b/resources/scripts/entrypoint-server.sh new file mode 100644 index 0000000..319de3e --- /dev/null +++ b/resources/scripts/entrypoint-server.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +while ! nc -z db 5432; do + sleep 2 + echo "Waiting postgress...." +done + +python3 manage.py collectstatic --noinput +python3 manage.py migrate --noinput + +gunicorn config.wsgi:application -b 0.0.0.0:8000 --workers $(($(nproc) * 2 + 1)) + + + +exit $? + + diff --git a/resources/scripts/entrypoint.sh b/resources/scripts/entrypoint.sh new file mode 100644 index 0000000..8c60554 --- /dev/null +++ b/resources/scripts/entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +while ! nc -z db 5432; do + sleep 2 + echo "Waiting postgress...." +done + + +python3 manage.py collectstatic --noinput +python3 manage.py migrate --noinput + +uvicorn config.asgi:application --host 0.0.0.0 --port 8000 --reload --reload-dir core --reload-dir config + + + +exit $? + + diff --git a/resources/static/css/app.css b/resources/static/css/app.css new file mode 100644 index 0000000..e69de29 diff --git a/resources/static/css/error.css b/resources/static/css/error.css new file mode 100644 index 0000000..11201a8 --- /dev/null +++ b/resources/static/css/error.css @@ -0,0 +1,109 @@ +* { + -webkit-box-sizing: border-box; + box-sizing: border-box; +} + +body { + padding: 0; + margin: 0; +} + +#notfound { + position: relative; + height: 100vh; +} + +#notfound .notfound { + position: absolute; + left: 50%; + top: 50%; + -webkit-transform: translate(-50%, -50%); + -ms-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +.notfound { + max-width: 710px; + width: 100%; + padding-left: 190px; + line-height: 1.4; +} + +.notfound .notfound-404 { + position: absolute; + left: 0; + top: 0; + width: 150px; + height: 150px; +} + +.notfound .notfound-404 h1 { + font-family: 'Passion One', cursive; + color: #00b5c3; + font-size: 150px; + letter-spacing: 15.5px; + margin: 0px; + font-weight: 900; + position: absolute; + left: 50%; + top: 50%; + -webkit-transform: translate(-50%, -50%); + -ms-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +.notfound h2 { + font-family: 'Raleway', sans-serif; + color: #292929; + font-size: 28px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 2.5px; + margin-top: 0; +} + +.notfound p { + font-family: 'Raleway', sans-serif; + font-size: 14px; + font-weight: 400; + margin-top: 0; + margin-bottom: 15px; + color: #333; +} + +.notfound a { + font-family: 'Raleway', sans-serif; + font-size: 14px; + text-decoration: none; + text-transform: uppercase; + background: #fff; + display: inline-block; + padding: 15px 30px; + border-radius: 40px; + color: #292929; + font-weight: 700; + -webkit-box-shadow: 0px 4px 15px -5px rgba(0, 0, 0, 0.3); + box-shadow: 0px 4px 15px -5px rgba(0, 0, 0, 0.3); + -webkit-transition: 0.2s all; + transition: 0.2s all; +} + +.notfound a:hover { + color: #fff; + background-color: #00b5c3; +} + +@media only screen and (max-width: 480px) { + .notfound { + text-align: center; + } + .notfound .notfound-404 { + position: relative; + width: 100%; + margin-bottom: 15px; + } + .notfound { + padding-left: 15px; + padding-right: 15px; + } +} diff --git a/resources/static/css/input.css b/resources/static/css/input.css new file mode 100644 index 0000000..04b35af --- /dev/null +++ b/resources/static/css/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/resources/static/css/jazzmin.css b/resources/static/css/jazzmin.css new file mode 100644 index 0000000..8b8be66 --- /dev/null +++ b/resources/static/css/jazzmin.css @@ -0,0 +1,5 @@ +.login-logo img { + border-radius: 100%; + width: 100px; + height: 100px; +} \ No newline at end of file diff --git a/resources/static/css/output.css b/resources/static/css/output.css new file mode 100644 index 0000000..99bc6dc --- /dev/null +++ b/resources/static/css/output.css @@ -0,0 +1,772 @@ +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; + --tw-contain-size: ; + --tw-contain-layout: ; + --tw-contain-paint: ; + --tw-contain-style: ; +} + +/* +! tailwindcss v3.4.14 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +7. Disable tap highlights on iOS +*/ + +html, +:host { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ + -webkit-tap-highlight-color: transparent; + /* 7 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font-family by default. +2. Use the user's configured `mono` font-feature-settings by default. +3. Use the user's configured `mono` font-variation-settings by default. +4. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-feature-settings: normal; + /* 2 */ + font-variation-settings: normal; + /* 3 */ + font-size: 1em; + /* 4 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297) +2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-feature-settings: inherit; + /* 1 */ + font-variation-settings: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + letter-spacing: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +input:where([type='button']), +input:where([type='reset']), +input:where([type='submit']) { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Reset default styling for dialogs. +*/ + +dialog { + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden]:where(:not([hidden="until-found"])) { + display: none; +} + +.static { + position: static; +} + +.m-2 { + margin: 0.5rem; +} + +.mx-4 { + margin-left: 1rem; + margin-right: 1rem; +} + +.mb-3 { + margin-bottom: 0.75rem; +} + +.mb-5 { + margin-bottom: 1.25rem; +} + +.ml-1 { + margin-left: 0.25rem; +} + +.mt-3 { + margin-top: 0.75rem; +} + +.mt-4 { + margin-top: 1rem; +} + +.mt-5 { + margin-top: 1.25rem; +} + +.block { + display: block; +} + +.inline-block { + display: inline-block; +} + +.flex { + display: flex; +} + +.grid { + display: grid; +} + +.h-\[100vh\] { + height: 100vh; +} + +.w-\[100vw\] { + width: 100vw; +} + +.w-full { + width: 100%; +} + +.cursor-pointer { + cursor: pointer; +} + +.grid-cols-1 { + grid-template-columns: repeat(1, minmax(0, 1fr)); +} + +.items-center { + align-items: center; +} + +.justify-end { + justify-content: flex-end; +} + +.justify-center { + justify-content: center; +} + +.gap-8 { + gap: 2rem; +} + +.gap-x-3 { + -moz-column-gap: 0.75rem; + column-gap: 0.75rem; +} + +.whitespace-nowrap { + white-space: nowrap; +} + +.rounded { + border-radius: 0.25rem; +} + +.border { + border-width: 1px; +} + +.border-black { + --tw-border-opacity: 1; + border-color: rgb(0 0 0 / var(--tw-border-opacity)); +} + +.bg-blue-100 { + --tw-bg-opacity: 1; + background-color: rgb(219 234 254 / var(--tw-bg-opacity)); +} + +.bg-gray-200 { + --tw-bg-opacity: 1; + background-color: rgb(229 231 235 / var(--tw-bg-opacity)); +} + +.p-4 { + padding: 1rem; +} + +.px-2 { + padding-left: 0.5rem; + padding-right: 0.5rem; +} + +.px-\[40px\] { + padding-left: 40px; + padding-right: 40px; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.text-center { + text-align: center; +} + +.text-\[20px\] { + font-size: 20px; +} + +.text-\[25px\] { + font-size: 25px; +} + +.text-\[30px\] { + font-size: 30px; +} + +.text-\[40px\] { + font-size: 40px; +} + +.font-\[400\] { + font-weight: 400; +} + +.font-\[600\] { + font-weight: 600; +} + +.font-bold { + font-weight: 700; +} + +.font-semibold { + font-weight: 600; +} + +.uppercase { + text-transform: uppercase; +} + +.leading-normal { + line-height: 1.5; +} + +.text-black { + --tw-text-opacity: 1; + color: rgb(0 0 0 / var(--tw-text-opacity)); +} + +.text-blue-500 { + --tw-text-opacity: 1; + color: rgb(59 130 246 / var(--tw-text-opacity)); +} + +.text-red-500 { + --tw-text-opacity: 1; + color: rgb(239 68 68 / var(--tw-text-opacity)); +} + +@media (min-width: 640px) { + .sm\:grid-cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 768px) { + .md\:grid-cols-3 { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .lg\:grid-cols-4 { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} diff --git a/resources/static/images/default_avatar.jpg b/resources/static/images/default_avatar.jpg new file mode 100644 index 0000000..a0aed81 Binary files /dev/null and b/resources/static/images/default_avatar.jpg differ diff --git a/resources/static/images/logo.png b/resources/static/images/logo.png new file mode 100644 index 0000000..57ac67e Binary files /dev/null and b/resources/static/images/logo.png differ diff --git a/resources/static/js/alpine.js b/resources/static/js/alpine.js new file mode 100644 index 0000000..88b64ab --- /dev/null +++ b/resources/static/js/alpine.js @@ -0,0 +1,9 @@ +import Alpine from 'alpinejs' +import counter from "./counter"; + +window.Alpine = Alpine + +Alpine.data("vars", counter) + +Alpine.start() + diff --git a/resources/static/js/app.js b/resources/static/js/app.js new file mode 100644 index 0000000..f936998 --- /dev/null +++ b/resources/static/js/app.js @@ -0,0 +1 @@ +import "./alpine" diff --git a/resources/static/js/counter.js b/resources/static/js/counter.js new file mode 100644 index 0000000..f6a1d2c --- /dev/null +++ b/resources/static/js/counter.js @@ -0,0 +1,3 @@ +export default () => ({ + count: 2309 +}) \ No newline at end of file diff --git a/resources/static/js/customer.js b/resources/static/js/customer.js new file mode 100644 index 0000000..06bacfb --- /dev/null +++ b/resources/static/js/customer.js @@ -0,0 +1,49 @@ + +class Changer { + constructor() { + this.inputs = []; + + this.legal = [ + "bank_mfo", + "bank_name", + "bank_account", + "name", + "director_name", + "responsible_person", + "inn" + ]; + + this.physical = [ + "passport_series", + "jshir", + "first_name", + "last_name", + ] + this.legal.concat(this.physical).forEach((item) => { + this.inputs[item] = document.querySelector(`#id_${item}`).closest(".form-row"); + }) + } + toggleDisplay(showItems, hideItems) { + showItems.forEach(item => { + this.inputs[item].style.display = "block"; + }); + hideItems.forEach(item => { + this.inputs[item].style.display = "none"; + }); + }; + + change(e) { + if (e == "PHYSICAL") { + this.toggleDisplay(this.physical, this.legal); + } else if (e == "LEGAL") { + this.toggleDisplay(this.legal, this.physical); + } + } +} + +document.addEventListener("DOMContentLoaded", () => { + let obj = new Changer(); + let select = document.querySelector("#id_person_type"); + select.addEventListener("change", (e) => obj.change(e.target.value)); + obj.change(select.value); +}) \ No newline at end of file diff --git a/resources/static/js/vite-refresh.js b/resources/static/js/vite-refresh.js new file mode 100644 index 0000000..019ed44 --- /dev/null +++ b/resources/static/js/vite-refresh.js @@ -0,0 +1,9 @@ +import RefreshRuntime from 'http://localhost:5173/@react-refresh' + +if (RefreshRuntime) { + RefreshRuntime.injectIntoGlobalHook(window) + window.$RefreshReg$ = () => { + } + window.$RefreshSig$ = () => (type) => type + window.__vite_plugin_react_preamble_installed__ = true +} \ No newline at end of file diff --git a/resources/static/vite/assets/appCss-w40geAFS.js b/resources/static/vite/assets/appCss-w40geAFS.js new file mode 100644 index 0000000..e69de29 diff --git a/resources/static/vite/assets/appJs-YH6iAcjX.js b/resources/static/vite/assets/appJs-YH6iAcjX.js new file mode 100644 index 0000000..7274c06 --- /dev/null +++ b/resources/static/vite/assets/appJs-YH6iAcjX.js @@ -0,0 +1,6 @@ +var Ce=!1,Me=!1,L=[],Te=-1;function zn(e){Hn(e)}function Hn(e){L.includes(e)||L.push(e),qn()}function Mt(e){let t=L.indexOf(e);t!==-1&&t>Te&&L.splice(t,1)}function qn(){!Me&&!Ce&&(Ce=!0,queueMicrotask(Wn))}function Wn(){Ce=!1,Me=!0;for(let e=0;ee.effect(t,{scheduler:n=>{Ie?zn(n):n()}}),Tt=e.raw}function _t(e){K=e}function Vn(e){let t=()=>{};return[r=>{let i=K(r);return e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(o=>o())}),e._x_effects.add(i),t=()=>{i!==void 0&&(e._x_effects.delete(i),q(i))},i},()=>{t()}]}function It(e,t){let n=!0,r,i=K(()=>{let o=e();JSON.stringify(o),n?r=o:queueMicrotask(()=>{t(o,r),r=o}),n=!1});return()=>q(i)}function X(e,t,n={}){e.dispatchEvent(new CustomEvent(t,{detail:n,bubbles:!0,composed:!0,cancelable:!0}))}function I(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>I(i,t));return}let n=!1;if(t(e,()=>n=!0),n)return;let r=e.firstElementChild;for(;r;)I(r,t),r=r.nextElementSibling}function O(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var ht=!1;function Yn(){ht&&O("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),ht=!0,document.body||O("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `