diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..c0ce342 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,27 @@ +# http://editorconfig.org + +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{py,rst,ini}] +indent_style = space +indent_size = 4 + +[*.{html,css,scss,json,yml,xml}] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab + +[default.conf] +indent_style = space +indent_size = 2 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..45329fd --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Enter your OPEN AI Token here +OPENAI_TOKEN= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..176a458 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7544bbc --- /dev/null +++ b/.gitignore @@ -0,0 +1,278 @@ +### Python template +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Django stuff: +staticfiles/ + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + + + +# Environments +.venv +venv/ +ENV/ + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + + +### Node template +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Typescript v1 declaration files +typings/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + + +### Linux template +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + + +### VisualStudioCode template +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +*.code-workspace + +# Local History for devcontainer +.devcontainer/bash_history + + + + +### Windows template +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + + +### macOS template +# General +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + + +### SublimeText template +# Cache files for Sublime Text +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache + +# Workspace files are user-specific +*.sublime-workspace + +# Project files should be checked into the repository, unless a significant +# proportion of contributors will probably not be using Sublime Text +# *.sublime-project + +# SFTP configuration file +sftp-config.json + +# Package control specific files +Package Control.last-run +Package Control.ca-list +Package Control.ca-bundle +Package Control.system-ca-bundle +Package Control.cache/ +Package Control.ca-certs/ +Package Control.merged-ca-bundle +Package Control.user-ca-bundle +oscrypto-ca-bundle.crt +bh_unicode_properties.cache + +# Sublime-github package stores a github token in this file +# https://packagecontrol.io/packages/sublime-github +GitHub.sublime-settings + + +### Vim template +# Swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-v][a-z] +[._]sw[a-p] + +# Session +Session.vim + +# Temporary +.netrwhist + +# Auto-generated tag files +tags + +# Redis dump file +dump.rdb + +### Project template +gpt_reservation_system/media/ + +.pytest_cache/ +.env +.idea/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..04639bd --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,57 @@ +exclude: '^docs/|/migrations/' +default_stages: [commit] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-json + - id: check-toml + - id: check-xml + - id: check-yaml + - id: debug-statements + - id: check-builtin-literals + - id: check-case-conflict + - id: check-docstring-first + - id: detect-private-key + + - repo: https://github.com/adamchainz/django-upgrade + rev: '1.14.0' + hooks: + - id: django-upgrade + args: ['--target-version', '4.2'] + + - repo: https://github.com/asottile/pyupgrade + rev: v3.7.0 + hooks: + - id: pyupgrade + args: [--py311-plus] + + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + + - repo: https://github.com/PyCQA/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + + - repo: https://github.com/Riverside-Healthcare/djLint + rev: v1.31.1 + hooks: + - id: djlint-reformat-django + - id: djlint-django + +# sets up .pre-commit-ci.yaml to ensure pre-commit dependencies stay up to date +ci: + autoupdate_schedule: weekly + skip: [] + submodules: false diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 0000000..55fd399 --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,20 @@ +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Set the version of Python and other tools you might need +build: + os: ubuntu-22.04 + tools: + python: '3.10' + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +# Python requirements required to build your docs +python: + install: + - requirements: requirements/local.txt diff --git a/CONTRIBUTORS.txt b/CONTRIBUTORS.txt new file mode 100644 index 0000000..fb3c23c --- /dev/null +++ b/CONTRIBUTORS.txt @@ -0,0 +1 @@ +Can Arsoy diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..90dbaa3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,9 @@ + +The MIT License (MIT) +Copyright (c) 2023, Can Arsoy + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b0261fc --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# GPT Reservation System + +GPT Reservation System + +[![Built with Cookiecutter Django](https://img.shields.io/badge/built%20with-Cookiecutter%20Django-ff69b4.svg?logo=cookiecutter)](https://github.com/cookiecutter/cookiecutter-django/) +[![Black code style](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) + +License: MIT + +## Using the API + +- Make sure to create a `.env` file with the same schema as the `env.example` file and enter your +`OPENAI_TOKEN` to leverage the OPEN AI API. +- Create a new virtualenv with **Python 3.10.8** with the tool of your choice (pyenv, virtualenv etc.) and install the requirements. + + ```bash + pyenv virtualenv 3.10.8 yourenv && + pyenv activate yourenv && + pip install -r requirements/local.txt + ``` + +- Run `python manage.py runserver` to spin up your server +- Send a `POST` request to `http://localhost:8000/appointments/manage/` via the tool of your choice (curl, POSTMAN etc.) + - The payload for the request is in this format: `{"user_prompt": "28 Temmuz'daki rezervasyonumuzu saat 17'ye degistirebilir miyiz?"}` + where the "user_prompt" is the appointment request sentence in Turkish. +- The response should be in this format: + ```json + { + "intent": "other", + "datetime": "2023-07-28T17:00:00+03:00", + "is_success": true + } + ``` + where "intent" is the appointment intent extracted, "datetime" is the extracted datetime information + and "is_success" indicates whether the extraction and the I/O operations were successful. + +#### Running tests with pytest + + $ pytest + +Note that the OPEN AI API is not mocked to test the +actual response, so please be cautious. diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..db0dfcc --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,243 @@ +""" +Base settings to build other settings files upon. +""" +from pathlib import Path + +from decouple import config + +BASE_DIR = Path(__file__).resolve(strict=True).parent.parent.parent +# gpt_reservation_system/ +APPS_DIR = BASE_DIR / "gpt_reservation_system" + + +# GENERAL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#debug +DEBUG = config("DJANGO_DEBUG", False) +# Local time zone. Choices are +# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name +# though not all of them may be available with every OS. +# In Windows, this must be set to your system time zone. +TIME_ZONE = "UTC" +# https://docs.djangoproject.com/en/dev/ref/settings/#language-code +LANGUAGE_CODE = "en-us" +# https://docs.djangoproject.com/en/dev/ref/settings/#languages +# from django.utils.translation import gettext_lazy as _ +# LANGUAGES = [ +# ('en', _('English')), +# ('pt-br', _('Português')), +# ] +# https://docs.djangoproject.com/en/dev/ref/settings/#site-id +SITE_ID = 1 +# https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n +USE_I18N = True +# https://docs.djangoproject.com/en/dev/ref/settings/#use-tz +USE_TZ = True +# https://docs.djangoproject.com/en/dev/ref/settings/#locale-paths +LOCALE_PATHS = [str(BASE_DIR / "locale")] + +# DATABASES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#databases + +# https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-DEFAULT_AUTO_FIELD +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# URLS +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#root-urlconf +ROOT_URLCONF = "config.urls" +# https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application +WSGI_APPLICATION = "config.wsgi.application" + +# APPS +# ------------------------------------------------------------------------------ +DJANGO_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", + # "django.contrib.humanize", # Handy template tags + "django.contrib.admin", + "django.forms", +] +THIRD_PARTY_APPS = ["rest_framework"] + +LOCAL_APPS = [ + "gpt_reservation_system.appointments", +] +# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps +INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS + +# MIGRATIONS +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#migration-modules +MIGRATION_MODULES = {"sites": "gpt_reservation_system.contrib.sites.migrations"} + +# AUTHENTICATION +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#authentication-backends +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", +] +# https://docs.djangoproject.com/en/dev/ref/settings/#auth-user-model +# AUTH_USER_MODEL = "users.User" +# https://docs.djangoproject.com/en/dev/ref/settings/#login-redirect-url +LOGIN_REDIRECT_URL = "users:redirect" +# https://docs.djangoproject.com/en/dev/ref/settings/#login-url +LOGIN_URL = "account_login" + +# PASSWORDS +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers +PASSWORD_HASHERS = [ + # https://docs.djangoproject.com/en/dev/topics/auth/passwords/#using-argon2-with-django + "django.contrib.auth.hashers.Argon2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", + "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", +] +# https://docs.djangoproject.com/en/dev/ref/settings/#auth-password-validators +AUTH_PASSWORD_VALIDATORS = [ + {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, + {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"}, + {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"}, + {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, +] + +# MIDDLEWARE +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#middleware +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.locale.LocaleMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +# STATIC +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#static-root +STATIC_ROOT = str(BASE_DIR / "staticfiles") +# https://docs.djangoproject.com/en/dev/ref/settings/#static-url +STATIC_URL = "/static/" +# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS +STATICFILES_DIRS = [str(APPS_DIR / "static")] +# https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders +STATICFILES_FINDERS = [ + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", +] + +# MEDIA +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#media-root +MEDIA_ROOT = str(APPS_DIR / "media") +# https://docs.djangoproject.com/en/dev/ref/settings/#media-url +MEDIA_URL = "/media/" + +# TEMPLATES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#templates +TEMPLATES = [ + { + # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND + "BACKEND": "django.template.backends.django.DjangoTemplates", + # https://docs.djangoproject.com/en/dev/ref/settings/#dirs + "DIRS": [str(APPS_DIR / "templates")], + # https://docs.djangoproject.com/en/dev/ref/settings/#app-dirs + "APP_DIRS": True, + "OPTIONS": { + # https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", + ], + }, + } +] + +# https://docs.djangoproject.com/en/dev/ref/settings/#form-renderer +FORM_RENDERER = "django.forms.renderers.TemplatesSetting" + +# http://django-crispy-forms.readthedocs.io/en/latest/install.html#template-packs +CRISPY_TEMPLATE_PACK = "bootstrap5" +CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" + +# FIXTURES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#fixture-dirs +FIXTURE_DIRS = (str(APPS_DIR / "fixtures"),) + +# SECURITY +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-httponly +SESSION_COOKIE_HTTPONLY = True +# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-httponly +CSRF_COOKIE_HTTPONLY = True +# https://docs.djangoproject.com/en/dev/ref/settings/#x-frame-options +X_FRAME_OPTIONS = "DENY" + +# EMAIL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend +EMAIL_BACKEND = config( + "DJANGO_EMAIL_BACKEND", + default="django.core.mail.backends.smtp.EmailBackend", +) +# https://docs.djangoproject.com/en/dev/ref/settings/#email-timeout +EMAIL_TIMEOUT = 5 + +# ADMIN +# ------------------------------------------------------------------------------ +# Django Admin URL. +ADMIN_URL = "admin/" +# https://docs.djangoproject.com/en/dev/ref/settings/#admins +ADMINS = [("""Can Arsoy""", "can-arsoy@example.com")] +# https://docs.djangoproject.com/en/dev/ref/settings/#managers +MANAGERS = ADMINS +# https://cookiecutter-django.readthedocs.io/en/latest/settings.html#other-environment-settings +# Force the `admin` sign in process to go through the `django-allauth` workflow +DJANGO_ADMIN_FORCE_ALLAUTH = config("DJANGO_ADMIN_FORCE_ALLAUTH", default=False, cast=bool) + +# LOGGING +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#logging +# See https://docs.djangoproject.com/en/dev/topics/logging for +# more details on how to customize your logging configuration. +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s", + }, + }, + "handlers": { + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "verbose", + } + }, + "root": {"level": "INFO", "handlers": ["console"]}, +} + + +# Config keys +# ------------------------------------------------------------------------------ +OPENAI_TOKEN = config("OPENAI_TOKEN", cast=str) +OPENAI_MODEL = "gpt-4" diff --git a/config/settings/local.py b/config/settings/local.py new file mode 100644 index 0000000..796d1a8 --- /dev/null +++ b/config/settings/local.py @@ -0,0 +1,52 @@ +from .base import * # noqa +from .base import config + +# GENERAL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#debug +DEBUG = True +# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key +SECRET_KEY = config( + "DJANGO_SECRET_KEY", + default="A7Ua7tYfXVEA8bPC7H3GQHquRyvfnUBx4ZXIImQtMA2AqFPzfm4mnRNnbjHI7BOH", +) +# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts +ALLOWED_HOSTS = ["localhost", "0.0.0.0", "127.0.0.1"] + +# CACHES +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#caches +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "", + } +} + +# EMAIL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend +EMAIL_BACKEND = config("DJANGO_EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend") + +# django-debug-toolbar +# ------------------------------------------------------------------------------ +# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#prerequisites +INSTALLED_APPS += ["debug_toolbar"] # noqa: F405 +# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#middleware +MIDDLEWARE += ["debug_toolbar.middleware.DebugToolbarMiddleware"] # noqa: F405 +# https://django-debug-toolbar.readthedocs.io/en/latest/configuration.html#debug-toolbar-config +DEBUG_TOOLBAR_CONFIG = { + "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"], + "SHOW_TEMPLATE_CONTEXT": True, +} +# https://django-debug-toolbar.readthedocs.io/en/latest/installation.html#internal-ips +INTERNAL_IPS = ["127.0.0.1", "10.0.2.2"] + + +# django-extensions +# ------------------------------------------------------------------------------ +# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration +INSTALLED_APPS += ["django_extensions"] # noqa: F405 + +# Your stuff... +# ------------------------------------------------------------------------------ diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..a1d0d74 --- /dev/null +++ b/config/settings/production.py @@ -0,0 +1,170 @@ +from .base import * # noqa +from .base import env + +# GENERAL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key +SECRET_KEY = env("DJANGO_SECRET_KEY") +# https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts +ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["example.com"]) + +# CACHES +# ------------------------------------------------------------------------------ +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": env("REDIS_URL"), + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + # Mimicing memcache behavior. + # https://github.com/jazzband/django-redis#memcached-exceptions-behavior + "IGNORE_EXCEPTIONS": True, + }, + } +} + +# SECURITY +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header +SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") +# https://docs.djangoproject.com/en/dev/ref/settings/#secure-ssl-redirect +SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=True) +# https://docs.djangoproject.com/en/dev/ref/settings/#session-cookie-secure +SESSION_COOKIE_SECURE = True +# https://docs.djangoproject.com/en/dev/ref/settings/#csrf-cookie-secure +CSRF_COOKIE_SECURE = True +# https://docs.djangoproject.com/en/dev/topics/security/#ssl-https +# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-seconds +# TODO: set this to 60 seconds first and then to 518400 once you prove the former works +SECURE_HSTS_SECONDS = 60 +# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-include-subdomains +SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool("DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS", default=True) +# https://docs.djangoproject.com/en/dev/ref/settings/#secure-hsts-preload +SECURE_HSTS_PRELOAD = env.bool("DJANGO_SECURE_HSTS_PRELOAD", default=True) +# https://docs.djangoproject.com/en/dev/ref/middleware/#x-content-type-options-nosniff +SECURE_CONTENT_TYPE_NOSNIFF = env.bool("DJANGO_SECURE_CONTENT_TYPE_NOSNIFF", default=True) + +# STORAGES +# ------------------------------------------------------------------------------ +# https://django-storages.readthedocs.io/en/latest/#installation +INSTALLED_APPS += ["storages"] # noqa: F405 +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings +AWS_ACCESS_KEY_ID = env("DJANGO_AWS_ACCESS_KEY_ID") +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings +AWS_SECRET_ACCESS_KEY = env("DJANGO_AWS_SECRET_ACCESS_KEY") +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings +AWS_STORAGE_BUCKET_NAME = env("DJANGO_AWS_STORAGE_BUCKET_NAME") +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings +AWS_QUERYSTRING_AUTH = False +# DO NOT change these unless you know what you're doing. +_AWS_EXPIRY = 60 * 60 * 24 * 7 +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings +AWS_S3_OBJECT_PARAMETERS = { + "CacheControl": f"max-age={_AWS_EXPIRY}, s-maxage={_AWS_EXPIRY}, must-revalidate", +} +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings +AWS_S3_MAX_MEMORY_SIZE = env.int( + "DJANGO_AWS_S3_MAX_MEMORY_SIZE", + default=100_000_000, # 100MB +) +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#settings +AWS_S3_REGION_NAME = env("DJANGO_AWS_S3_REGION_NAME", default=None) +# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html#cloudfront +AWS_S3_CUSTOM_DOMAIN = env("DJANGO_AWS_S3_CUSTOM_DOMAIN", default=None) +aws_s3_domain = AWS_S3_CUSTOM_DOMAIN or f"{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com" +# STATIC +# ------------------------ +STATICFILES_STORAGE = "gpt_reservation_system.utils.storages.StaticRootS3Boto3Storage" +COLLECTFAST_STRATEGY = "collectfast.strategies.boto3.Boto3Strategy" +STATIC_URL = f"https://{aws_s3_domain}/static/" +# MEDIA +# ------------------------------------------------------------------------------ +DEFAULT_FILE_STORAGE = "gpt_reservation_system.utils.storages.MediaRootS3Boto3Storage" +MEDIA_URL = f"https://{aws_s3_domain}/media/" + +# EMAIL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#default-from-email +DEFAULT_FROM_EMAIL = env( + "DJANGO_DEFAULT_FROM_EMAIL", + default="GPT Reservation System ", +) +# https://docs.djangoproject.com/en/dev/ref/settings/#server-email +SERVER_EMAIL = env("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL) +# https://docs.djangoproject.com/en/dev/ref/settings/#email-subject-prefix +EMAIL_SUBJECT_PREFIX = env( + "DJANGO_EMAIL_SUBJECT_PREFIX", + default="[GPT Reservation System] ", +) + +# ADMIN +# ------------------------------------------------------------------------------ +# Django Admin URL regex. +ADMIN_URL = env("DJANGO_ADMIN_URL") + +# Anymail +# ------------------------------------------------------------------------------ +# https://anymail.readthedocs.io/en/stable/installation/#installing-anymail +INSTALLED_APPS += ["anymail"] # noqa: F405 +# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend +# https://anymail.readthedocs.io/en/stable/installation/#anymail-settings-reference +# https://anymail.readthedocs.io/en/stable/esps/mailgun/ +EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend" +ANYMAIL = { + "MAILGUN_API_KEY": env("MAILGUN_API_KEY"), + "MAILGUN_SENDER_DOMAIN": env("MAILGUN_DOMAIN"), + "MAILGUN_API_URL": env("MAILGUN_API_URL", default="https://api.mailgun.net/v3"), +} + +# Collectfast +# ------------------------------------------------------------------------------ +# https://github.com/antonagestam/collectfast#installation +INSTALLED_APPS = ["collectfast"] + INSTALLED_APPS # noqa: F405 + +# LOGGING +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#logging +# See https://docs.djangoproject.com/en/dev/topics/logging for +# more details on how to customize your logging configuration. +# A sample logging configuration. The only tangible logging +# performed by this configuration is to send an email to +# the site admins on every HTTP 500 error when DEBUG=False. +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s", + }, + }, + "handlers": { + "mail_admins": { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "django.utils.log.AdminEmailHandler", + }, + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "verbose", + }, + }, + "root": {"level": "INFO", "handlers": ["console"]}, + "loggers": { + "django.request": { + "handlers": ["mail_admins"], + "level": "ERROR", + "propagate": True, + }, + "django.security.DisallowedHost": { + "level": "ERROR", + "handlers": ["console", "mail_admins"], + "propagate": True, + }, + }, +} + + +# Your stuff... +# ------------------------------------------------------------------------------ diff --git a/config/settings/test.py b/config/settings/test.py new file mode 100644 index 0000000..c5e2d64 --- /dev/null +++ b/config/settings/test.py @@ -0,0 +1,32 @@ +""" +With these settings, tests run faster. +""" + +from .base import * # noqa +from .base import config + +# GENERAL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#secret-key +SECRET_KEY = config( + "DJANGO_SECRET_KEY", + default="YzXne273FeUVF7A2RYc0DiY1kq2Cc6c42xrOq4OVG0jVrW66twPDLVNDCvFCREK7", +) +# https://docs.djangoproject.com/en/dev/ref/settings/#test-runner +TEST_RUNNER = "django.test.runner.DiscoverRunner" + +# PASSWORDS +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + +# EMAIL +# ------------------------------------------------------------------------------ +# https://docs.djangoproject.com/en/dev/ref/settings/#email-backend +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" + +# DEBUGGING FOR TEMPLATES +# ------------------------------------------------------------------------------ +TEMPLATES[0]["OPTIONS"]["debug"] = True # type: ignore # noqa: F405 +# Your stuff... +# ------------------------------------------------------------------------------ diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..25dde19 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,14 @@ +from django.conf import settings +from django.urls import include, path + +urlpatterns = [ + # Appointment management + path("appointments/", include("gpt_reservation_system.appointments.urls", namespace="users")), +] + + +if settings.DEBUG: + if "debug_toolbar" in settings.INSTALLED_APPS: + import debug_toolbar + + urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..bb6a906 --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,38 @@ +""" +WSGI config for GPT Reservation System project. + +This module contains the WSGI application used by Django's development server +and any production WSGI deployments. It should expose a module-level variable +named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover +this application via the ``WSGI_APPLICATION`` setting. + +Usually you will have the standard Django WSGI application here, but it also +might make sense to replace the whole Django WSGI application with a custom one +that later delegates to the Django one. For example, you could introduce WSGI +middleware here, or combine a Django application with an application of another +framework. + +""" +import os +import sys +from pathlib import Path + +from django.core.wsgi import get_wsgi_application + +# This allows easy placement of apps within the interior +# gpt_reservation_system directory. +BASE_DIR = Path(__file__).resolve(strict=True).parent.parent +sys.path.append(str(BASE_DIR / "gpt_reservation_system")) +# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks +# if running multiple sites in the same mod_wsgi process. To fix this, use +# mod_wsgi daemon mode with each site in its own daemon process, or use +# os.environ["DJANGO_SETTINGS_MODULE"] = "config.settings.production" +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production") + +# This application object is used by any WSGI server configured to use this +# file. This includes Django's development server, if the WSGI_APPLICATION +# setting points here. +application = get_wsgi_application() +# Apply WSGI middleware here. +# from helloworld.wsgi import HelloWorldApplication +# application = HelloWorldApplication(application) diff --git a/gpt_reservation_system/__init__.py b/gpt_reservation_system/__init__.py new file mode 100644 index 0000000..9c9b953 --- /dev/null +++ b/gpt_reservation_system/__init__.py @@ -0,0 +1,2 @@ +__version__ = "0.1.0" +__version_info__ = tuple(int(num) if num.isdigit() else num for num in __version__.replace("-", ".", 1).split(".")) diff --git a/gpt_reservation_system/appointments/__init__.py b/gpt_reservation_system/appointments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gpt_reservation_system/appointments/apps.py b/gpt_reservation_system/appointments/apps.py new file mode 100644 index 0000000..883a79d --- /dev/null +++ b/gpt_reservation_system/appointments/apps.py @@ -0,0 +1,13 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class UsersConfig(AppConfig): + name = "gpt_reservation_system.appointments" + verbose_name = _("Users") + + def ready(self): + try: + import gpt_reservation_system.appointments.signals # noqa: F401 + except ImportError: + pass diff --git a/gpt_reservation_system/appointments/exceptions.py b/gpt_reservation_system/appointments/exceptions.py new file mode 100644 index 0000000..f52fc09 --- /dev/null +++ b/gpt_reservation_system/appointments/exceptions.py @@ -0,0 +1,2 @@ +class AppointmentException(Exception): + pass diff --git a/gpt_reservation_system/appointments/manager.py b/gpt_reservation_system/appointments/manager.py new file mode 100644 index 0000000..2be3186 --- /dev/null +++ b/gpt_reservation_system/appointments/manager.py @@ -0,0 +1,81 @@ +import json +from datetime import datetime +from typing import Dict, Optional + +import pytz +import requests +from django.conf import settings + +from gpt_reservation_system.appointments.exceptions import AppointmentException + +# GPT parameters +MAX_TOKENS = 300 +PATCH_MAX_LENGTH = 5500 +TEMPERATURE = 0.8 + +# Timezone +LOCAL_TIMEZONE = pytz.timezone("Europe/Istanbul") +LOCAL_TIME = datetime.now(LOCAL_TIMEZONE).isoformat() + +# Prompts +SYSTEM_PROMPT = f""" +You're a native Turkish speaker assistant within the GMT+3 timezone who helps businesses manage appointment +requests coming from clients. You will be given a appointment request as a Turkish sentence and you should find out +for which datetime the appointment was requested and the intent of the request. +Only return the intent string and the GMT+3 datetime as a ISO 8601 string in JSON format. +Example format for the JSON is {{\"intent\": \"new_appointment\", \"datetime\": \"2023-07-24T15:45:00+03:00\"}}. +The available values for the `intent` are `new_appointment` and `other`, where `new_appointment` indicates that a +new appointment request has been made and `other` is for other requests, such as when a client wants to cancel an +already existing appointment. The current date and time is " +""" + + +class AppointmentManager: + def __init__(self): + self.system_prompt = SYSTEM_PROMPT + self.local_time = datetime.now(LOCAL_TIMEZONE).isoformat() + + OPENAI_API = requests.Session() + OPENAI_API.headers.update( + {"Content-Type": "application/json", "Authorization": f"Bearer {settings.OPENAI_TOKEN}"} + ) + self.openai_api = OPENAI_API + self.openai_model = settings.OPENAI_MODEL + + def execute(self, user_prompt: str) -> Optional[Dict]: + """Gets appointment intent and date time given a Turkish sentence user prompt. + + Raises: + AppointmentException: When there is an error during the request / response cycle. + """ + + try: + return self.get_appointment_details(user_prompt) + except AppointmentException as e: + raise e + + def get_appointment_details(self, user_prompt: str) -> Dict: + """Gets appointment intent and date time given a Turkish sentence user prompt. + + Raises: + AppointmentException: When there is an error during the request / response cycle. + """ + + try: + response = self.openai_api.post( + "https://api.openai.com/v1/chat/completions", + json={ + "model": self.openai_model, + "messages": [ + {"role": "system", "content": self.system_prompt + self.local_time}, + {"role": "user", "content": f"{user_prompt}"}, + ], + "n": 1, + "temperature": TEMPERATURE, + "max_tokens": MAX_TOKENS, + }, + ) + gpt_json_str = response.json()["choices"][0]["message"]["content"].strip() + return json.loads(gpt_json_str) + except Exception as e: + raise AppointmentException(f"Error occurred during appointment extraction. Error: {e.__str__()}") diff --git a/gpt_reservation_system/appointments/migrations/__init__.py b/gpt_reservation_system/appointments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gpt_reservation_system/appointments/serializers.py b/gpt_reservation_system/appointments/serializers.py new file mode 100644 index 0000000..e316b27 --- /dev/null +++ b/gpt_reservation_system/appointments/serializers.py @@ -0,0 +1,18 @@ +from rest_framework import serializers + +from gpt_reservation_system.appointments.exceptions import AppointmentException +from gpt_reservation_system.appointments.manager import AppointmentManager + + +class GPTManageAppointmentSerializer(serializers.Serializer): + def to_representation(self, data): + appointment_manager = AppointmentManager() + user_prompt = data["user_prompt"] + + try: + json_response = appointment_manager.execute(user_prompt) + json_response["is_success"] = True + except AppointmentException as e: + json_response = {"error": e.__str__(), "is_success": False} + + return json_response diff --git a/gpt_reservation_system/appointments/tests/__init__.py b/gpt_reservation_system/appointments/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/gpt_reservation_system/appointments/tests/test_views.py b/gpt_reservation_system/appointments/tests/test_views.py new file mode 100644 index 0000000..66d13d1 --- /dev/null +++ b/gpt_reservation_system/appointments/tests/test_views.py @@ -0,0 +1,36 @@ +from datetime import datetime + +import pytest +from django.urls import reverse +from freezegun import freeze_time +from rest_framework.test import APIClient + + +class TestGPTManageAppointmentView: + manage_url = reverse("appointments:manage") + + def send_manage_request(self, user_prompt): + response = APIClient().post(self.manage_url, {"user_prompt": user_prompt}, format="json") + return response + + @pytest.mark.parametrize( + "user_prompt,intent,expected_date_time", + [ + ("Yarin oglen 2'de musait misiniz?", "new_appointment", "2023-07-25T14:00:00+03:00"), + ("Haftaya 16:30'a rezervasyon yapabilir miyiz?", "new_appointment", "2023-07-31T16:30:00+03:00"), + ("Bugunku rezervasyonumuzu iptal edelim.", "other", "2023-07-24T16:00:00+03:00"), + ("Bu Carsamba 2'ye ceyrek kalaya ayarlayalim mi?", "new_appointment", "2023-07-26T13:45:00+03:00"), + ("28 Temmuz'daki rezervasyonumuzu saat 17'ye degistirebilir miyiz?", "other", "2023-07-28T17:00:00+03:00"), + ], + ) + def test_manage_view(self, user_prompt, intent, expected_date_time): + # Given + current_utc_time = datetime(2023, 7, 24, 13, 0, 0) + # When + with freeze_time(current_utc_time): + response = self.send_manage_request(user_prompt) + # Then + json_response = response.json() + assert json_response["is_success"] is True + assert json_response["intent"] == intent + assert json_response["datetime"] == expected_date_time diff --git a/gpt_reservation_system/appointments/urls.py b/gpt_reservation_system/appointments/urls.py new file mode 100644 index 0000000..151eb9f --- /dev/null +++ b/gpt_reservation_system/appointments/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from gpt_reservation_system.appointments.views import GPTManageAppointmentView + +app_name = "appointments" +urlpatterns = [ + path("manage/", view=GPTManageAppointmentView.as_view(), name="manage"), +] diff --git a/gpt_reservation_system/appointments/views.py b/gpt_reservation_system/appointments/views.py new file mode 100644 index 0000000..e5cacfd --- /dev/null +++ b/gpt_reservation_system/appointments/views.py @@ -0,0 +1,14 @@ +from rest_framework.response import Response +from rest_framework.views import APIView + +from gpt_reservation_system.appointments.serializers import GPTManageAppointmentSerializer + + +class GPTManageAppointmentView(APIView): + throttle_scope = "auth" + + serializer_class = GPTManageAppointmentSerializer + + def post(self, request): + serializer = self.serializer_class(request.data) + return Response(serializer.data) diff --git a/gpt_reservation_system/contrib/__init__.py b/gpt_reservation_system/contrib/__init__.py new file mode 100644 index 0000000..1c7ecc8 --- /dev/null +++ b/gpt_reservation_system/contrib/__init__.py @@ -0,0 +1,5 @@ +""" +To understand why this file is here, please read: + +http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django +""" diff --git a/gpt_reservation_system/contrib/sites/__init__.py b/gpt_reservation_system/contrib/sites/__init__.py new file mode 100644 index 0000000..1c7ecc8 --- /dev/null +++ b/gpt_reservation_system/contrib/sites/__init__.py @@ -0,0 +1,5 @@ +""" +To understand why this file is here, please read: + +http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django +""" diff --git a/gpt_reservation_system/contrib/sites/migrations/0001_initial.py b/gpt_reservation_system/contrib/sites/migrations/0001_initial.py new file mode 100644 index 0000000..304cd6d --- /dev/null +++ b/gpt_reservation_system/contrib/sites/migrations/0001_initial.py @@ -0,0 +1,42 @@ +import django.contrib.sites.models +from django.contrib.sites.models import _simple_domain_name_validator +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="Site", + fields=[ + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ( + "domain", + models.CharField( + max_length=100, + verbose_name="domain name", + validators=[_simple_domain_name_validator], + ), + ), + ("name", models.CharField(max_length=50, verbose_name="display name")), + ], + options={ + "ordering": ("domain",), + "db_table": "django_site", + "verbose_name": "site", + "verbose_name_plural": "sites", + }, + bases=(models.Model,), + managers=[("objects", django.contrib.sites.models.SiteManager())], + ) + ] diff --git a/gpt_reservation_system/contrib/sites/migrations/0002_alter_domain_unique.py b/gpt_reservation_system/contrib/sites/migrations/0002_alter_domain_unique.py new file mode 100644 index 0000000..2c8d6da --- /dev/null +++ b/gpt_reservation_system/contrib/sites/migrations/0002_alter_domain_unique.py @@ -0,0 +1,20 @@ +import django.contrib.sites.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [("sites", "0001_initial")] + + operations = [ + migrations.AlterField( + model_name="site", + name="domain", + field=models.CharField( + max_length=100, + unique=True, + validators=[django.contrib.sites.models._simple_domain_name_validator], + verbose_name="domain name", + ), + ) + ] diff --git a/gpt_reservation_system/contrib/sites/migrations/0003_set_site_domain_and_name.py b/gpt_reservation_system/contrib/sites/migrations/0003_set_site_domain_and_name.py new file mode 100644 index 0000000..d02dde8 --- /dev/null +++ b/gpt_reservation_system/contrib/sites/migrations/0003_set_site_domain_and_name.py @@ -0,0 +1,63 @@ +""" +To understand why this file is here, please read: + +http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django +""" +from django.conf import settings +from django.db import migrations + + +def _update_or_create_site_with_sequence(site_model, connection, domain, name): + """Update or create the site with default ID and keep the DB sequence in sync.""" + site, created = site_model.objects.update_or_create( + id=settings.SITE_ID, + defaults={ + "domain": domain, + "name": name, + }, + ) + if created: + # We provided the ID explicitly when creating the Site entry, therefore the DB + # sequence to auto-generate them wasn't used and is now out of sync. If we + # don't do anything, we'll get a unique constraint violation the next time a + # site is created. + # To avoid this, we need to manually update DB sequence and make sure it's + # greater than the maximum value. + max_id = site_model.objects.order_by("-id").first().id + with connection.cursor() as cursor: + cursor.execute("SELECT last_value from django_site_id_seq") + (current_id,) = cursor.fetchone() + if current_id <= max_id: + cursor.execute( + "alter sequence django_site_id_seq restart with %s", + [max_id + 1], + ) + + +def update_site_forward(apps, schema_editor): + """Set site domain and name.""" + Site = apps.get_model("sites", "Site") + _update_or_create_site_with_sequence( + Site, + schema_editor.connection, + "example.com", + "GPT Reservation System", + ) + + +def update_site_backward(apps, schema_editor): + """Revert site domain and name to default.""" + Site = apps.get_model("sites", "Site") + _update_or_create_site_with_sequence( + Site, + schema_editor.connection, + "example.com", + "example.com", + ) + + +class Migration(migrations.Migration): + + dependencies = [("sites", "0002_alter_domain_unique")] + + operations = [migrations.RunPython(update_site_forward, update_site_backward)] diff --git a/gpt_reservation_system/contrib/sites/migrations/0004_alter_options_ordering_domain.py b/gpt_reservation_system/contrib/sites/migrations/0004_alter_options_ordering_domain.py new file mode 100644 index 0000000..f7118ca --- /dev/null +++ b/gpt_reservation_system/contrib/sites/migrations/0004_alter_options_ordering_domain.py @@ -0,0 +1,21 @@ +# Generated by Django 3.1.7 on 2021-02-04 14:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("sites", "0003_set_site_domain_and_name"), + ] + + operations = [ + migrations.AlterModelOptions( + name="site", + options={ + "ordering": ["domain"], + "verbose_name": "site", + "verbose_name_plural": "sites", + }, + ), + ] diff --git a/gpt_reservation_system/contrib/sites/migrations/__init__.py b/gpt_reservation_system/contrib/sites/migrations/__init__.py new file mode 100644 index 0000000..1c7ecc8 --- /dev/null +++ b/gpt_reservation_system/contrib/sites/migrations/__init__.py @@ -0,0 +1,5 @@ +""" +To understand why this file is here, please read: + +http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django +""" diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..6859b2e --- /dev/null +++ b/manage.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +import os +import sys +from pathlib import Path + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.local") + + try: + from django.core.management import execute_from_command_line + except ImportError: + # The above import may fail for some other reason. Ensure that the + # issue is really that Django is missing to avoid masking other + # exceptions on Python 2. + try: + import django # noqa + except ImportError: + 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?" + ) + + raise + + # This allows easy placement of apps within the interior + # gpt_reservation_system directory. + current_path = Path(__file__).parent.resolve() + sys.path.append(str(current_path / "gpt_reservation_system")) + + execute_from_command_line(sys.argv) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..dd94b9b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,103 @@ +# ==== pytest ==== +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "--ds=config.settings.test --reuse-db" +python_files = [ + "tests.py", + "test_*.py", +] + +# ==== Coverage ==== +[tool.coverage.run] +include = ["gpt_reservation_system/**"] +omit = ["*/migrations/*", "*/tests/*"] +plugins = ["django_coverage_plugin"] + + +# ==== black ==== +[tool.black] +line-length = 119 +target-version = ['py311'] + + +# ==== isort ==== +[tool.isort] +profile = "black" +line_length = 119 +known_first_party = [ + "gpt_reservation_system", + "config", +] +skip = ["venv/"] +skip_glob = ["**/migrations/*.py"] + + +# ==== mypy ==== +[tool.mypy] +python_version = "3.11" +check_untyped_defs = true +ignore_missing_imports = true +warn_unused_ignores = true +warn_redundant_casts = true +warn_unused_configs = true +plugins = [ + "mypy_django_plugin.main", +] + +[[tool.mypy.overrides]] +# Django migrations should not produce any errors: +module = "*.migrations.*" +ignore_errors = true + +[tool.django-stubs] +django_settings_module = "config.settings.test" + + +# ==== PyLint ==== +[tool.pylint.MASTER] +load-plugins = [ + "pylint_django", +] +django-settings-module = "config.settings.local" + +[tool.pylint.FORMAT] +max-line-length = 119 + +[tool.pylint."MESSAGES CONTROL"] +disable = [ + "missing-docstring", + "invalid-name", +] + +[tool.pylint.DESIGN] +max-parents = 13 + +[tool.pylint.TYPECHECK] +generated-members = [ + "REQUEST", + "acl_users", + "aq_parent", + "[a-zA-Z]+_set{1,2}", + "save", + "delete", +] + + +# ==== djLint ==== +[tool.djlint] +blank_line_after_tag = "load,extends" +close_void_tags = true +format_css = true +format_js = true +# TODO: remove T002 when fixed https://github.com/Riverside-Healthcare/djLint/issues/687 +ignore = "H006,H030,H031,T002" +include = "H017,H035" +indent = 2 +max_line_length = 119 +profile = "django" + +[tool.djlint.css] +indent_size = 2 + +[tool.djlint.js] +indent_size = 2 diff --git a/requirements/base.txt b/requirements/base.txt new file mode 100644 index 0000000..878b286 --- /dev/null +++ b/requirements/base.txt @@ -0,0 +1,18 @@ +# python-slugify==8.0.1 # https://github.com/un33k/python-slugify +# Pillow==10.0.0 # https://github.com/python-pillow/Pillow +# argon2-cffi==21.3.0 # https://github.com/hynek/argon2_cffi +# redis==4.6.0 # https://github.com/redis/redis-py +# hiredis==2.2.3 # https://github.com/redis/hiredis-py +python-decouple==3.8 +freezegun==1.2.2 + +# Django +# ------------------------------------------------------------------------------ +django==4.0.8 # pyup: < 5.0 # https://www.djangoproject.com/ +# django-environ==0.10.0 # https://github.com/joke2k/django-environ +# django-model-utils==4.3.1 # https://github.com/jazzband/django-model-utils +# django-allauth==0.54.0 # https://github.com/pennersr/django-allauth +# django-crispy-forms==2.0 # https://github.com/django-crispy-forms/django-crispy-forms +# crispy-bootstrap5==0.7 # https://github.com/django-crispy-forms/crispy-bootstrap5 +# django-redis==5.3.0 # https://github.com/jazzband/django-redis +djangorestframework==3.14.0 diff --git a/requirements/local.txt b/requirements/local.txt new file mode 100644 index 0000000..ff044f0 --- /dev/null +++ b/requirements/local.txt @@ -0,0 +1,36 @@ +-r base.txt + +Werkzeug[watchdog]==2.3.6 # https://github.com/pallets/werkzeug +ipdb==0.13.13 # https://github.com/gotcha/ipdb +psycopg[binary]==3.1.9 # https://github.com/psycopg/psycopg + +# Testing +# ------------------------------------------------------------------------------ +mypy==1.4.1 # https://github.com/python/mypy +django-stubs==4.2.3 # https://github.com/typeddjango/django-stubs +pytest==7.4.0 # https://github.com/pytest-dev/pytest +pytest-sugar==0.9.7 # https://github.com/Frozenball/pytest-sugar + +# Documentation +# ------------------------------------------------------------------------------ +sphinx==6.2.1 # https://github.com/sphinx-doc/sphinx +sphinx-autobuild==2021.3.14 # https://github.com/GaretJax/sphinx-autobuild + +# Code quality +# ------------------------------------------------------------------------------ +flake8==6.0.0 # https://github.com/PyCQA/flake8 +flake8-isort==6.0.0 # https://github.com/gforcada/flake8-isort +coverage==7.2.7 # https://github.com/nedbat/coveragepy +black==23.7.0 # https://github.com/psf/black +djlint==1.32.1 # https://github.com/Riverside-Healthcare/djLint +pylint-django==2.5.3 # https://github.com/PyCQA/pylint-django +pre-commit==3.3.3 # https://github.com/pre-commit/pre-commit + +# Django +# ------------------------------------------------------------------------------ +factory-boy==3.3.0 # https://github.com/FactoryBoy/factory_boy + +django-debug-toolbar==4.1.0 # https://github.com/jazzband/django-debug-toolbar +django-extensions==3.2.3 # https://github.com/django-extensions/django-extensions +django-coverage-plugin==3.1.0 # https://github.com/nedbat/django_coverage_plugin +pytest-django==4.5.2 # https://github.com/pytest-dev/pytest-django diff --git a/requirements/production.txt b/requirements/production.txt new file mode 100644 index 0000000..569a576 --- /dev/null +++ b/requirements/production.txt @@ -0,0 +1,12 @@ +# PRECAUTION: avoid production dependencies that aren't in development + +-r base.txt + +gunicorn==21.2.0 # https://github.com/benoitc/gunicorn +psycopg[c]==3.1.9 # https://github.com/psycopg/psycopg +Collectfast==2.2.0 # https://github.com/antonagestam/collectfast + +# Django +# ------------------------------------------------------------------------------ +django-storages[boto3]==1.13.2 # https://github.com/jschneier/django-storages +django-anymail[mailgun]==10.0 # https://github.com/anymail/django-anymail diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..8290642 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,10 @@ +# flake8 and pycodestyle don't support pyproject.toml +# https://github.com/PyCQA/flake8/issues/234 +# https://github.com/PyCQA/pycodestyle/issues/813 +[flake8] +max-line-length = 119 +exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv,.venv + +[pycodestyle] +max-line-length = 119 +exclude = .tox,.git,*/migrations/*,*/static/CACHE/*,docs,node_modules,venv,.venv