diff --git a/.djlintrc b/.djlintrc new file mode 100644 index 0000000..cdcc348 --- /dev/null +++ b/.djlintrc @@ -0,0 +1,12 @@ +{ + "blank_line_after_tag": "load,extends,include", + "close_void_tags": true, + "format_css": true, + "format_js": true, + "ignore": "H006,H030,H031", + "indent": 2, + "max_line_length": 120, + "preserve_blank_lines": true, + "preserve_leading_space": true, + "format_attribute_template_tags": false +} diff --git a/.dockerignore b/.dockerignore index a602416..98c5421 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,3 +10,4 @@ venv .git .envs/ +.bridge diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..73a1b51 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,17 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "weekly" + allow: + - dependency-type: "all" + commit-message: + prefix: "dependabot" + include: "scope" + labels: + - "dependencies" + open-pull-requests-limit: 5 + ignore: + - dependency-name: "uv" + versions: ["*"] diff --git a/.github/workflows/bump_version.yml b/.github/workflows/bump_version.yml new file mode 100644 index 0000000..2b42d99 --- /dev/null +++ b/.github/workflows/bump_version.yml @@ -0,0 +1,69 @@ +name: Bump Version with Commitizen + +on: + push: + branches: + - binary + issue_comment: + types: [created] + +jobs: + prerelease: + if: github.event.issue.pull_request != null && contains(github.event.comment.body, '/prerelease') + runs-on: ubuntu-latest + steps: + - name: Extract prerelease value + id: prerelease + run: | + echo "value=$(echo '${{ github.event.comment.body }}' | awk '{print $2}')" >> $GITHUB_OUTPUT + - name: Checkout code + uses: actions/checkout@v4 + - name: Create prerelease flag + run: | + echo "${{ steps.prerelease.outputs.value }}" > .prerelease_flag + bump-version: + runs-on: ubuntu-latest + if: github.event_name == 'push' && contains(github.ref, 'refs/heads/binary') + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + export PATH="$HOME/.local/bin:$PATH" + - name: Install Commitizen + run: | + poetry add --dev commitizen + - name: Update Build Info + run: | + poetry run python devtools/bump_build.py + git add . + - name: Check for prerelease flag + id: prerelease + run: | + if [ -f .prerelease_flag ]; then + PRERELEASE=$(cat .prerelease_flag) + echo "prerelease=$PRERELEASE" >> $GITHUB_OUTPUT + rm .prerelease_flag + git add . + else + echo "prerelease=" >> $GITHUB_OUTPUT + fi + - name: Bump version with Commitizen + run: | + if [ -n "${{ steps.prerelease.outputs.prerelease }}" ]; then + poetry run cz bump --yes --prerelease "${{ steps.prerelease.outputs.prerelease }}" + else + poetry run cz bump --yes + fi + - name: Push changes + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git add . + git commit -m "chore(release): bump version [skip ci]" + git push diff --git a/.github/workflows/django.yml b/.github/workflows/django.yml new file mode 100644 index 0000000..b1a720c --- /dev/null +++ b/.github/workflows/django.yml @@ -0,0 +1,36 @@ +name: Django CI + +on: + push: + branches: [ "binary" ] + pull_request: + branches: [ "binary" ] + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + python-version: [3.12, 3.13] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install Poetry + run: | + curl -sSL https://install.python-poetry.org | python3 - + export PATH="$HOME/.local/bin:$PATH" + - name: Install Dependencies + run: | + poetry sync --with test + - name: Collect Static Files + run: | + poetry run python manage.py collectstatic --noinput + - name: Run Tests + run: | + poetry run pytest diff --git a/.gitignore b/.gitignore index 67df634..c661c69 100644 --- a/.gitignore +++ b/.gitignore @@ -117,7 +117,7 @@ node_modules/ jspm_packages/ # Typescript v1 declaration files -typings/ +# typings/ # also used as a stubs location for pyright # Optional npm cache directory .npm diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/Incredible-Data.iml b/.idea/Incredible-Data.iml new file mode 100644 index 0000000..bbdb88e --- /dev/null +++ b/.idea/Incredible-Data.iml @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.idea/copilot.data.migration.agent.xml b/.idea/copilot.data.migration.agent.xml new file mode 100644 index 0000000..174f94f --- /dev/null +++ b/.idea/copilot.data.migration.agent.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/.idea/copilot.data.migration.ask.xml b/.idea/copilot.data.migration.ask.xml new file mode 100644 index 0000000..192e353 --- /dev/null +++ b/.idea/copilot.data.migration.ask.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/.idea/copilot.data.migration.ask2agent.xml b/.idea/copilot.data.migration.ask2agent.xml new file mode 100644 index 0000000..135d282 --- /dev/null +++ b/.idea/copilot.data.migration.ask2agent.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/.idea/copilot.data.migration.edit.xml b/.idea/copilot.data.migration.edit.xml new file mode 100644 index 0000000..906443a --- /dev/null +++ b/.idea/copilot.data.migration.edit.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/.idea/externalDependencies.xml b/.idea/externalDependencies.xml new file mode 100644 index 0000000..a2700e4 --- /dev/null +++ b/.idea/externalDependencies.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..cc5462d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..9e7f054 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ff96819 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..dcb6b8c --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ac36b54..f1af131 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -4,12 +4,20 @@ ci: submodules: false default_stages: -- commit +- pre-commit exclude: ^docs/|/migrations/|devcontainer.json|/users/ repos: + - repo: local + hooks: + - id: update-build-info + name: Update build_info.py + entry: python devtools/bump_build.py + language: system + pass_filenames: false + stages: [commit] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer @@ -22,41 +30,35 @@ repos: - id: check-docstring-first - id: detect-private-key - repo: https://github.com/adamchainz/django-upgrade - rev: 1.19.0 + rev: 1.25.0 hooks: - id: django-upgrade - args: [--target-version, "5.0"] + args: [--target-version, "5.1"] - repo: https://gitlab.com/bmares/check-json5 rev: v1.0.0 hooks: - id: check-json5 + - repo: https://github.com/fpgmaas/deptry.git + rev: 0.23.1 + hooks: + - id: deptry - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.0 + rev: v0.12.9 hooks: - - id: ruff - args: [--fix, --exit-non-zero-on-fix] + - id: ruff-check + args: [--fix] - id: ruff-format - repo: https://github.com/Riverside-Healthcare/djLint - rev: v1.34.1 + rev: v1.36.4 hooks: - id: djlint-reformat-django - id: djlint-django - repo: https://github.com/commitizen-tools/commitizen - rev: v3.27.0 + rev: v4.8.3 hooks: - id: commitizen - - repo: https://github.com/asottile/pyupgrade - rev: v3.16.0 - hooks: - - id: pyupgrade - args: [--py311-plus,--py36-plus,--py37-plus,--py38-plus,--py39-plus,--py310-plus,--py311-plus,--py312-plus] - repo: https://github.com/python-poetry/poetry - #! does not update properly - # 1.8.3 as of 2024-05-27 - rev: 1.8.3 + rev: 2.1.3 hooks: - id: poetry-check - id: poetry-lock - args: [--no-update] - - id: poetry-export - args: [--with, "production",-o,"requirements/production.txt"] diff --git a/.python_version b/.python_version new file mode 100644 index 0000000..fdcfcfd --- /dev/null +++ b/.python_version @@ -0,0 +1 @@ +3.12 \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..e45cd02 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,34 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Django", + "type": "debugpy", + "request": "launch", + "args": ["runserver", "8763"], + "django": true, + "autoStartBrowser": false, + "program": "${workspaceFolder}\\manage.py", + "env": { + "DJANGO_READ_DOT_ENV_FILE": "false" + }, + "justMyCode": true + }, + { + "name": "Django: ALL Code", + "type": "debugpy", + "request": "launch", + "args": ["runserver", "8763"], + "django": true, + "autoStartBrowser": false, + "program": "${workspaceFolder}\\manage.py", + "env": { + "DJANGO_READ_DOT_ENV_FILE": "false" + }, + "justMyCode": false + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 02f00fe..495ff1c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,15 +1,38 @@ { - "esbonio.sphinx.confDir": "", - "files.associations": { - "*.in": "pip-requirements", + "esbonio.sphinx.confDir": "", + "files.associations": { + "*.in": "pip-requirements" + }, + "[python]": { + "diffEditor.ignoreTrimWhitespace": false, + "editor.formatOnType": true, + "editor.wordBasedSuggestions": "off", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" }, - "[python]": { - "diffEditor.ignoreTrimWhitespace": false, - "editor.formatOnType": true, - "editor.wordBasedSuggestions": "off", - "editor.codeActionsOnSave": { - "source.organizeImports": "explicit" - }, - "editor.formatOnSave": true, - } + "editor.formatOnSave": true, + "editor.rulers": [100] + }, + "[html]": { + "editor.defaultFormatter": "monosans.djlint", + "editor.formatOnSave": true + }, + "[django-html]": { + "editor.defaultFormatter": "monosans.djlint", + "editor.formatOnSave": true + }, + "djlint.useEditorIndentation": false, + "djlint.formatCss": true, + "djlint.formatJs": true, + "djlint.closeVoidTags": true, + "python.testing.pytestEnabled": true, + "python.testing.pytestArgs": [ + "tests", + "incredible_data/helpers/tests", + "incredible_data/users/tests", + "incredible_data/budget/tests" + ], + "python.testing.unittestEnabled": false, + "python.languageServer": "None", + "terminal.integrated.defaultProfile.windows": "Command Prompt" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aabe22..89d78f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## Unreleased + +## 0.7.0.dev20240913646 (2024-09-13) + +## 0.7.0.dev20240913635 (2024-09-13) + +## 0.7.0.dev20240913608 (2024-09-13) + +## 0.7.0.dev20240913595 (2024-09-13) + +### Fix + +- **psycopg**: remove "c" extra and use "binary" always + +## 0.7.0.dev20240913583 (2024-09-13) + +## 0.7.0.dev20240910001 (2024-09-10) + +### Feat + +- **allauth**: add socialaccount_providers variable +- **fuel**: add new app with basic models +- **views**: neapolitan and other views; mostly working invoice + ## 0.6.0 (2024-06-05) ### Feat diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..a57d785 --- /dev/null +++ b/Makefile @@ -0,0 +1,52 @@ + +ENV_RUN := poetry run +DJANGO_MANAGE := $(ENV_RUN) python manage.py +PYTHON := $(ENV_RUN) python + +.PHONY: help runserver migrate makemigrations test pytest-coverage lint format collectstatic shell + +help: + @echo "Common Django project commands:" + @echo " make runserver # Start Django development server" + @echo " make migrate # Apply database migrations" + @echo " make makemigrations # Create new migrations" + @echo " make test # Run all tests" + @echo " make pytest-coverage # Run tests with coverage (HTML report)" + @echo " make lint # Run ruff linting" + @echo " make format # Auto-format code with ruff" + @echo " make collectstatic # Collect static files" + @echo " make shell # Open Django shell" + +runserver: + $(DJANGO_MANAGE) runserver + +migrate: + $(DJANGO_MANAGE) migrate + +makemigrations: + $(DJANGO_MANAGE) makemigrations + +test: + $(ENV_RUN) pytest + +coverage: + $(ENV_RUN) coverage run -m pytest + $(ENV_RUN) coverage html + +lint: + $(ENV_RUN) ruff check . + +format: + $(ENV_RUN) ruff format . + +collectstatic: + $(DJANGO_MANAGE) collectstatic --noinput + +shell: + $(DJANGO_MANAGE) shell + +precommit: + $(ENV_RUN) pre-commit run + +deptrycheck: + $(ENV_RUN) deptry . diff --git a/bridge.yaml b/bridge.yaml index d475e39..49ba177 100644 --- a/bridge.yaml +++ b/bridge.yaml @@ -1,2 +1,5 @@ enable_postgres: true enable_worker: false + +postgres_image: "postgres:15" +postgres_name: "bridge_incredible_data_postgres" diff --git a/compose/local/django/Dockerfile b/compose/local/django/Dockerfile index 3905876..6842117 100644 --- a/compose/local/django/Dockerfile +++ b/compose/local/django/Dockerfile @@ -58,6 +58,7 @@ RUN SNIPPET="export PROMPT_COMMAND='history -a' && export HISTFILE=/home/$USERNA RUN apt-get update && apt-get install --no-install-recommends -y \ # psycopg2 dependencies libpq-dev \ + postgresql-client \ # Translations dependencies gettext \ # cleaning up unused files diff --git a/config/api_router.py b/config/api_router.py index 0240253..16ad702 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -1,12 +1,29 @@ +import logging + from django.conf import settings +from django.urls import URLPattern, URLResolver, path from rest_framework.routers import DefaultRouter, SimpleRouter +from incredible_data.mood.api.views import ( + ChartAPIView, + EntryViewSet, + MetricTypeViewSet, + MetricViewSet, +) from incredible_data.users.api.views import UserViewSet -router = DefaultRouter() if settings.DEBUG else SimpleRouter() +logger = logging.getLogger(__name__) -router.register("users", UserViewSet) +router = DefaultRouter() if settings.DEBUG else SimpleRouter() # pyright: ignore[reportAny] +router.register("users", UserViewSet) +router.register("mood/metric-types", MetricTypeViewSet) +router.register("mood/metrics", MetricViewSet) +router.register("mood/entries", EntryViewSet) app_name = "api" -urlpatterns = router.urls +urlpatterns: list[URLPattern | URLResolver] = router.urls # pyright: ignore[reportAny] + +urlpatterns += [ + path("mood/chart/", ChartAPIView.as_view(), name="mood-chart"), +] diff --git a/config/settings/base.py b/config/settings/base.py index ef2f409..67a2a83 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -1,25 +1,56 @@ -# ruff: noqa: ERA001, E501 +# ruff: noqa: ERA001 +# pyright: reportConstantRedefinition=false """Base settings to build other settings files upon.""" from pathlib import Path +from typing import cast import environ from django.contrib.messages import constants as message_constants +from loguru import logger + +from config.settings.drf_models import Spectacular +from config.settings.settings_models import Databases, Logging +from devtools.bump_build import BUILD_INFO_FILE, GitInfo +from incredible_data import ( + __version__, # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType] +) BASE_DIR = Path(__file__).resolve(strict=True).parent.parent.parent # incredible_data/ APPS_DIR = BASE_DIR / "incredible_data" -env = environ.Env() +env = environ.Env( + SOCIALACCOUNT_PROVIDERS=(dict, {}), +) + + +def get_git_info() -> GitInfo: + """Attempts to use git to collect versioning info, falling back to the build info file.""" + try: + return GitInfo.get_info() + except RuntimeError: + return GitInfo.from_file(BUILD_INFO_FILE) + + +git_info = get_git_info() + +VERSION = cast("str", __version__) +BUILD_NUMBER = git_info.commit_hash + +logger.debug(f"Current version: {VERSION}+{BUILD_NUMBER}") READ_DOT_ENV_FILE = env.bool("DJANGO_READ_DOT_ENV_FILE", default=False) +logger.info(f"READ_DOT_ENV_FILE={READ_DOT_ENV_FILE}") + if READ_DOT_ENV_FILE: # OS environment variables take precedence over variables from .env - env.read_env(str(BASE_DIR / ".env")) + logger.info("Loading .env file to get environment variables") + env.read_env(BASE_DIR / ".env") # GENERAL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#debug -DEBUG = env.bool("DJANGO_DEBUG", False) +DEBUG = env.bool("DJANGO_DEBUG", default=False) MESSAGE_LEVEL = message_constants.DEBUG if DEBUG else message_constants.INFO # Local time zone. Choices are # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name @@ -47,8 +78,14 @@ # DATABASES # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#databases -DATABASES = {"default": env.db("DATABASE_URL", default="sqlite:///db.sqlite3")} -DATABASES["default"]["ATOMIC_REQUESTS"] = True +databases_obj = Databases.model_validate( + {"default": env.db("DATABASE_URL", default="sqlite:///db.sqlite3")} +) +databases_obj.default.atomic_requests = True +DATABASES = databases_obj.render() +# DATABASES = {"default": env.db("DATABASE_URL", default="sqlite:///db.sqlite3")} +# DATABASES["default"]["ATOMIC_REQUESTS"] = True +logger.debug(f"Default database engine: {databases_obj.default.engine_name}") # https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-DEFAULT_AUTO_FIELD DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" @@ -69,7 +106,7 @@ "django.contrib.messages", "django.contrib.staticfiles", # "django.contrib.humanize", # Handy template tags - # "constance", + "constance", "django.contrib.admin", "django.forms", ] @@ -82,6 +119,7 @@ "allauth.mfa", "allauth.socialaccount", "django_celery_beat", + "django_select2", "rest_framework", "rest_framework.authtoken", "corsheaders", @@ -94,6 +132,9 @@ "qr_code", "django_tables2", "django_rubble", + "dbbackup", + "django_filters", + "colorfield", ] LOCAL_APPS = [ @@ -104,6 +145,8 @@ "incredible_data.business", "incredible_data.contacts", "incredible_data.customers", + "incredible_data.fuel", + "incredible_data.mood", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS @@ -127,6 +170,19 @@ # https://docs.djangoproject.com/en/dev/ref/settings/#login-url LOGIN_URL = "account_login" +# STORAGES +# ---------------------------------------------------------------------------- +STORAGES = { + "default": {"BACKEND": "django.core.files.storage.FileSystemStorage"}, + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage" + }, + "dbbackup": { + "BACKEND": "django.core.files.storage.FileSystemStorage", + "OPTIONS": {"location": BASE_DIR / "backup"}, + }, +} + # PASSWORDS # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers @@ -159,6 +215,7 @@ "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.auth.middleware.LoginRequiredMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", "allauth.account.middleware.AccountMiddleware", @@ -207,6 +264,7 @@ "django.template.context_processors.static", "django.template.context_processors.tz", "django.contrib.messages.context_processors.messages", + "incredible_data.helpers.context_processors.version", "incredible_data.users.context_processors.allauth_settings", ], }, @@ -279,6 +337,8 @@ "root": {"level": "INFO", "handlers": ["console"]}, } +logging_obj = Logging.model_validate(LOGGING) + # Celery # ------------------------------------------------------------------------------ if USE_TZ: @@ -317,11 +377,9 @@ # ------------------------------------------------------------------------------ ACCOUNT_ALLOW_REGISTRATION = env.bool("DJANGO_ACCOUNT_ALLOW_REGISTRATION", True) # https://docs.allauth.org/en/latest/account/configuration.html -ACCOUNT_AUTHENTICATION_METHOD = "email" -# https://docs.allauth.org/en/latest/account/configuration.html -ACCOUNT_EMAIL_REQUIRED = True +ACCOUNT_LOGIN_METHODS = {"email"} # https://docs.allauth.org/en/latest/account/configuration.html -ACCOUNT_USERNAME_REQUIRED = False +ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"] # https://docs.allauth.org/en/latest/account/configuration.html ACCOUNT_USER_MODEL_USERNAME_FIELD = None ACCOUNT_USER_DISPLAY = "users.callables.display_name" @@ -350,6 +408,7 @@ ), "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",), "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + "DEFAULT_FILTER_BACKENDS": ("django_filters.rest_framework.DjangoFilterBackend",), } # django-cors-headers - https://github.com/adamchainz/django-cors-headers#setup @@ -357,12 +416,13 @@ # By Default swagger ui is available only to admin user(s). You can change permission classes to change that # See more configuration options at https://drf-spectacular.readthedocs.io/en/latest/settings.html#settings -SPECTACULAR_SETTINGS = { +SPECTACULAR_SETTINGS: dict[str, str | list[str]] = { "TITLE": "Incredible Data API", "DESCRIPTION": "Documentation of API endpoints of Incredible Data", "VERSION": "1.0.0", "SERVE_PERMISSIONS": ["rest_framework.permissions.IsAdminUser"], } +spectacular_settings_obj = Spectacular.model_validate(SPECTACULAR_SETTINGS) # Your stuff... # ------------------------------------------------------------------------------ PHONENUMBER_DEFAULT_REGION = "US" @@ -370,15 +430,41 @@ CURRENCIES = ("USD",) DEFAULT_CURRENCY = "USD" +## Constance + # CONSTANCE_REDIS_CONNECTION = env.cache_url("REDIS_URL") +CONSTANCE_BACKEND = "constance.backends.database.DatabaseBackend" -# CONSTANCE_CONFIG = { -# "AZURE_ENDPOINT": ( -# "Set Azure Endpoint", -# "Azure Endpoint assigned to project for receipt ML.", -# str, -# ), -# "AZURE_KEY": ("must set azure key", "Unique Key for receipt ML", str), +CONSTANCE_CONFIG = { + "MOOD_GRAPH_COLORS": ( + [ + "red", + "blue", + "green", + "yellow", + "purple", + "orange", + "pink", + "brown", + "gray", + "black", + "white", + ], + "Available colors for mood graphs", + list, + ), + # "Morning Start": (dt.time(6, 0), "hello"), + # "Morning End": (dt.time(12, 0), "hello"), + # "Afternoon Start": (dt.time(12, 0), "hello"), + # "Afternoon End": (dt.time(18, 0), "hello"), + # "Evening Start": (dt.time(18, 0), "hello"), + # "Evening End": (dt.time(21, 0), "hello"), +} + +# CONSTANCE_CONFIG_FIELDSETS = { +# "Morning": ("Morning Start", "Morning End"), +# "Afternoon": ("Afternoon Start", "Afternoon End"), +# "Evening": ("Evening Start", "Evening End"), # } AZURE_ENDPOINT = env.str("DJANGO_AZURE_ENDPOINT", "") @@ -386,3 +472,10 @@ # equivalent to `[2-9A-HJ-NP-Z]` in regex, `I`, `O`, `1`, `0` are excluded SHORTUUID_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ" + +DJANGO_TABLES2_TABLE_ATTRS = { + "class": "table table-hover", + "thead": { + "class": "table-light", + }, +} diff --git a/config/settings/drf_models.py b/config/settings/drf_models.py new file mode 100644 index 0000000..df1b373 --- /dev/null +++ b/config/settings/drf_models.py @@ -0,0 +1,27 @@ +from typing import ClassVar + +from attrmagic import ClassBase, SimpleListRoot +from pydantic import AnyUrl, ConfigDict + +from .settings_models import SettingsModel + + +class DRFBase(SettingsModel): + model_config: ConfigDict = ConfigDict(alias_generator=lambda s: s.upper()) # pyright: ignore[reportIncompatibleVariableOverride] + + +class Server(ClassBase): + url: AnyUrl + description: str | None = None + + +class Servers(SimpleListRoot[Server]): ... + + +class Spectacular(DRFBase): + name: ClassVar[str] = "SPECTACULAR_SETTINGS" + title: str + description: str + version: str + serve_permissions: list[str] + servers: Servers = Servers(root=[]) diff --git a/config/settings/local.py b/config/settings/local.py index b31e136..eb4e57a 100644 --- a/config/settings/local.py +++ b/config/settings/local.py @@ -1,11 +1,20 @@ -# ruff: noqa: E501 +# ruff: noqa: ERA001, E501 +# pyright: reportConstantRedefinition=false +from loguru import logger + +from config.settings.settings_models import Levels + from .base import * # noqa: F403 -from .base import INSTALLED_APPS, MIDDLEWARE, env +from .base import DEBUG, INSTALLED_APPS, MIDDLEWARE, env, logging_obj # GENERAL # ------------------------------------------------------------------------------ # https://docs.djangoproject.com/en/dev/ref/settings/#debug -DEBUG = True +if not DEBUG: + logger.warning( + "You are running with DEBUG=False in local.py. This is not recommended for local development. Please set DEBUG=True in local.py." + ) + # https://docs.djangoproject.com/en/dev/ref/settings/#secret-key SECRET_KEY = env( "DJANGO_SECRET_KEY", @@ -67,7 +76,13 @@ CELERY_TASK_EAGER_PROPAGATES = True # Your stuff... # ------------------------------------------------------------------------------ +logging_obj.handlers["console"].level = Levels.DEBUG if DEBUG else Levels.INFO +logging_obj.root.level = Levels.DEBUG if DEBUG else Levels.WARNING +LOGGING = logging_obj.render() + +use_bridge = env.bool("DJANGO_USE_BRIDGE", default=False) -from bridge import django # noqa: E402 +if use_bridge: + from bridge import django # pyright: ignore[reportMissingTypeStubs] -django.configure(locals()) + django.configure(locals()) diff --git a/config/settings/production.py b/config/settings/production.py index d185cb8..06dc440 100644 --- a/config/settings/production.py +++ b/config/settings/production.py @@ -1,5 +1,8 @@ -# ruff: noqa: E501 +# ruff: noqa: ERA001, E501 +# pyright: reportConstantRedefinition=false, reportUnknownVariableType=false +import contextlib import logging +from typing import TYPE_CHECKING, cast import sentry_sdk from sentry_sdk.integrations.celery import CeleryIntegration @@ -7,8 +10,21 @@ from sentry_sdk.integrations.logging import LoggingIntegration from sentry_sdk.integrations.redis import RedisIntegration +from config.settings.drf_models import Server +from config.settings.settings_models import Levels + from .base import * # noqa: F403 -from .base import DATABASES, INSTALLED_APPS, SPECTACULAR_SETTINGS, env +from .base import ( + DATABASES, + DEBUG, + INSTALLED_APPS, + env, + logging_obj, + spectacular_settings_obj, +) + +if TYPE_CHECKING: + from urllib.parse import ParseResult # GENERAL # ------------------------------------------------------------------------------ @@ -17,11 +33,11 @@ # https://docs.djangoproject.com/en/dev/ref/settings/#allowed-hosts ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS", default=["example.com"]) -ALLOWED_HOSTS += env.url("RENDER_EXTERNAL_HOSTNAME", default="") +ALLOWED_HOSTS += env.url("RENDER_EXTERNAL_HOSTNAME", default="") # pyright: ignore[reportUnknownMemberType] # DATABASES # ------------------------------------------------------------------------------ -DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60) +DATABASES["default"]["CONN_MAX_AGE"] = env.int("CONN_MAX_AGE", default=60) # pyright: ignore[reportUnknownMemberType] # CACHES # ------------------------------------------------------------------------------ @@ -170,10 +186,17 @@ }, } +# allauth +# ------------------------------------------------------------------------------ +with contextlib.suppress(ValueError): + SOCIALACCOUNT_PROVIDERS = env.parse_value( # pyright: ignore[reportUnknownMemberType] + "DJANGO_SOCIALACCOUNT_PROVIDERS", cast=dict + ) + # Sentry # ------------------------------------------------------------------------------ -SENTRY_DSN = env("SENTRY_DSN") -SENTRY_LOG_LEVEL = env.int("DJANGO_SENTRY_LOG_LEVEL", logging.INFO) +SENTRY_DSN = cast("str | None", env("SENTRY_DSN")) +SENTRY_LOG_LEVEL = cast("int", env.int("DJANGO_SENTRY_LOG_LEVEL", logging.INFO)) # pyright: ignore[reportUnknownMemberType] sentry_logging = LoggingIntegration( level=SENTRY_LOG_LEVEL, # Capture info and above as breadcrumbs @@ -185,21 +208,34 @@ CeleryIntegration(), RedisIntegration(), ] -sentry_sdk.init( +_ = sentry_sdk.init( dsn=SENTRY_DSN, integrations=integrations, - environment=env("SENTRY_ENVIRONMENT", default="production"), - traces_sample_rate=env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.0), - profiles_sample_rate=env.float("SENTRY_PROFILES_SAMPLE_RATE", default=0.0), + environment=cast("str | None", env("SENTRY_ENVIRONMENT", default="production")), + traces_sample_rate=cast( + "float", + env.float("SENTRY_TRACES_SAMPLE_RATE", default=0.0), # pyright: ignore[reportUnknownMemberType] + ), + profiles_sample_rate=cast( + "float", + env.float("SENTRY_PROFILES_SAMPLE_RATE", default=0.0), # pyright: ignore[reportUnknownMemberType] + ), +) + +PRODUCTION_URL = cast( + "ParseResult", + env.url("EXTERNAL_HOSTNAME", default="https://data.wooster.xyz"), # pyright: ignore[reportUnknownMemberType] ) -PRODUCTION_URL = env.url("EXTERNAL_HOSTNAME", default="https://data.wooster.xyz") # django-rest-framework # ------------------------------------------------------------------------------- # Tools that generate code samples can use SERVERS to point to the correct domain -SPECTACULAR_SETTINGS["SERVERS"] = [ - {"url": PRODUCTION_URL, "description": "Production server"}, -] +prod_server = Server(url=PRODUCTION_URL.geturl(), description="Production server") # pyright: ignore[reportArgumentType] +spectacular_settings_obj.servers.append(prod_server) +SPECTACULAR_SETTINGS = spectacular_settings_obj.render() # Your stuff... # ------------------------------------------------------------------------------ +logging_obj.handlers["console"].level = Levels.DEBUG if DEBUG else Levels.INFO +logging_obj.root.level = Levels.DEBUG if DEBUG else Levels.WARNING +LOGGING = logging_obj.render() diff --git a/config/settings/settings_models.py b/config/settings/settings_models.py new file mode 100644 index 0000000..899518b --- /dev/null +++ b/config/settings/settings_models.py @@ -0,0 +1,199 @@ +import abc +from enum import StrEnum +from typing import Annotated, Any, ClassVar + +from attrmagic import ClassBase, SimpleDict +from attrmagic.sentinels import MISSING, Missing +from loguru import logger +from pydantic import ConfigDict, Field, SecretStr, field_validator, model_serializer + + +def serialize_missing(self: ClassBase) -> dict[str, Any]: # pyright: ignore[reportExplicitAny] + return { + k: v + for k, v in self.model_dump(by_alias=True).items() # pyright: ignore[reportAny] + if v is not MISSING + } + + +class SettingsModel(ClassBase, abc.ABC): + name: ClassVar[str] + + def render( + self, *, by_alias: bool = True, exclude_none: bool = True + ) -> dict[str, int | bool | dict[str, dict[str, str]] | dict[str, str | list[str]]]: + """ + Render the settings to a format suitable for Django's LOGGING configuration. + """ + return self.model_dump(by_alias=by_alias, exclude_none=exclude_none) + + +class Levels(StrEnum): + NOTSET = "NOTSET" + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + +class Formatter(ClassBase): + format: str + + +class Formatters(SimpleDict[str, Formatter]): + pass + + +class Handler(ClassBase): + level: Levels = Levels.DEBUG + class_name: Annotated[str, Field(alias="class")] + formatter: str + + +class Handlers(SimpleDict[str, Handler]): + pass + + +class RootLogger(ClassBase): + level: Levels = Levels.INFO + handlers: list[str] + + +class Logging(SettingsModel): + name: ClassVar[str] = "LOGGING" + version: int = 1 + disable_existing_loggers: bool + formatters: Formatters + handlers: Handlers + root: RootLogger + + +class BuiltInEngine(StrEnum): + POSTGRESQL = "django.db.backends.postgresql" + MYSQL = "django.db.backends.mysql" + SQLITE = "django.db.backends.sqlite3" + ORACLE = "django.db.backends.oracle" + + +class TestDatabase(ClassBase): + model_config: ConfigDict = ConfigDict(alias_generator=lambda s: s.upper()) # pyright: ignore[reportIncompatibleVariableOverride] + name: str | Missing = MISSING + charset: str | Missing = MISSING + collation: str | Missing = MISSING + migrate: bool | Missing = MISSING + mirror: str | Missing = MISSING + template: str | Missing = MISSING + create_db: bool | Missing = MISSING + create_user: bool | Missing = MISSING + user: str | Missing = MISSING + password: SecretStr | Missing = MISSING + + @model_serializer + def serialize_missing(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny] + return serialize_missing(self) + + +class DatabaseOptions(ClassBase): + engine: ClassVar[BuiltInEngine | str] + + +class Database(ClassBase): + model_config: ConfigDict = ConfigDict(alias_generator=lambda s: s.upper()) # pyright: ignore[reportIncompatibleVariableOverride] + atomic_requests: bool | Missing = MISSING + autocommit: bool | Missing = MISSING + engine: BuiltInEngine | str | Missing = MISSING + host: str | Missing = MISSING + name: str | Missing = MISSING + conn_max_age: int | None | Missing = MISSING + conn_health_checks: bool | Missing = MISSING + options: DatabaseOptions | Missing = MISSING + password: SecretStr | Missing = MISSING + port: Annotated[int, Field(ge=0, le=65535)] | Missing = MISSING + time_zone: str | Missing = MISSING + disable_server_side_cursors: bool | Missing = MISSING + user: str | Missing = MISSING + test: TestDatabase | Missing = MISSING + + @field_validator("engine", mode="before") + @classmethod + def validate_builtin_engine(cls, v: Any) -> Any: # pyright: ignore[reportAny, reportExplicitAny] + try: + return BuiltInEngine(v) + except KeyError: + logger.warning(f"Unknown database engine: {v}") + + return v # pyright: ignore[reportAny] + + @field_validator("port", mode="before") + @classmethod + def validate_empty_port(cls, v: Any) -> Any: # pyright: ignore[reportAny, reportExplicitAny] + if v in ("",): + return MISSING + return v # pyright: ignore[reportAny] + + @model_serializer + def serialize_missing(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny] + output: dict[str, Any] = {} # pyright: ignore[reportExplicitAny] + cls = self.__class__ + for key, value in self: # pyright: ignore[reportAny] + if (value_mut := value) is MISSING: # pyright: ignore[reportAny] + continue + key_alias = cls.model_fields[key].alias or key + if isinstance(value, SecretStr): + value_mut = value.get_secret_value() + output[key_alias] = value_mut + + return output + + @property + def engine_name(self) -> str | None: + try: + return self.engine.name # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType, reportAttributeAccessIssue] + + except AttributeError: + if self.engine is MISSING: + return None + return self.engine + + +class Databases(SimpleDict[str, Database]): + def render(self) -> dict[str, dict[str, Any]]: # pyright: ignore[reportExplicitAny] + """ + Render the databases to a format suitable for Django's DATABASES setting. + """ + return {db_name: db.model_dump(by_alias=True) for db_name, db in self.items()} + + @property + def default(self) -> Database: + assert "default" in self.root + return self["default"] + + +if __name__ == "__main__": + # This block is for testing purposes only + from rich import print as rprint + + logging_example = Logging( + disable_existing_loggers=False, + formatters=Formatters( + root={ + "example_formatter": Formatter( + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + }, + ), + handlers=Handlers( + root={ + "console": Handler( + level=Levels.DEBUG, + class_name="logging.StreamHandler", + formatter="example_formatter", + ) + } + ), + root=RootLogger(level=Levels.INFO, handlers=["console"]), + ) + rprint(logging_example.model_dump(by_alias=True)) + + logging_example.handlers["console"].level = Levels.ERROR diff --git a/config/urls.py b/config/urls.py index d5d5880..d4d077e 100644 --- a/config/urls.py +++ b/config/urls.py @@ -1,50 +1,57 @@ -# ruff: noqa +# ruff: noqa: ERA001 +import logging +from typing import TYPE_CHECKING + from django.conf import settings from django.conf.urls.static import static from django.contrib import admin + +# TODO: basedpyright not seeing login_not_required +from django.contrib.auth.decorators import ( + login_not_required, # pyright: ignore[reportAttributeAccessIssue, reportUnknownVariableType] +) from django.contrib.staticfiles.urls import staticfiles_urlpatterns -from django.urls import include -from django.urls import path +from django.urls import include, path from django.views import defaults as default_views from django.views.generic import TemplateView -from drf_spectacular.views import SpectacularAPIView -from drf_spectacular.views import SpectacularSwaggerView +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from rest_framework.authtoken.views import obtain_auth_token -from neapolitan.views import Role -from incredible_data.business.views import ( - InvoiceListView, - InvoiceView, - ProjectListView, - ProjectView, - printable_invoice, -) +if TYPE_CHECKING: + from django.urls.resolvers import URLPattern, URLResolver + +logger = logging.getLogger(__name__) -urlpatterns = [ - path("", TemplateView.as_view(template_name="pages/home.html"), name="home"), - path( - "about/", - TemplateView.as_view(template_name="pages/about.html"), - name="about", - ), +# fmt: off +urlpatterns: "list[URLPattern | URLResolver]" = [ + path("", login_not_required(TemplateView.as_view(template_name="pages/home.html")), name="home"), # pyright: ignore[reportUnknownArgumentType] + path("about/", TemplateView.as_view(template_name="pages/about.html"), name="about"), # path("grappelli/", include("grappelli.urls")), # Django Admin, use {% url 'admin:index' %} - path(settings.ADMIN_URL, admin.site.urls), + path(settings.ADMIN_URL, admin.site.urls), # pyright: ignore[reportAny] # User management path("users/", include("incredible_data.users.urls", namespace="users")), path("accounts/", include("allauth.urls")), # Your stuff: custom urls includes go here # path("business/", include("incredible_data.business.urls", namespace="business")), path("budget/", include("incredible_data.budget.budget_urls", namespace="budget")), + path("business/", include("incredible_data.business.urls", namespace="business")), path("bins/", include("incredible_data.bins.urls", namespace="bins")), + path("customers/", include("incredible_data.customers.urls", namespace="customers")), + path("mood/", include("incredible_data.mood.urls", namespace="mood")), path("qr_code/", include("qr_code.urls", namespace="qr_code")), # Media files - *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT), + *static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT), # pyright: ignore[reportAny] ] -if settings.DEBUG: +if settings.DEBUG: # pyright: ignore[reportAny] # Static file serving when using Gunicorn + Uvicorn for local web socket development urlpatterns += staticfiles_urlpatterns() +# Select2 URLS +urlpatterns += [ + path("select2/", include("django_select2.urls")), +] + # API URLS urlpatterns += [ # API base url @@ -52,45 +59,25 @@ # DRF auth token path("auth-token/", obtain_auth_token), path("api/schema/", SpectacularAPIView.as_view(), name="api-schema"), - path( - "api/docs/", - SpectacularSwaggerView.as_view(url_name="api-schema"), - name="api-docs", - ), + path("api/docs/", SpectacularSwaggerView.as_view(url_name="api-schema"), name="api-docs"), ] -if settings.DEBUG: +if settings.DEBUG: # pyright: ignore[reportAny] # This allows the error pages to be debugged during development, just visit # these url in browser to see how these error pages look like. urlpatterns += [ - path( - "400/", - default_views.bad_request, - kwargs={"exception": Exception("Bad Request!")}, - ), - path( - "403/", - default_views.permission_denied, - kwargs={"exception": Exception("Permission Denied")}, - ), - path( - "404/", - default_views.page_not_found, - kwargs={"exception": Exception("Page not Found")}, - ), + path("400/", default_views.bad_request, kwargs={"exception": Exception("Bad Request!")}), + path("403/", default_views.permission_denied, kwargs={"exception": Exception("Permission Denied")}), + path("404/", default_views.page_not_found, kwargs={"exception": Exception("Page not Found")}), path("500/", default_views.server_error), ] - if "debug_toolbar" in settings.INSTALLED_APPS: - import debug_toolbar + if "debug_toolbar" in settings.INSTALLED_APPS: # pyright: ignore[reportAny] + import debug_toolbar # pyright: ignore[reportMissingTypeStubs] - urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns + urlpatterns = [path("__debug__/", include(debug_toolbar.urls)), *urlpatterns] + +neapolitan_urlpatterns: "list[URLPattern | URLResolver]" = [] -neapolitan_urlpatterns = [ - *ProjectView.get_urls(roles=[Role.CREATE, Role.DELETE, Role.DETAIL, Role.LIST]), - # path("project/", ProjectListView.as_view(), name="project-list"), - path("invoice/", InvoiceListView.as_view(), name="invoice-create"), - *InvoiceView.get_urls(roles=[Role.CREATE, Role.UPDATE, Role.DETAIL, Role.DELETE]), - path("invoice//printable/", printable_invoice, name="invoice-print"), -] urlpatterns.extend(neapolitan_urlpatterns) +# fmt:on diff --git a/devtools/__init__.py b/devtools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/devtools/bump_build.py b/devtools/bump_build.py new file mode 100644 index 0000000..0e7841f --- /dev/null +++ b/devtools/bump_build.py @@ -0,0 +1,108 @@ +import re +import subprocess +from enum import StrEnum +from pathlib import Path +from typing import ClassVar, Self + +from pydantic import BaseModel + +PROJECT_BASE_PATH = Path(__file__).parent.parent / "incredible_data" + +VERSION_FILE = PROJECT_BASE_PATH / "__init__.py" +BUILD_INFO_FILE = PROJECT_BASE_PATH / "build_info.py" + +assert VERSION_FILE.exists(), "Version file does not exist, check the path" + + +def bump_build(): + text = VERSION_FILE.read_text() + value_pattern = r"__build__\s*=\s*(\d+)" + match = re.search(value_pattern, text) + + if not match: + msg = "Could not find __build__ in version file" + raise ValueError(msg) + + build_number = int(match.group(1)) + 1 + + replace_pattern = r"(__build__\s*=\s*)(\d+)" + new_text = re.sub(replace_pattern, rf"\1{build_number}", text) + + _ = VERSION_FILE.write_text(new_text) + + +class GitCommand(StrEnum): + REV_LIST = "rev-list" + REV_PARSE = "rev-parse" + + +def git_output(command: GitCommand, *options: str, commit: str = "HEAD") -> str: + result = subprocess.check_output( # noqa: S603 + ["git", command, *options, commit], # noqa: S607 + stderr=subprocess.DEVNULL, + ) + return result.decode("utf-8").strip() + + +class GitInfo(BaseModel): + commit_count: int + commit_hash: str + + build_number_variable_name: ClassVar[str] = "BUILD_NUMBER" + commit_hash_variable_name: ClassVar[str] = "COMMIT_HASH" + + @classmethod + def get_info(cls) -> Self: + try: + commit_count = git_output(GitCommand.REV_LIST, "--count") + commit_hash = git_output(GitCommand.REV_PARSE, "--short") + return cls(commit_count=commit_count, commit_hash=commit_hash) # pyright: ignore[reportArgumentType] + except subprocess.CalledProcessError: + msg = "Failed to get git info, is git installed?" + raise RuntimeError(msg) from None + + def write_file(self, file_path: Path) -> None: + content = ( + f'BUILD_NUMBER = {self.commit_count}\nCOMMIT_HASH = "{self.commit_hash}"\n' + ) + _ = file_path.write_text(content) + + @classmethod + def _build_build_number_pattern(cls) -> "re.Pattern[str]": + return re.compile(rf"{cls.build_number_variable_name}\s*=\s*(\d+)") + + @classmethod + def _build_commit_hash_pattern(cls) -> "re.Pattern[str]": + return re.compile(rf"{cls.commit_hash_variable_name}\s*=\s*\"([0-9a-f]+)\"") + + @classmethod + def from_file(cls, file_path: Path) -> Self: + content = file_path.read_text() + build_number_pattern = cls._build_build_number_pattern() + build_number_matches = build_number_pattern.search(content) + commit_hash_pattern = cls._build_commit_hash_pattern() + commit_hash_matches = commit_hash_pattern.search(content) + + if not build_number_matches or not commit_hash_matches: + msg = "Failed to parse build info file" + raise ValueError(msg) + + build_number = int(build_number_matches.group(1)) + commit_hash = commit_hash_matches.group(1) + + return cls(commit_count=build_number, commit_hash=commit_hash) + + +def _stage_file(file_path: Path) -> None: + file_path_str = str(file_path) + _ = subprocess.run(["git", "add", file_path_str], check=False) # noqa: S603, S607 + + +def update_build_info(): + git_info = GitInfo.get_info() + git_info.write_file(BUILD_INFO_FILE) + _stage_file(BUILD_INFO_FILE) + + +if __name__ == "__main__": + update_build_info() diff --git a/extract_ruff.py b/extract_ruff.py new file mode 100644 index 0000000..4314bb8 --- /dev/null +++ b/extract_ruff.py @@ -0,0 +1,190 @@ +from pathlib import Path +from re import Pattern +from typing import TYPE_CHECKING, Annotated, Literal + +from caseconverter import kebabcase +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ImportString, + PlainSerializer, + RootModel, + StringConstraints, +) +from pydantic_settings import ( + BaseSettings, + PyprojectTomlConfigSettingsSource, + SettingsConfigDict, +) + +if TYPE_CHECKING: + from pydantic_settings import PydanticBaseSettingsSource +import sys + +import toml +from rich.theme import Theme + +script_theme = Theme( + { + "linkish": "bright_magenta", + } +) + +# Define file paths +pyproject_path = Path("pyproject.toml") +ruff_path = Path("ruff.toml") + +PathStr = Annotated[ + Path, + PlainSerializer(lambda x: str(x.as_posix()), return_type=str, when_used="json"), +] + +RuleSelector = Annotated[ + str, StringConstraints(min_length=1, pattern=r"^[A-Z][A-Z]*\d*$") +] + +RuleList = Annotated[ + list[RuleSelector], PlainSerializer(lambda x: sorted(x), return_type=list) +] + + +class BaseConfig(BaseModel): + model_config = ConfigDict(alias_generator=kebabcase, extra="forbid") + + +class AnalyzeConfig(BaseConfig): + detect_string_imports: bool | None = None + direction: Literal["dependents", "dependencies"] | None = None + + +class FormatConfig(BaseConfig): + docstring_code_format: bool | None = None + docstring_code_line_length: int | Literal["dynamic"] | None = None + exclude: list[PathStr] | None = None + indent_style: Literal["space", "tab"] | None = None + line_ending: Literal["auto", "lf", "cr-lf", "native"] | None = None + preview: bool | None = None + quote_style: Literal["single", "double"] | None = None + skip_magic_trailing_comma: bool | None = None + + +class LintExtendPerFileIgnoresConfig(RootModel[dict[PathStr, list[str]]]): + pass + + +class LintPerFileIgnoresConfig(RootModel[dict[PathStr, list[str]]]): + pass + + +class LintIsortConfig(BaseConfig): + model_config = ConfigDict(extra="ignore") + force_single_line: bool | None = None + + +class LintFlake8PytestStyleConfig(BaseConfig): + fixture_parentheses: bool | None = None + + +class LintConfig(BaseConfig): + model_config = ConfigDict(extra="ignore") + allowed_confusables: list[str] | None = None + dummy_variable_rgx: Pattern | None = None + exclude: list[PathStr] | None = None + explicit_preview_rules: bool | None = None + extend_fixable: RuleList | None = None + extend_ignore: ( + Annotated[RuleList, Field(deprecated="Interchangeable with `ignore`")] | None + ) = None + extend_per_file_ignores: LintExtendPerFileIgnoresConfig | None = None + extend_safe_fixes: RuleList | None = None + extend_select: RuleList | None = None + extend_unsafe_fixes: RuleList | None = None + external: RuleList | None = None + fixable: Literal["ALL"] | RuleList | None = None + ignore: RuleList | None = None + ignore_init_module_imports: bool | None = None + logger_objects: list[ImportString] | None = None + per_file_ignores: LintExtendPerFileIgnoresConfig | None = None + preview: bool | None = None + select: RuleList | None = None + task_tags: list[str] | None = None + typing_modules: list[ImportString] | None = None + unfixable: RuleList | None = None + flake8_pytest_style: LintFlake8PytestStyleConfig | None = None + isort: LintIsortConfig | None = None + + +class RuffConfig(BaseSettings): + model_config = SettingsConfigDict( + pyproject_toml_table_header=("tool", "ruff"), + extra="ignore", + alias_generator=kebabcase, + ) + + builtins: list[PathStr] | None = None + cache_dir: PathStr | None = None + exclude: list[PathStr] | None = None + extend: PathStr | None = None + extend_exclude: list[PathStr] | None = None + extend_include: list[PathStr] | None = None + fix: bool | None = None + fix_only: bool | None = None + force_exclude: bool | None = None + include: list[PathStr] | None = None + indent_width: int | None = None + line_length: int | None = None + namespace_packages: list[PathStr] | None = None + output_format: str | None = None + preview: bool | None = None + required_version: str | None = None + respect_gitignore: bool | None = None + show_fixes: bool | None = None + src: list[PathStr] | None = None + target_version: str | None = None + unsafe_fixes: bool | None = None + analyze: AnalyzeConfig | None = None + # TODO: Add more fields as needed + format: FormatConfig | None = None + lint: LintConfig | None = None + + def model_dump_toml( + self, *, by_alias: bool = True, exclude_none: bool = True, **kwargs + ): + as_dict = self.model_dump( + mode="json", by_alias=by_alias, exclude_none=exclude_none, **kwargs + ) + return toml.dumps(as_dict) + + def write_file(self, file_path: Path = ruff_path): + with file_path.open("w") as file: + toml.dump( + self.model_dump(mode="json", by_alias=True, exclude_none=True), file + ) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: "PydanticBaseSettingsSource", + env_settings: "PydanticBaseSettingsSource", + dotenv_settings: "PydanticBaseSettingsSource", + file_secret_settings: "PydanticBaseSettingsSource", + ) -> tuple["PydanticBaseSettingsSource", ...]: + return (PyprojectTomlConfigSettingsSource(settings_cls),) + + +if __name__ == "__main__": + from rich.console import Console + + console = Console(theme=script_theme) + + if not pyproject_path.exists(): + console.print(f"[linkish]{pyproject_path.name}[/] not found!") + sys.exit(1) + + ruff_config = RuffConfig() + + console.print(ruff_config.model_dump_toml()) + + # ruff_config.write_file() # noqa: ERA001 diff --git a/incredible_data/__init__.py b/incredible_data/__init__.py index cfc890a..64cff8c 100644 --- a/incredible_data/__init__.py +++ b/incredible_data/__init__.py @@ -1,4 +1,4 @@ -__version__ = "0.6.0" +__version__ = "0.7.0b2" __version_info__ = tuple( int(num) if num.isdigit() else num for num in __version__.replace("-", ".", 1).split(".") diff --git a/incredible_data/budget/models.py b/incredible_data/budget/models.py index 33551b7..e166dc9 100644 --- a/incredible_data/budget/models.py +++ b/incredible_data/budget/models.py @@ -1,18 +1,24 @@ import logging from pathlib import Path +from typing import cast from django.conf import settings from django.core.validators import FileExtensionValidator from django.db import models +from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import AutoSlugField, ShortUUIDField -from django_extensions.db.models import TitleSlugDescriptionModel -from django_rubble.fields.db_fields import SimplePercentageField +from django_extensions.db.models import ( # pyright: ignore[reportMissingTypeStubs] + TitleSlugDescriptionModel, +) +from django_rubble.fields.db_fields import ( # pyright: ignore[reportMissingTypeStubs] + SimplePercentageField, +) from djmoney.models.fields import MoneyField logger = logging.getLogger(__name__) -if settings.DEBUG: +if cast("bool", settings.DEBUG): logger.setLevel(logging.DEBUG) else: logger.setLevel(logging.INFO) @@ -37,8 +43,6 @@ class Meta: abstract = True def get_absolute_url(self): - from django.urls import reverse - viewname = self.reverse_viewname reverse_kwargs = self.reverse_kwargs @@ -50,7 +54,7 @@ def get_absolute_url(self): def uploaded_receipt_path(instance: models.Model, filename: str) -> str: filename_path = Path(filename) datetime_now = timezone.now() - return f"upload/receipts/{datetime_now:%Y}/{datetime_now:%m}/uploaded_receipt_{datetime_now:%Y%m%d-%H%M%S}{filename_path.suffix}" # noqa: E501 + return f"upload/receipts/{datetime_now:%Y}/{datetime_now:%m}/uploaded_receipt_{datetime_now:%Y%m%d-%H%M%S}{filename_path.suffix}" class Merchant(TitleSlugDescriptionModel): @@ -139,7 +143,7 @@ class Receipt(models.Model): def __str__(self) -> str: if self.merchant is not None and self.transaction_date is not None: - return f"Receipt({self.merchant} - {self.transaction_date} - {self.grand_total})" # noqa: E501 + return f"Receipt({self.merchant} - {self.transaction_date} - {self.grand_total})" return f"uploaded file: {self.receipt_file}" @@ -164,5 +168,5 @@ class ReceiptItem(models.Model): def __str__(self) -> str: if self.product_code is not None or self.description is not None: - return f"Item({self.description or '(no description)'}, code={self.product_code or '(no UPC)'}, price={self.total_price})" # noqa: E501 + return f"Item({self.description or '(no description)'}, code={self.product_code or '(no UPC)'}, price={self.total_price})" return f"unknown item, price={self.total_price}" diff --git a/incredible_data/budget/tests/test_receipts.py b/incredible_data/budget/tests/test_receipts.py index 7ae0aff..d2861ba 100644 --- a/incredible_data/budget/tests/test_receipts.py +++ b/incredible_data/budget/tests/test_receipts.py @@ -2,6 +2,7 @@ import logging from datetime import datetime as dt from pathlib import Path +from typing import Any, cast import pytest from django.conf import settings @@ -10,10 +11,10 @@ from factory.django import DjangoModelFactory from factory.fuzzy import FuzzyDateTime -from incredible_data.budget.models import ReceiptItem +from incredible_data.budget.models import ReceiptFile, ReceiptItem from incredible_data.budget.receipt_services import create_receipt -APPS_DIR = settings.APPS_DIR +APPS_DIR = cast("str", settings.APPS_DIR) logger = logging.getLogger(__name__) @@ -22,33 +23,34 @@ def get_example_json() -> str: json_file_path = Path( APPS_DIR, "budget", "fixtures", "budget", "example_receipt_result.json" ) - with Path.open(json_file_path, "rb") as f: - analysis_dict: dict = json.load(f) + with json_file_path.open("rb") as f: + analysis_dict: dict[str, Any] = json.load(f) # pyright: ignore[reportExplicitAny, reportAny] analyze_result_dict = analysis_dict.get("analyzeResult") return json.dumps(analyze_result_dict) -class ReceiptFileFactory(DjangoModelFactory): - class Meta: - model = "budget.ReceiptFile" +class ReceiptFileFactory(DjangoModelFactory[ReceiptFile]): + class Meta: # pyright: ignore[reportIncompatibleVariableOverride] + model: str = "budget.ReceiptFile" - file = SimpleUploadedFile( + file: SimpleUploadedFile = SimpleUploadedFile( "test_receipt.pdf", b"test file content", content_type="application/pdf" ) - analyze_result = get_example_json() - analyzed_datetime = FuzzyDateTime( + analyze_result: str = get_example_json() + analyzed_datetime: FuzzyDateTime = FuzzyDateTime( dt(2024, 1, 1, 1, 24, tzinfo=get_current_timezone()) ) -@pytest.mark.django_db() +@pytest.mark.django_db class TestReceipts: + @pytest.mark.skip("need to mock azure calls") def test_receipt_object_creation(self): receipt_file = ReceiptFileFactory() - receipt_obj, created = create_receipt(receipt_file) + receipt_obj, _created = create_receipt(receipt_file) receiptitem_qs = ReceiptItem.objects.filter( parent_receipt=receipt_obj @@ -57,4 +59,6 @@ def test_receipt_object_creation(self): receipt_item_count = 3 cheapest_item_description = "Beer" assert len(receiptitem_qs) == receipt_item_count - assert receiptitem_qs.first().description == cheapest_item_description + first_receipt = receiptitem_qs.first() + assert first_receipt is not None + assert first_receipt.description == cheapest_item_description diff --git a/incredible_data/build_info.py b/incredible_data/build_info.py new file mode 100644 index 0000000..a2c4a2f --- /dev/null +++ b/incredible_data/build_info.py @@ -0,0 +1,2 @@ +BUILD_NUMBER = 228 +COMMIT_HASH = "e826c52" diff --git a/incredible_data/business/admin.py b/incredible_data/business/admin.py index b70465a..31a340f 100644 --- a/incredible_data/business/admin.py +++ b/incredible_data/business/admin.py @@ -33,6 +33,14 @@ class InvoiceAdmin(StampedAdmin): inlines = [InvoiceLineInline] +@admin.register(InvoiceLine) +class InvoiceLineAdmin(admin.ModelAdmin): + list_display = ["rank", "description", "quantity", "unit_price", "extended_price"] + readonly_fields = ["extended_price"] + search_fields = ["description", "invoice__customer"] + list_filter = ["invoice__customer"] + + @admin.register(Project) class ProjectAdmin(UserStampedModelAdmin): list_display = ["number", "customer", "name"] diff --git a/incredible_data/business/forms/business_accounting_forms.py b/incredible_data/business/forms/business_accounting_forms.py index b23c6bc..bb66301 100644 --- a/incredible_data/business/forms/business_accounting_forms.py +++ b/incredible_data/business/forms/business_accounting_forms.py @@ -1,11 +1,26 @@ +# pyright: reportMissingTypeArgument=false + +import logging +from typing import TYPE_CHECKING, Any, cast, final, override + from crispy_forms.helper import FormHelper -from crispy_forms.layout import Submit +from crispy_forms.layout import Field, Layout, Row, Submit from django import forms +from django.utils.translation import gettext_lazy as _ +from django_select2.forms import ModelSelect2Widget + +from incredible_data.business.models.business_accounting_models import Invoice, Order +from incredible_data.business.models.business_project_models import Project + +if TYPE_CHECKING: + from incredible_data.customers.models import Customer -from incredible_data.business.models.business_accounting_models import Invoice +logger = logging.getLogger(__name__) +@final class InvoiceForm(forms.ModelForm): + @final class Meta: model = Invoice fields = [ @@ -18,10 +33,105 @@ class Meta: "order", ] - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def __init__(self, *args, **kwargs): # pyright: ignore[reportMissingParameterType, reportUnknownParameterType] + super().__init__(*args, **kwargs) # pyright: ignore[reportUnknownArgumentType] self.helper = FormHelper() self.helper.form_class = "blueForms" self.helper.form_method = "post" self.helper.add_input(Submit("submit", "Submit")) + + +@final +class CustomerWidget(ModelSelect2Widget): + search_fields = ["name__icontains"] + # TODO: Add queryset to select customer for better performance + + +@final +class OrderForm(forms.ModelForm): + @final + class Meta: + model = Order + fields = ["customer", "expected_date", "notes", "created_by", "modified_by"] + widgets = { + "customer": CustomerWidget( + attrs={ + "data-minimum-input-length": 0, + "data-placeholder": "Select a Customer", + }, + ), + "expected_date": forms.DateInput(attrs={"type": "date"}), + } + + def __init__(self, *args, **kwargs) -> None: # pyright: ignore[reportUnknownParameterType, reportMissingParameterType] + super().__init__(*args, **kwargs) # pyright: ignore[reportUnknownArgumentType] + self.helper = FormHelper() + self.helper.form_class = "blueForms" + self.helper.form_method = "post" + self.helper.layout = Layout( + "customer", + "expected_date", + "notes", + Field("created_by", type="hidden"), + Field("modified_by", type="hidden"), + ) + + self.helper.add_input(Submit("submit", _("Submit"))) + + +@final +class OrderWidget(ModelSelect2Widget): + search_fields = ["customer__name__icontains"] + # TODO: Add queryset to select customer for better performance + + +@final +class ProjectForm(forms.ModelForm): + @final + class Meta: + model = Project + fields = ["name", "customer", "order", "notes", "created_by", "modified_by"] + widgets = { + "customer": CustomerWidget( + attrs={ + "data-minimum-input-length": 0, + "data-placeholder": "Select a Customer", + }, + ), + "order": OrderWidget( + dependent_fields={"customer": "customer"}, + attrs={ + "data-minimum-input-length": 0, + "data-placeholder": "Select an Order", + }, + ), + } + + def __init__(self, *args, **kwargs): # pyright: ignore[reportUnknownParameterType, reportMissingParameterType] + super().__init__(*args, **kwargs) # pyright: ignore[reportUnknownArgumentType] + + self.helper = FormHelper() + self.helper.form_class = "blueForms" + self.helper.form_method = "post" + self.helper.layout = Layout( + "name", + Row("customer", "order"), + "notes", + Field("created_by", type="hidden"), + Field("modified_by", type="hidden"), + ) + + self.helper.add_input(Submit("submit", _("Submit"))) + + @override + def clean(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny] + cleaned_data = super().clean() + customer = cast("Customer", cleaned_data["customer"]) + order = cast("Order | None", cleaned_data.get("order", None)) + if order is not None: + if customer != order.customer: # pyright: ignore[reportUnknownMemberType] + msg = f"Order '{order}' is not for customer '{customer}'" + raise forms.ValidationError(msg) + + return cleaned_data diff --git a/incredible_data/business/migrations/0007_alter_invoiceline_rank_invoiceline_unique_line_rank.py b/incredible_data/business/migrations/0007_alter_invoiceline_rank_invoiceline_unique_line_rank.py new file mode 100644 index 0000000..46f77b9 --- /dev/null +++ b/incredible_data/business/migrations/0007_alter_invoiceline_rank_invoiceline_unique_line_rank.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.2 on 2025-06-06 21:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('business', '0006_alter_invoice_created_by_alter_invoice_modified_by_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='invoiceline', + name='rank', + field=models.PositiveSmallIntegerField(blank=True, verbose_name='rank'), + ), + migrations.AddConstraint( + model_name='invoiceline', + constraint=models.UniqueConstraint(fields=('invoice', 'rank'), name='unique_line_rank'), + ), + ] diff --git a/incredible_data/business/migrations/0008_alter_invoiceline_options.py b/incredible_data/business/migrations/0008_alter_invoiceline_options.py new file mode 100644 index 0000000..25b123a --- /dev/null +++ b/incredible_data/business/migrations/0008_alter_invoiceline_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.2 on 2025-06-08 00:11 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('business', '0007_alter_invoiceline_rank_invoiceline_unique_line_rank'), + ] + + operations = [ + migrations.AlterModelOptions( + name='invoiceline', + options={'ordering': ['rank']}, + ), + ] diff --git a/incredible_data/business/migrations/0009_alter_invoice_options_alter_order_options_and_more.py b/incredible_data/business/migrations/0009_alter_invoice_options_alter_order_options_and_more.py new file mode 100644 index 0000000..964964a --- /dev/null +++ b/incredible_data/business/migrations/0009_alter_invoice_options_alter_order_options_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.2 on 2025-06-08 01:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('business', '0008_alter_invoiceline_options'), + ] + + operations = [ + migrations.AlterModelOptions( + name='invoice', + options={'ordering': ['-created']}, + ), + migrations.AlterModelOptions( + name='order', + options={'ordering': ['-created']}, + ), + migrations.AlterModelOptions( + name='project', + options={'ordering': ['number']}, + ), + ] diff --git a/incredible_data/business/models/business_accounting_models.py b/incredible_data/business/models/business_accounting_models.py index 9a4ac03..78582f5 100644 --- a/incredible_data/business/models/business_accounting_models.py +++ b/incredible_data/business/models/business_accounting_models.py @@ -1,13 +1,20 @@ +# pyright: reportUnknownVariableType=false, reportUnknownMemberType=false, reportIncompatibleVariableOverride=false +import contextlib +import logging +from collections.abc import Iterable from datetime import date, timedelta from decimal import Decimal +from typing import TYPE_CHECKING, Any, final, override -from django.db import models -from django.db.models import Count, F, Max, Sum +from django.db import models, transaction +from django.db.models import BaseConstraint, Count, F, Max, Sum, UniqueConstraint from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import AutoSlugField -from django_rubble.models.stamped_models import StampedModel +from django_rubble.models.stamped_models import ( # pyright: ignore[reportMissingTypeStubs] + StampedModel, +) from djmoney.models.fields import MoneyField from model_utils.choices import Choices from model_utils.models import StatusModel @@ -19,15 +26,19 @@ NumberedModel, ) +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + def fourteen_days() -> date: - return timezone.now() + timedelta(days=14) + return timezone.now().date() + timedelta(days=14) def thirty_days() -> date: - return timezone.now() + timedelta(days=30) + return timezone.now().date() + timedelta(days=30) +@final class Order(BaseNumberedModel): customer = models.ForeignKey( "customers.Customer", @@ -39,13 +50,28 @@ class Order(BaseNumberedModel): default=fourteen_days, ) notes = models.TextField(_("order notes"), blank=True) - slug = AutoSlugField(populate_from=["customer", "number"]) + slug = AutoSlugField(populate_from=["customer", "number"]) # pyright: ignore[reportCallIssue] number_config = NumberConfig(prefix="MHC", width=4, start_value=1) + class Meta: + ordering: list[str] = ["-created"] + + @override def __str__(self) -> str: return f"{self.number} - {self.customer}" + def get_absolute_url(self): + return reverse("business:order-detail", kwargs={"slug": self.slug}) + def get_create_project_url(self): + customer = self.customer + return ( + reverse("business:project-create") + + f"?customer={customer.pk}&order={self.pk}" # pyright: ignore[reportAny] + ) + + +@final class Invoice(StampedModel, StatusModel, NumberedModel): number_config = NumberConfig(prefix="INV-", width=4, start_value=10) STATUS = Choices( @@ -68,34 +94,47 @@ class Invoice(StampedModel, StatusModel, NumberedModel): Order, verbose_name=_("order"), on_delete=models.PROTECT, blank=True, null=True ) - slug = AutoSlugField(populate_from="number", slugify_function=slugify) + slug = AutoSlugField(populate_from="number", slugify_function=slugify) # pyright: ignore[reportCallIssue] + + if TYPE_CHECKING: + @property + def invoiceline_set(self) -> models.QuerySet["InvoiceLine"]: + return self.invoiceline_set + + class Meta: + ordering: list[str] = ["-created"] + + @override def __str__(self): return f"{self.customer} - {self.number}" def update_totals(self) -> None: + """Aggregates the invoice lines and updates the totals.""" total = self.get_subtotal() kwargs = {"subtotal": total, "grand_total": total} - self.__class__.objects.filter(pk=self.pk).update(**kwargs) + _ = self.__class__.objects.filter(pk=self.pk).update(**kwargs) # pyright: ignore[reportAny] def get_subtotal(self) -> Decimal: - return self.invoiceline_set.aggregate( + aggregated = self.invoiceline_set.aggregate( subtotal=Sum(F("quantity") * F("unit_price")) - )["subtotal"] + ) + + return Decimal(aggregated["subtotal"]) # pyright: ignore[reportAny] def normalize_rank(self) -> None: qs: models.QuerySet[InvoiceLine] qs = self.invoiceline_set.order_by("rank") result = qs.aggregate(Count("rank"), Max("rank")) - count, max_rank = result["rank__count"], result["rank__max"] + count, max_rank = result["rank__count"], result["rank__max"] # pyright: ignore[reportAny] if count in (max_rank, 0): return # shift all ranks outside current range to prevent uniqueness errors - qs.update(rank=F("rank") + int(max_rank)) + _ = qs.update(rank=F("rank") + int(max_rank)) # pyright: ignore[reportAny] # rewrite ranks starting at 1 for ( @@ -103,49 +142,112 @@ def normalize_rank(self) -> None: line, ) in enumerate(qs): line.rank = idx + 1 - qs.bulk_update(qs, ["rank"]) + _ = qs.bulk_update(qs, ["rank"]) + + def max_rank(self) -> int: + """ + Returns the maximum rank of the invoice lines. + If there are no lines, returns 0. + """ + result = self.invoiceline_set.aggregate(Max("rank")) + return result["rank__max"] if result["rank__max"] is not None else 0 def get_absolute_url(self): - return reverse("invoice-detail", kwargs={"slug": self.slug}) + return reverse("business:invoice-detail", kwargs={"slug": self.slug}) + +class InvoiceLineManager(models.Manager["InvoiceLine"]): + @transaction.atomic + def move_up(self, line: "InvoiceLine", distance: int = 1) -> None: + """Moves the line up or down by the specified distance. + If the distance is positive, moves up; if negative, moves down. + If the line is already at the top or bottom, does nothing. + """ + if line.rank <= distance: + return + + invoice = line.invoice + + invoice_qs = self.filter(invoice=invoice).order_by("rank") + current_rank = line.rank + new_rank = line.rank - distance + lines_to_change = invoice_qs.filter(rank__gte=new_rank) + + msg = f"Moving line {line.rank} to {new_rank} in invoice {invoice.number}" + logger.debug(msg) + + with contextlib.suppress(self.model.DoesNotExist): + changed_lines = lines_to_change.update(rank=F("rank") + 1) + msg = f"Changed lines: {changed_lines}" + logger.debug(msg) + if changed_lines > 0: + line.rank = new_rank + line.save(update_fields=["rank"]) + + _ = invoice_qs.filter(rank_gt=current_rank).update(rank=F("rank") - 1) + + +@final class InvoiceLine(models.Model): - rank = models.PositiveSmallIntegerField(_("rank")) + rank = models.PositiveSmallIntegerField(_("rank"), blank=True) description = models.CharField(_("description"), max_length=100) quantity = models.DecimalField( - _("quantity"), max_digits=15, decimal_places=5, default=1 + _("quantity"), max_digits=15, decimal_places=5, default=Decimal("1") ) unit_price = MoneyField(_("unit price"), max_digits=19, decimal_places=4, default=0) invoice = models.ForeignKey( Invoice, verbose_name=_("invoice"), on_delete=models.CASCADE ) + objects = InvoiceLineManager() + + class Meta: + constraints: list[BaseConstraint] = [ + UniqueConstraint(fields=["invoice", "rank"], name="unique_line_rank") + ] + ordering: list[str] = ["rank"] + + @override def __str__(self) -> str: return f"{self.description}|{self.invoice.number} - Line {self.rank}" - def save(self, *args, **kwargs): - if self._state.adding: - self.rank = self.get_next_rank() - super().save(*args, **kwargs) + @override + def save( + self, + *args: Any, # pyright: ignore[reportExplicitAny, reportAny] + **kwargs: bool | str | Iterable[str] | None, + ) -> None: + if TYPE_CHECKING: + assert isinstance(self.invoice, Invoice) + if self.rank is None: # pyright: ignore[reportUnnecessaryComparison] + max_rank = self.invoice.max_rank() + self.rank = max_rank + 1 + super().save(*args, **kwargs) # pyright: ignore[reportAny] self.invoice.update_totals() - def delete(self, *args, **kwargs) -> tuple[int, dict[str, int]]: - deleted = super().delete(*args, **kwargs) + @override + def delete( + self, + *args: Any, # pyright: ignore[reportExplicitAny, reportAny] + **kwargs: Any, # pyright: ignore[reportExplicitAny, reportAny] + ) -> tuple[int, dict[str, int]]: + invoice = self.invoice + this_rank = self.rank - self.invoice.normalize_rank() + deleted = super().delete(*args, **kwargs) # pyright: ignore[reportAny] - return deleted - - def get_next_rank(self) -> int: - results = self.objects.filter(invoice=self.invoice).aggregate(Max("rank")) + _ = invoice.invoiceline_set.filter(rank__gt=this_rank).update( + rank=F("rank") - 1 + ) - return results["rank__max"] + 1 if results["rank__max"] is not None else 1 + return deleted @property - def extended_price(self): + def extended_price(self) -> Decimal: return self.quantity * self.unit_price @property - def line_number(self): + def line_number(self) -> int: return self.rank diff --git a/incredible_data/business/models/business_inventory_models.py b/incredible_data/business/models/business_inventory_models.py index 1066c28..c4df8ab 100644 --- a/incredible_data/business/models/business_inventory_models.py +++ b/incredible_data/business/models/business_inventory_models.py @@ -1,7 +1,11 @@ +# pyright: reportUnknownVariableType=false, reportUnknownMemberType=false, reportIncompatibleVariableOverride=false +from typing import final, override + from django.db import models from django.utils.translation import gettext_lazy as _ +@final class Manufacturer(models.Model): name = models.CharField(_("manufacturer name"), max_length=50) sku_code = models.CharField( @@ -11,13 +15,18 @@ class Manufacturer(models.Model): max_length=10, ) + class Meta: + ordering: list[str] = ["name"] + + @override def __str__(self) -> str: return f"{self.name} | {self.sku_code}" - def natural_key(self): + def natural_key(self) -> str: return self.sku_code +@final class ItemCategory(models.Model): name = models.CharField(_("category name"), max_length=50) sku_code = models.CharField( @@ -27,20 +36,27 @@ class ItemCategory(models.Model): max_length=50, ) + class Meta: + ordering: list[str] = ["name"] + + @override def __str__(self) -> str: return f"{self.name} | {self.sku_code}" - def natural_key(self): + def natural_key(self) -> str: return self.sku_code +@final class SkuColor(models.Model): name = models.CharField(_("color name"), max_length=50) - def __str__(self, *args, **kwargs): + @override + def __str__(self, *args, **kwargs): # pyright: ignore[reportUnknownParameterType, reportMissingParameterType] return self.name +@final class Item(models.Model): sku = models.CharField( _("internal stock keeping unit"), @@ -57,5 +73,6 @@ class Item(models.Model): null=True, ) + @override def __str__(self) -> str: return f"{self.description} | {self.sku}" diff --git a/incredible_data/business/models/business_project_models.py b/incredible_data/business/models/business_project_models.py index 283b8d4..7c4f81a 100644 --- a/incredible_data/business/models/business_project_models.py +++ b/incredible_data/business/models/business_project_models.py @@ -1,11 +1,18 @@ +# pyright: reportUnknownVariableType=false, reportUnknownMemberType=false, reportIncompatibleVariableOverride=false +from typing import final, override + from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import AutoSlugField -from incredible_data.contacts.models.utility_models import BaseNumberedModel +from incredible_data.contacts.models.utility_models import ( + BaseNumberedModel, + NumberConfig, +) +@final class Project(BaseNumberedModel): name = models.CharField(_("project name"), max_length=50) customer = models.ForeignKey( @@ -20,14 +27,14 @@ class Project(BaseNumberedModel): null=True, ) slug = AutoSlugField(populate_from=["pk", "name"]) - number_config = { - "prefix": "PJ", - "width": 4, - "start_value": 100, - } + number_config = NumberConfig(prefix="PJ", width=4, start_value=100) + + class Meta: + ordering: list[str] = ["number"] + @override def __str__(self) -> str: return f"{self.number} {self.name}" def get_absolute_url(self): - return reverse("project-detail", kwargs={"slug": self.slug}) + return reverse("business:project-detail", kwargs={"slug": self.slug}) diff --git a/incredible_data/business/tables/business_accounting_tables.py b/incredible_data/business/tables/business_accounting_tables.py index a0f50cf..f22e3c0 100644 --- a/incredible_data/business/tables/business_accounting_tables.py +++ b/incredible_data/business/tables/business_accounting_tables.py @@ -1,12 +1,23 @@ +from typing import final + import django_tables2 as tables +from django.db import models from django.utils.translation import gettext_lazy as _ from incredible_data.business.models.business_accounting_models import Invoice +@final class InvoiceTable(tables.Table): - number = tables.Column(_("number"), linkify=True) + number = tables.Column(_("Number"), linkify=True) + order__number = tables.Column(_("Order")) class Meta: - model = Invoice - fields = ["number", "status", "customer", "grand_total", "order"] + model: type[models.Model] = Invoice + fields: list[str] = [ + "number", + "status", + "customer", + "grand_total", + "order__number", + ] diff --git a/incredible_data/business/tables/business_project_tables.py b/incredible_data/business/tables/business_project_tables.py index 8c03216..6beac91 100644 --- a/incredible_data/business/tables/business_project_tables.py +++ b/incredible_data/business/tables/business_project_tables.py @@ -1,12 +1,26 @@ +from typing import final + import django_tables2 as tables +from django.db import models from django.utils.translation import gettext_lazy as _ +from incredible_data.business.models.business_accounting_models import Order from incredible_data.business.models.business_project_models import Project +@final class ProjectTable(tables.Table): - number = tables.Column(_("number"), linkify=True) + number = tables.Column(_("Number"), linkify=True) + + class Meta: + model: type[models.Model] = Project + fields: list[str] = ["number", "name", "order__customer"] + + +@final +class OrderTable(tables.Table): + number = tables.Column(_("Order"), linkify=True) class Meta: - model = Project - fields = ["number", "name"] + model: type[models.Model] = Order + fields: list[str] = ["number", "customer", "expected_date"] diff --git a/incredible_data/business/templatetags/business_extras.py b/incredible_data/business/templatetags/business_extras.py index 71b9355..ee2c85d 100644 --- a/incredible_data/business/templatetags/business_extras.py +++ b/incredible_data/business/templatetags/business_extras.py @@ -5,9 +5,9 @@ from django.template.defaultfilters import stringfilter from django.urls import reverse from django.utils.html import conditional_escape, format_html +from djmoney.money import Money from furl import furl from loguru import logger -from moneyed import Money register = template.Library() diff --git a/incredible_data/business/urls.py b/incredible_data/business/urls.py index bcdf88c..9b57a34 100644 --- a/incredible_data/business/urls.py +++ b/incredible_data/business/urls.py @@ -1,10 +1,34 @@ -# ruff: noqa: E501 from django.urls import path +from neapolitan.views import Role -from .views import printable_invoice +from .views import ( + InvoiceView, + OrderView, + ProjectView, + invoice_create_view, + invoice_detail_view, + order_create_view, + order_detail_view, + order_edit_view, + printable_invoice, + project_create_view, + project_detail_view, + project_edit_view, +) app_name = "business" # fmt: off urlpatterns = [ - path("invoices//printable", printable_invoice, name="invoice_detail_printable"), + path("invoice//printable", printable_invoice, name="invoice_detail_printable"), + path("project/new/", project_create_view, name="project-create"), + path("project//edit/", project_edit_view, name="project-update"), + path("project//", project_detail_view, name="project-detail"), + *ProjectView.get_urls(roles=[Role.DELETE, Role.LIST]), + path("order/new/", order_create_view, name="order-create"), + path("order//edit/", order_edit_view, name="order-update"), + path("order//", order_detail_view, name="order-detail"), + *OrderView.get_urls(roles=[Role.DELETE, Role.LIST, Role.UPDATE]), + path("invoice/new/", invoice_create_view, name="invoice-create"), + path("invoice//", invoice_detail_view, name="invoice-detail"), + *InvoiceView.get_urls(roles=[Role.DELETE, Role.LIST]), ] diff --git a/incredible_data/business/views.py b/incredible_data/business/views.py index b79b1de..571b33b 100644 --- a/incredible_data/business/views.py +++ b/incredible_data/business/views.py @@ -1,16 +1,61 @@ -from django.shortcuts import render -from django_tables2 import SingleTableView -from neapolitan.views import CRUDView +import abc +import logging +from collections.abc import Iterable +from http import HTTPMethod +from typing import ( + Any, + Generic, + Protocol, + TypedDict, + TypeVar, + cast, + final, + override, + runtime_checkable, +) +from django import forms +from django.contrib import messages +from django.db import models +from django.db.models import Model +from django.http import HttpRequest +from django.http.response import HttpResponse, HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import ( + path, + reverse, + reverse_lazy, # pyright: ignore[reportAny] +) +from django.utils.functional import Promise +from django.utils.safestring import SafeString +from neapolitan.views import Role +from pydantic import BaseModel + +from incredible_data.business.forms.business_accounting_forms import ( + InvoiceForm, + OrderForm, + ProjectForm, +) from incredible_data.business.tables.business_accounting_tables import InvoiceTable -from incredible_data.business.tables.business_project_tables import ProjectTable +from incredible_data.business.tables.business_project_tables import ( + OrderTable, + ProjectTable, +) +from incredible_data.helpers.function_based_views import ( + DetailWidget, + DetailWidgets, + generic_create_view, +) +from incredible_data.helpers.helper_views import CustomCRUDView, SingleTableListView from .models.business_accounting_models import Invoice, Order from .models.business_project_models import Project +logger = logging.getLogger(__name__) + # Create your views here. -def printable_invoice(request, slug: str): +def printable_invoice(request: HttpRequest, slug: str): invoice = ( Invoice.objects.filter(slug=slug) .select_related("customer") @@ -20,24 +65,310 @@ def printable_invoice(request, slug: str): return render(request, "business/invoice.html", context) -class ProjectView(CRUDView): +@final +class ProjectListView(SingleTableListView): + view_title = "Projects" + model = Project + table_class = ProjectTable + template_name = "base_list_tables2.html" + actions = [("New", reverse_lazy("business:project-create"))] + + +def _get_project(slug: str) -> Project: + return get_object_or_404(Project, slug=slug) + + +_T = TypeVar("_T", bound=Model) + + +class ActionLink(TypedDict): + href: str + label: str | Promise + + +class DetailView(Generic[_T]): + model: type[_T] + fields: DetailWidgets + actions: list[ActionLink] + + def __init__( + self, + model: type[_T], + fields: Iterable[str | DetailWidget], + actions: Iterable[ActionLink], + ) -> None: + self.model = model + self.fields = DetailWidgets(*fields) + self.actions = list(actions) + + def render_fields(self, instance: _T) -> list[SafeString]: + self.fields.bind(instance) + return self.fields.render_to_strings() + + def get_context(self, instance: _T, *args: Any, **kwargs: Any) -> dict[str, Any]: # pyright: ignore[reportAny, reportExplicitAny, reportUnusedParameter] + context: dict[str, Any] = {} # pyright: ignore[reportExplicitAny] + context["action_links"] = self.actions + context["fields"] = self.render_fields(instance) + context["object"] = instance + context["object_context_name"] = self.model.__name__.lower() + + model_name = self.model.__name__.lower() + context[model_name] = instance + + return context + + +class ProjectDetailView(DetailView[Project]): + pass + + +def project_detail_view(request: HttpRequest, slug: str) -> HttpResponse: + project = _get_project(slug=slug) + action_links: list[ActionLink] = [ + { + "href": reverse("business:project-update", kwargs={"slug": slug}), + "label": "Edit", + }, + {"href": reverse("business:project-list"), "label": "List"}, + ] + project_view = ProjectDetailView( + Project, + [ + DetailWidget("number"), + DetailWidget("name"), + DetailWidget("customer"), + DetailWidget("notes", template="field_textarea.html"), + DetailWidget("order"), + ], + action_links, + ) + context = project_view.get_context(project) + return render(request, "business/detail.html", context) + + +class InitialBase(BaseModel, abc.ABC): # pyright: ignore[reportUnsafeMultipleInheritance] + pass + + +@runtime_checkable +class CRUDModel(Protocol): + def get_absolute_url(self) -> str: ... + + +def process_form( + request: HttpRequest, + form_cls: type[forms.ModelForm], + instance: models.Model | None = None, + redirect_to: str | None = None, +) -> "HttpResponse": + model = form_cls._meta.model # noqa: SLF001 + assert model is not None, "Model is not defined" + model_name = model.__name__ + form = ( + form_cls(request.POST, instance=instance) + if instance is not None + else form_cls(request.POST) + ) + if form.is_valid(): + obj = cast("models.Model", form.save(commit=True)) + assert isinstance(obj, CRUDModel), ( + f"{model_name} does not have a get_absolute_url method" + ) + redirect_url = redirect_to or obj.get_absolute_url() + messages.info(request, f"{model_name} '{obj}' saved.") + return redirect(redirect_url) + + return render(request, "object_form.html", {"form": form}) + + +def get_next(params: dict[str, str | Any]) -> str: # pyright: ignore[reportExplicitAny] + next_url = params.pop("next", None) + if isinstance(next_url, list): + next_url = str(next_url[0]) # pyright: ignore[reportUnknownArgumentType] + return next_url + + +def project_create_view(request: HttpRequest) -> HttpResponse: + current_user = request.user + initial: dict[str, str | list[str] | Any] = request.GET.dict() # pyright: ignore[reportExplicitAny] + next_url = get_next(initial) + if request.method == HTTPMethod.POST: + return process_form(request, ProjectForm, redirect_to=next_url) + + initial.update({"created_by": current_user.pk, "modified_by": current_user.pk}) + + form = ProjectForm(initial=initial) + + return render(request, "object_form.html", {"form": form}) + + +def project_edit_view(request: HttpRequest, slug: str) -> HttpResponse: + current_user = request.user + query_params: dict[str, str | list[str] | Any] = request.GET.dict() # pyright: ignore[reportExplicitAny] + next_url = get_next(query_params) + project = _get_project(slug=slug) + if request.method == HTTPMethod.POST: + return process_form( + request, ProjectForm, instance=project, redirect_to=next_url + ) + + query_params["modified_by"] = current_user.pk + + form = ProjectForm(instance=project, initial=query_params) + + return render(request, "object_form.html", {"form": form}) + + +@final +class ProjectView(CustomCRUDView[Project]): model = Project fields = ["number", "name", "customer", "notes", "order"] lookup_field = "slug" + path_converter = "slug" + list_view = ProjectListView -class ProjectListView(SingleTableView): - model = Project - table_class = ProjectTable +def _get_order(slug: str) -> Order: + return get_object_or_404(Order, slug=slug) + + +@final +class OrderListView(SingleTableListView): + view_title = "Orders" + model = Order + table_class = OrderTable template_name = "base_list_tables2.html" + actions = [("New", reverse_lazy("business:order-create"))] + + +class OrderDetailView(DetailView[Order]): + pass + + +def order_create_view(request: HttpRequest) -> HttpResponse: + current_user = request.user + query_dict: dict[str, str | list[str] | Any] = request.GET.dict() # pyright: ignore[reportExplicitAny] + next_url = get_next(query_dict) + if request.method == HTTPMethod.POST: + return process_form(request, OrderForm, redirect_to=next_url) + + query_dict.update({"created_by": current_user.pk, "modified_by": current_user.pk}) + + form = OrderForm(initial=query_dict) + + return render(request, "object_form.html", {"form": form}) + + +def order_edit_view(request: HttpRequest, slug: str) -> HttpResponse: + order = _get_order(slug) + current_user = request.user + query_dict: dict[str, str | list[str] | Any] = request.GET.dict() # pyright: ignore[reportExplicitAny] + redirect_to = get_next(query_dict) + if request.method == HTTPMethod.POST: + return process_form(request, OrderForm, instance=order, redirect_to=redirect_to) + + query_dict.update({"modified_by": current_user.pk}) + + form = OrderForm(instance=order, initial=query_dict) + + return render(request, "object_form.html", {"form": form}) -class OrderView(CRUDView): +def order_detail_view(request: HttpRequest, slug: str) -> HttpResponse: + order = _get_order(slug) + + action_links: list[ActionLink] = [ + { + "href": reverse("business:order-update", args=(order.slug,)), # pyright: ignore[reportUnknownArgumentType, reportUnknownMemberType] + "label": "Edit", + }, + { + "href": order.get_create_project_url(), + "label": "New Project", + }, + {"href": reverse("business:order-list"), "label": "List"}, + ] + order_view = OrderDetailView( + Order, + [ + "customer", + "expected_date", + DetailWidget("notes", template="field_textarea.html"), + ], + action_links, + ) + context = order_view.get_context(order) + + return render(request, "business/detail.html", context) + + +@final +class OrderView(CustomCRUDView[Order]): model = Order fields = ["customer", "expected_date", "notes"] + lookup_field = "slug" + path_converter = "slug" + list_view = OrderListView + + +def _get_invoice(slug: str) -> Invoice: + return get_object_or_404(Invoice, slug=slug) -class InvoiceView(CRUDView): +@final +class InvoiceListView(SingleTableListView): + view_title = "Invoices" + model = Invoice + table_class = InvoiceTable + template_name = "base_list_tables2.html" + actions = [("New", reverse_lazy("business:invoice-create"))] + + +def invoice_create_view(request: HttpRequest) -> HttpResponseRedirect | HttpResponse: + template_name = "object_form.html" + form = InvoiceForm + redirect = "/" + return generic_create_view( + request, template_name=template_name, redirect=redirect, form=form + ) + + +class InvoiceDetailView(DetailView[Invoice]): ... + + +def invoice_detail_view(request: HttpRequest, slug: str) -> HttpResponse: + invoice = _get_invoice(slug) + action_links: list[ActionLink] = [ + { + "href": reverse("admin:business_invoice_change", args=(invoice.pk,)), # pyright: ignore[reportAny] + "label": "Edit", + }, + {"href": reverse("business:invoice-list"), "label": "List"}, + { + "href": reverse("business:invoice_detail_printable", kwargs={"slug": slug}), + "label": "Printable", + }, + ] + fields = DetailWidgets( + "number", + "customer", + DetailWidget("status", value_transform=lambda v: v.title()), + "due_date", + "subtotal", + "grand_total", + "order", + DetailWidget("notes", template="field_textarea.html"), + DetailWidget("terms", template="field_textarea.html"), + "status_changed", + ) + invoice_view = InvoiceDetailView(Invoice, fields.root, action_links) + context = invoice_view.get_context(invoice) + + return render(request, "business/detail.html", context) + + +@final +class InvoiceView(CustomCRUDView[Invoice]): model = Invoice fields = [ "number", @@ -49,11 +380,38 @@ class InvoiceView(CRUDView): "due_date", "order", ] - lookup_field = "slug" - path_converter = "slug" + lookup_field: str = "slug" + path_converter: str = "slug" + list_view = InvoiceListView + @override + @classmethod + def additional_urls(cls): + urls = super().additional_urls() + detail_url = Role.DETAIL.url_pattern(cls) + printable_url = path( + detail_url + "printable/", + printable_invoice, + name="invoice_printable", + ) -class InvoiceListView(SingleTableView): - model = Invoice - table_class = InvoiceTable - template_name = "base_list_tables2.html" + urls.append(printable_url) + return urls + + @override + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: # pyright: ignore[reportAny, reportExplicitAny] + context = cast("dict[str, Any]", super().get_context_data(**kwargs)) # pyright: ignore[reportAny, reportExplicitAny] + + lookup_value = getattr(self.object, self.lookup_field) # pyright: ignore[reportAny] + printable_action = { + "label": "Printable Invoice", + "href": reverse( + "business:invoice_printable", kwargs={self.lookup_field: lookup_value} + ), + } + action_links = cast("list[dict[str,str]]", context.get("action_links", [])) + if self.role == Role.DETAIL: + action_links.append(printable_action) + context["action_links"] = action_links + + return context diff --git a/incredible_data/conftest.py b/incredible_data/conftest.py index 3fd1477..92f0011 100644 --- a/incredible_data/conftest.py +++ b/incredible_data/conftest.py @@ -9,6 +9,6 @@ def _media_storage(settings, tmpdir) -> None: settings.MEDIA_ROOT = tmpdir.strpath -@pytest.fixture() +@pytest.fixture def user(db) -> User: return UserFactory() diff --git a/incredible_data/contacts/models/__init__.py b/incredible_data/contacts/models/__init__.py index 1ec53fa..aec6569 100644 --- a/incredible_data/contacts/models/__init__.py +++ b/incredible_data/contacts/models/__init__.py @@ -7,9 +7,9 @@ from .utility_models import NumberedModel __all__ = [ + "Contact", "ContactPrimaryBaseModel", - "NumberedModel", "Email", + "NumberedModel", "PhoneNumber", - "Contact", ] diff --git a/incredible_data/contacts/models/utility_models.py b/incredible_data/contacts/models/utility_models.py index 95f3f4a..b69b365 100644 --- a/incredible_data/contacts/models/utility_models.py +++ b/incredible_data/contacts/models/utility_models.py @@ -1,3 +1,6 @@ +# pyright: reportUnknownVariableType=false, reportUnknownMemberType=false, reportIncompatibleVariableOverride=false +from typing import final, override + from django.conf import settings from django.db import models from django.utils.translation import gettext_lazy as _ @@ -5,6 +8,7 @@ from pydantic import BaseModel +@final class DocumentNumber(models.Model): document = models.CharField(max_length=50, primary_key=True, unique=True) prefix = models.CharField(max_length=10, blank=True) @@ -13,13 +17,14 @@ class DocumentNumber(models.Model): last_number = models.CharField(max_length=50, editable=False) last_generated_date = models.DateTimeField(auto_now=True) + @override def __str__(self) -> str: return f"Document({self.document}) | Last: {self.last_number}" def get_next_number(self): prefix = self.prefix next_counter = self.next_counter - padded_counter = str(next_counter).zfill(self.padding_digits) + padded_counter = str(next_counter).zfill(self.padding_digits) # pyright: ignore[reportUnknownArgumentType] number = f"{prefix}{padded_counter}" self.next_counter += 1 @@ -43,16 +48,11 @@ class NumberedModel(models.Model): `f"{number_prefix}{str(self.id).zfill(number_width)}` - Args: - number_config: A dictionary with the following key:value pairs: - "prefix": str, - "width": int, - "start_value": 1 - number_prefix: A string prefix, case-sensitive - number_width: An int representing how wide the numeric part of the string - should be. - number_start_value: An int used to set initial value. Only used for - initial creation. + Attributes: + number_config: a NumberConfig instance with the following attributes: + - prefix: str = "" + - width: int = 0 + - start_value: int = 1 Example: @@ -60,13 +60,14 @@ class NumberedModel(models.Model): `INV0002`, etc. """ - number = models.CharField(_("number"), unique=True, max_length=10, editable=False) + number = models.CharField(_("number"), unique=True, max_length=10, editable=False) # pyright: ignore[reportUnannotatedClassAttribute] number_config: NumberConfig = NumberConfig() class Meta: - abstract = True + abstract = True # pyright: ignore[reportUnannotatedClassAttribute] - def save(self, *args, **kwargs) -> None: + @override + def save(self, *args, **kwargs) -> None: # pyright: ignore[reportUnknownParameterType, reportMissingParameterType] if self._state.adding: config = self.number_config project_number, _ = DocumentNumber.objects.get_or_create( @@ -78,29 +79,29 @@ def save(self, *args, **kwargs) -> None: }, ) self.number = project_number.get_next_number() - return super().save(*args, **kwargs) + return super().save(*args, **kwargs) # pyright: ignore[reportUnknownArgumentType] class UserStampedModel(models.Model): - created_by = models.ForeignKey( - settings.AUTH_USER_MODEL, + created_by = models.ForeignKey( # pyright: ignore[reportUnannotatedClassAttribute] + settings.AUTH_USER_MODEL, # pyright: ignore[reportAny] verbose_name=_("created by"), on_delete=models.CASCADE, related_name="%(class)s_created_by", ) - modified_by = models.ForeignKey( - settings.AUTH_USER_MODEL, + modified_by = models.ForeignKey( # pyright: ignore[reportUnannotatedClassAttribute] + settings.AUTH_USER_MODEL, # pyright: ignore[reportAny] verbose_name=_("modified by"), on_delete=models.CASCADE, related_name="%(class)s_modified_by", ) class Meta: - abstract = True + abstract = True # pyright: ignore[reportUnannotatedClassAttribute] class BaseNumberedModel(TimeStampedModel, UserStampedModel, NumberedModel): """Base class for Numbered models that include time and user stamps.""" class Meta: - abstract = True + abstract = True # pyright: ignore[reportUnannotatedClassAttribute] diff --git a/incredible_data/contrib/sites/migrations/0003_set_site_domain_and_name.py b/incredible_data/contrib/sites/migrations/0003_set_site_domain_and_name.py index 9da122d..4e4b7e2 100644 --- a/incredible_data/contrib/sites/migrations/0003_set_site_domain_and_name.py +++ b/incredible_data/contrib/sites/migrations/0003_set_site_domain_and_name.py @@ -3,20 +3,26 @@ http://cookiecutter-django.readthedocs.io/en/latest/faq.html#why-is-there-a-django-contrib-sites-directory-in-cookiecutter-django """ +from typing import TYPE_CHECKING, cast from django.conf import settings from django.db import migrations +if TYPE_CHECKING: + from django.apps.registry import Apps + from django.db.models import Model -def _update_or_create_site_with_sequence(site_model, connection, domain, name): + +def _update_or_create_site_with_sequence(site_model: type["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, + + _site, created = site_model.objects.update_or_create( + id=int(settings.SITE_ID), # pyright: ignore[reportAny] defaults={ "domain": domain, "name": name, }, ) - if created: + if created and connection.vendor == "postgresql": # 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 @@ -34,7 +40,7 @@ def _update_or_create_site_with_sequence(site_model, connection, domain, name): ) -def update_site_forward(apps, schema_editor): +def update_site_forward(apps: "Apps", schema_editor): """Set site domain and name.""" Site = apps.get_model("sites", "Site") _update_or_create_site_with_sequence( diff --git a/incredible_data/customers/filters.py b/incredible_data/customers/filters.py new file mode 100644 index 0000000..6f4923c --- /dev/null +++ b/incredible_data/customers/filters.py @@ -0,0 +1,15 @@ +from typing import final + +import django_filters as filters + +from incredible_data.customers.models import Customer + + +@final +class CustomerFilter(filters.FilterSet): # pyright: ignore[reportUnknownMemberType, reportUntypedBaseClass, reportAttributeAccessIssue] + name = filters.CharFilter(field_name="name", lookup_expr="icontains") + + @final + class Meta: + model = Customer + fields = ("name",) diff --git a/incredible_data/customers/forms.py b/incredible_data/customers/forms.py new file mode 100644 index 0000000..bd1ecef --- /dev/null +++ b/incredible_data/customers/forms.py @@ -0,0 +1,30 @@ +from typing import final + +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Field, Layout, Submit +from django import forms +from django.utils.translation import gettext_lazy as _ + +from incredible_data.customers.models import Customer + + +@final +class CustomerForm(forms.ModelForm): + @final + class Meta: + model = Customer + fields = ["name", "main_phone", "created_by", "modified_by"] + + def __init__(self, *args, **kwargs) -> None: # pyright: ignore[reportUnknownParameterType, reportMissingParameterType] + super().__init__(*args, **kwargs) # pyright: ignore[reportUnknownArgumentType] + self.helper = FormHelper() + self.helper.form_class = "blueForms" + self.helper.form_method = "post" + self.helper.layout = Layout( + "name", + "main_phone", + Field("created_by", type="hidden"), + Field("modified_by", type="hidden"), + ) + + self.helper.add_input(Submit("submit", _("Submit"))) diff --git a/incredible_data/customers/migrations/0004_alter_customer_options.py b/incredible_data/customers/migrations/0004_alter_customer_options.py new file mode 100644 index 0000000..799e3ed --- /dev/null +++ b/incredible_data/customers/migrations/0004_alter_customer_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.5 on 2025-08-25 05:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('customers', '0003_alter_customer_created_by_alter_customer_modified_by'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customer', + options={'ordering': ['name']}, + ), + ] diff --git a/incredible_data/customers/models.py b/incredible_data/customers/models.py index 725946a..5452c00 100644 --- a/incredible_data/customers/models.py +++ b/incredible_data/customers/models.py @@ -1,5 +1,8 @@ +from typing import TYPE_CHECKING, final, override + from django.db import models from django.db.models import Q, UniqueConstraint +from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django_extensions.db.fields import AutoSlugField from model_utils.models import TimeStampedModel @@ -7,35 +10,67 @@ from incredible_data.contacts.models.utility_models import UserStampedModel # Create your models here. +if TYPE_CHECKING: + from incredible_data.contacts.models.contacts_models import Contact, PhoneNumber +@final class Customer(TimeStampedModel, UserStampedModel): name = models.CharField(_("customer"), max_length=100) - main_phone = models.ForeignKey( + main_phone: "models.ForeignKey[PhoneNumber | None]" = models.ForeignKey( "contacts.PhoneNumber", verbose_name=_("organization phone"), on_delete=models.PROTECT, blank=True, null=True, ) - contacts = models.ManyToManyField( - "contacts.Contact", verbose_name=_("contacts"), through="CustomerContact" + contacts: "models.ManyToManyField[PhoneNumber, models.Model]" = ( + models.ManyToManyField( + "contacts.Contact", verbose_name=_("contacts"), through="CustomerContact" + ) ) - slug = AutoSlugField(populate_from="name") + slug = AutoSlugField(populate_from="name") # pyright: ignore[reportCallIssue] + + if TYPE_CHECKING: + customercontact_set: "models.QuerySet[CustomerContact]" # pyright: ignore[reportUninitializedInstanceVariable] + @final + class Meta: # pyright: ignore[reportIncompatibleVariableOverride] + ordering = ["name"] + + @override def __str__(self) -> str: return self.name + @property + def primary_contact(self) -> "Contact | None": + """ + Returns the primary contact for this customer, if it exists. + """ + + try: + return self.customercontact_set.get(primary=True).contact + except CustomerContact.DoesNotExist: + return None + + def get_absolute_url(self): + return reverse("customers:customer-detail", kwargs={"slug": self.slug}) # pyright: ignore[reportUnknownMemberType] + + def get_create_order_url(self): + return reverse("business:order-create") + f"?customer={self.pk}" # pyright: ignore[reportAny] + +@final class CustomerContact(models.Model): customer = models.ForeignKey( Customer, verbose_name=_("customer"), on_delete=models.CASCADE ) - contact = models.ForeignKey( + contact: "models.ForeignKey[Contact]" = models.ForeignKey( "contacts.Contact", verbose_name=_("contact"), on_delete=models.CASCADE ) primary = models.BooleanField(_("primary"), default=False) + @final class Meta: constraints = [ UniqueConstraint( @@ -45,5 +80,6 @@ class Meta: ) ] + @override def __str__(self) -> str: return f"{self.customer} - {self.contact} | primary={self.primary}" diff --git a/incredible_data/customers/tables.py b/incredible_data/customers/tables.py new file mode 100644 index 0000000..e2555e9 --- /dev/null +++ b/incredible_data/customers/tables.py @@ -0,0 +1,16 @@ +from typing import final + +import django_tables2 as tables +from django.utils.translation import gettext_lazy as _ + +from incredible_data.customers.models import Customer + + +@final +class CustomerTable(tables.Table): + name = tables.Column(_("Customer"), linkify=True) + + @final + class Meta: + model = Customer + fields = ["name", "main_phone"] diff --git a/incredible_data/customers/urls.py b/incredible_data/customers/urls.py new file mode 100644 index 0000000..f1212e8 --- /dev/null +++ b/incredible_data/customers/urls.py @@ -0,0 +1,18 @@ +from django.urls import path + +from incredible_data.customers.views import ( + customer_create_view, + customer_detail_view, + customer_edit_view, + customer_list_view, +) + +app_name = "customers" +# fmt: off +urlpatterns = [ + path("create/", customer_create_view, name="customer-create"), + path("/edit/", customer_edit_view, name="customer-update"), + path("/", customer_detail_view, name="customer-detail"), + path("", customer_list_view, name="customer-list"), +] +# fmt: on diff --git a/incredible_data/customers/views.py b/incredible_data/customers/views.py index 60f00ef..76c8524 100644 --- a/incredible_data/customers/views.py +++ b/incredible_data/customers/views.py @@ -1 +1,142 @@ # Create your views here. +import logging +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, Generic, TypedDict, TypeVar, cast + +from django.contrib import messages +from django.db.models import Model, QuerySet +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.utils.functional import Promise +from django.utils.safestring import SafeString + +from incredible_data.customers.filters import CustomerFilter +from incredible_data.customers.forms import CustomerForm +from incredible_data.customers.models import Customer +from incredible_data.customers.tables import CustomerTable +from incredible_data.helpers.function_based_views import ( + DetailWidget, + DetailWidgets, +) + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + +logger = logging.getLogger(__name__) + +_T = TypeVar("_T", bound=Model) + + +class ActionLink(TypedDict): + href: str + label: str | Promise + + +class DetailView(Generic[_T]): + model: type[_T] + fields: DetailWidgets + actions: list[ActionLink] + + def __init__( + self, + model: type[_T], + fields: Iterable[str | DetailWidget], + actions: Iterable[ActionLink], + ) -> None: + self.model = model + self.fields = DetailWidgets(*fields) + self.actions = list(actions) + + def render_fields(self, instance: _T) -> list[SafeString]: + self.fields.bind(instance) + return self.fields.render_to_strings() + + def get_context(self, instance: _T, *args: Any, **kwargs: Any) -> dict[str, Any]: # pyright: ignore[reportAny, reportExplicitAny, reportUnusedParameter] + context: dict[str, Any] = {} # pyright: ignore[reportExplicitAny] + context["action_links"] = self.actions + context["fields"] = self.render_fields(instance) + context["object"] = instance + context["object_context_name"] = self.model.__name__.lower() + + model_name = self.model.__name__.lower() + context[model_name] = instance + + return context + + +def customer_list_view(request: "HttpRequest") -> "HttpResponse": + f = CustomerFilter(request.GET) + qs = cast("QuerySet[Customer]", f.qs) + table = CustomerTable(qs) + action_links = [ + { + "href": reverse("customers:customer-create"), + "label": "Create", + }, + ] + return render( + request, + "base_list_tables2.html", + {"filter": f, "table": table, "action_links": action_links}, + ) + + +def customer_detail_view(request: "HttpRequest", slug: str) -> "HttpResponse": + customer = get_object_or_404(Customer, slug=slug) + action_links: list[ActionLink] = [ + { + "href": reverse("customers:customer-update", kwargs={"slug": slug}), + "label": "Edit", + }, + { + "href": customer.get_create_order_url(), + "label": "New Order", + }, + {"href": reverse("customers:customer-list"), "label": "List"}, + ] + customer_view = DetailView[Customer]( + Customer, + [ + DetailWidget("name"), + DetailWidget("main_phone"), + ], + action_links, + ) + context = customer_view.get_context(customer) + return render(request, "business/detail.html", context) + + +def customer_edit_view(request: "HttpRequest", slug: str) -> "HttpResponse": + current_user = request.user + customer = get_object_or_404(Customer, slug=slug) + if request.method == "POST": + form = CustomerForm(request.POST, instance=customer) + if form.is_valid(): + saved_customer = cast("Customer", form.save(commit=True)) + redirect_url = saved_customer.get_absolute_url() + messages.info(request, f"Customer '{saved_customer}' updated.") + return redirect(redirect_url) + + return render(request, "object_form.html", {"form": form}) + + form = CustomerForm(instance=customer, initial={"modified_by": current_user}) + + return render(request, "object_form.html", {"form": form}) + + +def customer_create_view(request: "HttpRequest") -> "HttpResponse": + current_user = request.user + if request.method == "POST": + form = CustomerForm(request.POST) + if form.is_valid(): + new_customer = cast("Customer", form.save(commit=True)) + redirect_url = new_customer.get_absolute_url() + messages.info(request, f"Customer '{new_customer}' created.") + return redirect(redirect_url) + + return render(request, "object_form.html", {"form": form}) + + form = CustomerForm( + initial={"created_by": current_user, "modified_by": current_user} + ) + return render(request, "object_form.html", {"form": form}) diff --git a/incredible_data/fuel/__init__.py b/incredible_data/fuel/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/incredible_data/fuel/admin.py b/incredible_data/fuel/admin.py new file mode 100644 index 0000000..6191ff2 --- /dev/null +++ b/incredible_data/fuel/admin.py @@ -0,0 +1,35 @@ +# ruff: noqa: F405 + +from django.contrib import admin + +from incredible_data.fuel.models import * # noqa: F403 + +# Register your models here. +admin.site.register(Manufacturer) + +admin.site.register(Model) + +admin.site.register(Fuel) + +admin.site.register(Vehicle) + +admin.site.register(FuelStation) + + +@admin.register(FuelLog) +class FuelLogAdmin(admin.ModelAdmin): + list_display = ( + "vehicle", + "station", + "gallons", + "cost_per_gallon", + "cost", + "date_time", + ) + + +admin.site.register(ServiceType) + +admin.site.register(ServiceLog) + +admin.site.register(ServiceItem) diff --git a/incredible_data/fuel/apps.py b/incredible_data/fuel/apps.py new file mode 100644 index 0000000..ae8b807 --- /dev/null +++ b/incredible_data/fuel/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class FuelConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "incredible_data.fuel" diff --git a/incredible_data/fuel/geolocate.py b/incredible_data/fuel/geolocate.py new file mode 100644 index 0000000..1d85ac8 --- /dev/null +++ b/incredible_data/fuel/geolocate.py @@ -0,0 +1,40 @@ +from geopy import geocoders +from geopy.geocoders import Nominatim +from geopy.location import Location + +geocoders.options.default_user_agent = "incredible-data-fuel" + + +class GeoLocate: + @staticmethod + def from_lat_long(latitude: float, longitude: float) -> Location: + geolocator = Nominatim() + return geolocator.reverse(f"{latitude}, {longitude}") + + @staticmethod + def from_address(address: str) -> Location: + geolocator = Nominatim() + return geolocator.geocode(address) + + +if __name__ == "__main__": + from rich import print # noqa: A004 + from rich.pretty import pprint + + latitude = 38.8977 + longitude = -77.0365 + + print(f"(Latitude: {latitude}, Longitude: {longitude})") + + lat_lon_location = GeoLocate.from_lat_long(latitude, longitude) + + pprint(f"Address: {lat_lon_location.address}") + + address = "7711 Thetis Dr, Pasco, WA 99301" + + address_location = GeoLocate.from_address(address) + + pprint( + f"Latitude: {address_location.latitude}, " + f"Longitude: {address_location.longitude}" + ) diff --git a/incredible_data/fuel/migrations/0001_initial.py b/incredible_data/fuel/migrations/0001_initial.py new file mode 100644 index 0000000..d3373de --- /dev/null +++ b/incredible_data/fuel/migrations/0001_initial.py @@ -0,0 +1,116 @@ +# Generated by Django 5.1.1 on 2024-09-12 23:19 + +import django.db.models.deletion +import django.utils.timezone +import djmoney.models.fields +from decimal import Decimal +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Fuel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('fuel_type', models.CharField(max_length=100)), + ], + ), + migrations.CreateModel( + name='FuelStation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(blank=True, max_length=100)), + ('address', models.CharField(max_length=150)), + ('latitude', models.FloatField()), + ('longitude', models.FloatField()), + ], + ), + migrations.CreateModel( + name='Manufacturer', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ], + ), + migrations.CreateModel( + name='ServiceType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('description', models.TextField()), + ], + ), + migrations.CreateModel( + name='Vehicle', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('year', models.IntegerField()), + ('fuel_type', models.CharField(max_length=100)), + ('price_currency', djmoney.models.fields.CurrencyField(choices=[('USD', 'US Dollar')], default='USD', editable=False, max_length=3)), + ('price', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=14)), + ('color', models.CharField(blank=True, max_length=100)), + ('vin', models.CharField(blank=True, max_length=100)), + ('model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fuel.manufacturer')), + ], + ), + migrations.CreateModel( + name='Model', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('manufacturer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fuel.manufacturer')), + ], + ), + migrations.CreateModel( + name='VehicleLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('mileage', models.IntegerField()), + ('date_time', models.DateTimeField(default=django.utils.timezone.now)), + ('vehicle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='fuel.vehicle')), + ], + ), + migrations.CreateModel( + name='FuelLog', + fields=[ + ('vehiclelog_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fuel.vehiclelog')), + ('gallons', models.DecimalField(blank=True, decimal_places=2, default=0, max_digits=14)), + ('cost_per_gallon_currency', djmoney.models.fields.CurrencyField(choices=[('USD', 'US Dollar')], default='USD', editable=False, max_length=3)), + ('cost_per_gallon', djmoney.models.fields.MoneyField(blank=True, decimal_places=3, default=Decimal('0'), max_digits=14)), + ('cost_currency', djmoney.models.fields.CurrencyField(choices=[('USD', 'US Dollar')], default='USD', editable=False, max_length=3)), + ('cost', djmoney.models.fields.MoneyField(blank=True, decimal_places=2, default=Decimal('0'), max_digits=14)), + ('fuel', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='fuel.fuel')), + ('station', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='fuel.fuelstation')), + ], + bases=('fuel.vehiclelog',), + ), + migrations.CreateModel( + name='ServiceLog', + fields=[ + ('vehiclelog_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='fuel.vehiclelog')), + ('description', models.TextField()), + ('notes', models.TextField(blank=True)), + ('service_types', models.ManyToManyField(related_name='service_logs', to='fuel.servicetype')), + ('station', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='fuel.fuelstation')), + ], + bases=('fuel.vehiclelog',), + ), + migrations.CreateModel( + name='ServiceItem', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('description', models.TextField()), + ('price_currency', djmoney.models.fields.CurrencyField(choices=[('USD', 'US Dollar')], default='USD', editable=False, max_length=3)), + ('price', djmoney.models.fields.MoneyField(decimal_places=2, max_digits=14)), + ('service_log', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='fuel.servicelog')), + ], + ), + ] diff --git a/incredible_data/fuel/migrations/__init__.py b/incredible_data/fuel/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/incredible_data/fuel/models.py b/incredible_data/fuel/models.py new file mode 100644 index 0000000..19c02d3 --- /dev/null +++ b/incredible_data/fuel/models.py @@ -0,0 +1,151 @@ +from django.db import models +from django.utils import timezone +from djmoney.models.fields import MoneyField + +from incredible_data.fuel.geolocate import GeoLocate + + +# Create your models here. +class Manufacturer(models.Model): + name = models.CharField(max_length=100) + + def __str__(self): + return self.name + + +class Model(models.Model): + manufacturer = models.ForeignKey(Manufacturer, on_delete=models.CASCADE) + name = models.CharField(max_length=100) + + def __str__(self): + return f"{self.manufacturer} {self.name}" + + +class Fuel(models.Model): + fuel_type = models.CharField(max_length=100) + + def __str__(self): + return self.fuel_type + + +class Vehicle(models.Model): + model = models.ForeignKey(Manufacturer, on_delete=models.CASCADE) + year = models.IntegerField() + fuel_type = models.CharField(max_length=100) + price = MoneyField(max_digits=14, decimal_places=2, default_currency="USD") + color = models.CharField(max_length=100, blank=True) + vin = models.CharField(max_length=100, blank=True) + + def __str__(self): + return f"{self.model} {self.year}" + + +class VehicleLog(models.Model): + vehicle = models.ForeignKey(Vehicle, on_delete=models.PROTECT) + mileage = models.IntegerField() + date_time = models.DateTimeField(default=timezone.now) + + def __str__(self): + return f"{self.mileage} miles on {self.date_time}" + + +class FuelStation(models.Model): + name = models.CharField(max_length=100, blank=True) + address = models.CharField(max_length=150) + latitude = models.FloatField() + longitude = models.FloatField() + + def __str__(self): + return self.name + + @classmethod + def from_lat_long(cls, latitude: float, longitude: float): + # Use a geocoding service to get the address + location = GeoLocate.from_lat_long(latitude, longitude) + address = location.address + latitude = location.latitude + longitude = location.longitude + return cls(address=address, latitude=latitude, longitude=longitude) + + +class FuelLog(VehicleLog): + fuel = models.ForeignKey(Fuel, on_delete=models.CASCADE) + gallons = models.DecimalField( + max_digits=14, decimal_places=2, blank=True, default=0 + ) + cost_per_gallon = MoneyField( + max_digits=14, decimal_places=3, default_currency="USD", blank=True, default=0 + ) + cost = MoneyField( + max_digits=14, decimal_places=2, default_currency="USD", blank=True, default=0 + ) + station = models.ForeignKey( + FuelStation, on_delete=models.CASCADE, blank=True, null=True + ) + + def __str__(self): + return ( + f"{self.fuel} {self.gallons} gallons for {self.cost} on" + f"{self.date_time:'Y-m-d'}" + ) + + def save(self, *args, **kwargs): + self.gallons, self.cost_per_gallon, self.cost = self._calculate_missing_input() + + super().save(*args, **kwargs) + + def _calculate_missing_input(self): + if all( + value + for value in [self.gallons, self.cost_per_gallon, self.cost] + if value != 0 + ): + return self.gallons, self.cost_per_gallon, self.cost + input_dict = { + "gallons": self.gallons, + "cost_per_gallon": self.cost_per_gallon, + "cost": self.cost, + } + if self.cost == 0: # missing `cost` + input_dict["cost"] = round(self.gallons * self.cost_per_gallon, 2) + elif self.cost_per_gallon == 0: # missing `cost_per_gallon` + input_dict["cost_per_gallon"] = round(self.cost / self.gallons, 3) + elif self.gallons == 0: # missing `gallons` + input_dict["gallons"] = round(self.cost / self.cost_per_gallon, 2) + else: + msg = "At least two of the following fields are required: gallons, cost_per_gallon, cost" + raise ValueError(msg) + + return input_dict["gallons"], input_dict["cost_per_gallon"], input_dict["cost"] + + +class ServiceType(models.Model): + name = models.CharField(max_length=100) + description = models.TextField() + + def __str__(self): + return self.name + + +class ServiceLog(VehicleLog): + description = models.TextField() + service_types = models.ManyToManyField(ServiceType, related_name="service_logs") + station = models.ForeignKey( + FuelStation, on_delete=models.SET_NULL, blank=True, null=True + ) + notes = models.TextField(blank=True) + + def __str__(self): + return f"{self.date_time} - {self.description}" + + +class ServiceItem(models.Model): + name = models.CharField(max_length=100) + description = models.TextField() + price = MoneyField(max_digits=14, decimal_places=2, default_currency="USD") + service_log = models.ForeignKey( + ServiceLog, on_delete=models.SET_NULL, blank=True, null=True + ) + + def __str__(self): + return self.name diff --git a/incredible_data/fuel/tests/__init__.py b/incredible_data/fuel/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/incredible_data/fuel/tests/test_geopy.py b/incredible_data/fuel/tests/test_geopy.py new file mode 100644 index 0000000..ae91a33 --- /dev/null +++ b/incredible_data/fuel/tests/test_geopy.py @@ -0,0 +1,29 @@ +# pyright: reportMissingTypeStubs=false + +import pytest +from geopy import geocoders +from geopy.location import Location + +from incredible_data.fuel.geolocate import GeoLocate + +geocoders.options.default_user_agent = "incredible-data-fuel" + + +def test_trivial(): + assert True + + +# Create your tests here. +@pytest.mark.skip("not being used") +def test_geolocate_lat_long(mocker): # pyright: ignore[reportUnknownParameterType, reportMissingParameterType] + mock_reverse = mocker.patch("geopy.geocoders.Nominatim.reverse") # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType] + mock_location = Location( + "221B Baker Street, London", (51.5237, -0.1585), {"place_id": 12345} + ) + mock_reverse.return_value = mock_location + + address = GeoLocate.from_lat_long(38.8977, -77.0365) + + mock_reverse.assert_called_once_with("38.8977, -77.0365") # pyright: ignore[reportUnknownMemberType] + + assert address == "221B Baker Street, London" diff --git a/incredible_data/fuel/views.py b/incredible_data/fuel/views.py new file mode 100644 index 0000000..60f00ef --- /dev/null +++ b/incredible_data/fuel/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/incredible_data/helpers/__init__.py b/incredible_data/helpers/__init__.py index 3e101d7..e3bff00 100644 --- a/incredible_data/helpers/__init__.py +++ b/incredible_data/helpers/__init__.py @@ -1,6 +1,6 @@ from .functions import short_uuid, truncate_string __all__ = [ - "truncate_string", "short_uuid", + "truncate_string", ] diff --git a/incredible_data/helpers/admin.py b/incredible_data/helpers/admin.py new file mode 100644 index 0000000..b45097b --- /dev/null +++ b/incredible_data/helpers/admin.py @@ -0,0 +1,216 @@ +# pyright: reportMissingTypeArgument = warning +"""This module provides generic admin classes for Django models as well as a UserStampedAdmin class.""" + +from collections.abc import Iterable +from typing import ( + TYPE_CHECKING, + Any, + Generic, + TypedDict, + TypeVar, + cast, + overload, + override, +) + +from django.contrib.admin import ModelAdmin, TabularInline +from django.contrib.admin.helpers import AdminForm, InlineAdminFormSet +from django.contrib.admin.options import ( + IS_POPUP_VAR, + TO_FIELD_VAR, + get_content_type_for_model, +) +from django.contrib.admin.templatetags.admin_urls import add_preserved_filters +from django.db.models import Model, QuerySet +from django.db.models.options import Options +from django.forms import ModelForm +from django.template.response import TemplateResponse + +from incredible_data.mood.manager import UserScopedManager + +if TYPE_CHECKING: + from django.http import HttpRequest + + from incredible_data.users.models import User + +_ModelT = TypeVar("_ModelT", bound=Model) + +if TYPE_CHECKING: + ModelOptions = Options +else: + + class ModelOptions(Options, Generic[_ModelT]): + pass + + +class ModelAdminContext(TypedDict, Generic[_ModelT]): + add: bool + change: bool + has_view_permission: bool + has_add_permission: bool + has_change_permission: bool + has_delete_permission: bool + has_editable_inline_admin_formsets: bool + has_file_field: bool + has_absolute_url: bool + absolute_url: str | None + form_url: str + opts: ModelOptions[_ModelT] + content_type_id: str + save_as: bool + save_on_top: bool + to_field_var: str + is_popup_var: str + app_label: str + adminform: "GenericModelAdmin[_ModelT]" + inline_admin_formsets: list[InlineAdminFormSet] + + +if TYPE_CHECKING: + + class GenericModelAdmin(ModelAdmin[_ModelT], Generic[_ModelT]): + class Meta: + abstract: bool = True +else: + + class GenericModelAdmin(ModelAdmin, Generic[_ModelT]): + class Meta: + abstract: bool = True + + +class GenericTabularInline(TabularInline, Generic[_ModelT]): # pyright: ignore[reportMissingTypeArgument] + class Meta: + abstract: bool = True + + +class GenericAdminForm(AdminForm, Generic[_ModelT]): + prepopulated_fields: dict[str, Any] # pyright: ignore[reportExplicitAny] + model_admin: GenericModelAdmin[_ModelT] + readonly_fields: Iterable[str] + + +class UserStampedAdmin(GenericModelAdmin[_ModelT], Generic[_ModelT]): + created_by_field: str = "created_by" + opts: "Options[_ModelT]" + + class Meta: # pyright: ignore[reportIncompatibleVariableOverride] + abstract: bool = True + + @override + def save_model( + self, request: "HttpRequest", obj: _ModelT, form: ModelForm, change: bool + ) -> None: + user = cast("User", request.user) + if not change: + setattr(obj, self.created_by_field, user) + + super().save_model(request, obj, form, change) + + def _has_editable_inline_admin_formsets( + self, context: ModelAdminContext[_ModelT] + ) -> bool: + formsets = context["inline_admin_formsets"] + for inline in formsets: + if ( + inline.has_add_permission # pyright: ignore[reportAny] + or inline.has_change_permission # pyright: ignore[reportAny] + or inline.has_delete_permission # pyright: ignore[reportAny] + ): + return True + return False + + def _any_formset_is_multipart(self, formsets: list[InlineAdminFormSet]) -> bool: + return any(formset.formset.is_multipart() for formset in formsets) # pyright: ignore[reportAny] + + def _has_file_field(self, context: ModelAdminContext[_ModelT]) -> bool: + admin_form = context["adminform"] + inline_formsets = context["inline_admin_formsets"] + return admin_form.form.is_multipart() or self._any_formset_is_multipart( # pyright: ignore[reportCallIssue, reportUnknownVariableType] + inline_formsets + ) + + @override + def render_change_form( # pyright: ignore[reportIncompatibleMethodOverride] + self, + request: "HttpRequest", + context: ModelAdminContext[_ModelT], + add: bool = False, + change: bool = False, + form_url: str = "", + obj: _ModelT | None = None, + ) -> TemplateResponse: + app_label = self.opts.app_label + preserved_filters = self.get_preserved_filters(request) + form_url = add_preserved_filters( + {"preserved_filters": preserved_filters, "opts": self.opts}, url=form_url + ) + view_on_site_url = self.get_view_on_site_url(obj) + has_editable_inline_admin_formsets = self._has_editable_inline_admin_formsets( + context + ) + + context.update( + { + "add": add, + "change": change, + "has_view_permission": self.has_view_permission(request, obj), + "has_add_permission": self.has_add_permission(request), + "has_change_permission": self.has_change_permission(request, obj), + "has_delete_permission": self.has_delete_permission(request, obj), + "has_editable_inline_admin_formsets": has_editable_inline_admin_formsets, + "has_file_field": self._has_file_field(context), + "has_absolute_url": view_on_site_url is not None, + "absolute_url": view_on_site_url, + "form_url": form_url, + "opts": self.opts, + "content_type_id": get_content_type_for_model(self.model).pk, # pyright: ignore[reportAny] + "save_as": self.save_as, + "save_on_top": self.save_on_top, + "to_field_var": TO_FIELD_VAR, + "is_popup_var": IS_POPUP_VAR, + "app_label": app_label, + } + ) + form_template = ( + self.add_form_template + if (add and self.add_form_template is not None) # pyright: ignore[reportUnnecessaryComparison] + else self.change_form_template + ) + + request.current_app = self.admin_site.name + + return TemplateResponse( + request, + template=self._get_template(form_template), + context=context, # pyright: ignore[reportArgumentType] + ) + + @overload + def _get_template(self, form_template: str) -> str: ... + @overload + def _get_template(self, form_template: None) -> list[str]: ... + @overload + def _get_template(self, form_template: str | None) -> str | list[str]: ... + def _get_template(self, form_template: str | None) -> str | list[str]: + if form_template is not None: + return form_template + app_label = self.opts.app_label + model_name = self.opts.model_name + return [ + f"admin/{app_label}/{model_name}/change_form.html", + f"admin/{app_label}/change_form.html", + "admin/change_form.html", + ] + + @override + def get_queryset(self, request: "HttpRequest") -> "QuerySet[_ModelT]": + if request.user.is_superuser: + return super().get_queryset(request) + + model = self.model + + model_manager = model.objects + + assert isinstance(model_manager, UserScopedManager) + + return model_manager.fetch_user_records(request) diff --git a/incredible_data/helpers/context_processors.py b/incredible_data/helpers/context_processors.py new file mode 100644 index 0000000..a9fec1c --- /dev/null +++ b/incredible_data/helpers/context_processors.py @@ -0,0 +1,34 @@ +from typing import TYPE_CHECKING, Self, cast + +from attrmagic import ClassBase +from django.conf import settings +from django.utils.version import get_version +from pydantic import Field + +if TYPE_CHECKING: + from django.http.request import HttpRequest + + +class ProjectInfo(ClassBase): + version: str + commit_hash: str + django_version: str = Field(default_factory=get_version) + + @property + def build_number(self) -> str: + return self.commit_hash + + @classmethod + def from_settings(cls) -> Self: + return cls( + version=cast("str", settings.VERSION), + commit_hash=cast("str", settings.BUILD_NUMBER), + ) + + @property + def full_version(self) -> str: + return f"{self.version}+{self.build_number}" + + +def version(_request: "HttpRequest"): + return {"project": ProjectInfo.from_settings()} diff --git a/incredible_data/helpers/decorators.py b/incredible_data/helpers/decorators.py new file mode 100644 index 0000000..3e5af29 --- /dev/null +++ b/incredible_data/helpers/decorators.py @@ -0,0 +1,19 @@ +from collections.abc import Callable +from functools import wraps +from typing import TYPE_CHECKING, ParamSpec, TypeVar + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + +_P = ParamSpec("_P") +_R = TypeVar("_R", bound="HttpResponse") + + +def create_view(func: Callable[_P, _R]): + @wraps(func) + def wrapper(request: "HttpRequest") -> _R: + initial = request.GET.copy().dict() + + return func(request, initial=initial) # pyright: ignore[reportUnknownVariableType, reportCallIssue] + + return wrapper diff --git a/incredible_data/helpers/fields.py b/incredible_data/helpers/fields.py new file mode 100644 index 0000000..d6e7163 --- /dev/null +++ b/incredible_data/helpers/fields.py @@ -0,0 +1,104 @@ +# pyright: reportIncompatibleVariableOverride=warning + + +import logging +from functools import cached_property +from typing import Any, final, override + +from django import forms +from django.core import checks, validators +from django.db import models +from django.utils.translation import gettext_lazy as _ + +logger = logging.getLogger(__name__) + + +@final +class RatingField(models.PositiveSmallIntegerField): # pyright: ignore[reportMissingTypeArgument] + # a Promise is used here in Django fields + description = _("A field for storing ratings on a user-specified scale.") + allow_zero: bool = False + scale_maximum: int = 10 + + @override + def check(self, **kwargs: Any) -> list[checks.CheckMessage]: # pyright: ignore[reportAny, reportExplicitAny] + errors = super().check(**kwargs) + errors.extend(self._check_maximum_is_positive()) + return errors + + def _check_maximum_is_positive(self) -> list[checks.CheckMessage]: + valid = True + msg_postfix = None + if self.allow_zero: + if self.scale_maximum <= 0: + valid = False + msg_postfix = "zero" + elif self.scale_maximum <= 1: + valid = False + msg_postfix = "one" + if not valid: + return [ + checks.Error( + f"'scale_maximum' must be greater than {msg_postfix}.", + obj=self, + id="incredible_data.E001", + ) + ] + return [] + + def __init__( + self, + allow_zero: bool = False, # noqa: FBT001, FBT002 + scale_maximum: int = 10, + **kwargs: Any, # pyright: ignore[reportExplicitAny, reportAny] + ) -> None: + super().__init__(**kwargs) + + self.allow_zero = allow_zero + self.scale_maximum = scale_maximum + + @override + def get_internal_type(self) -> str: + return "PositiveSmallIntegerField" + + @cached_property + @override + def validators(self): # pyright: ignore[reportIncompatibleMethodOverride] + validators_ = super().validators + + msg = f"IntegerField validators: {validators_}" + logger.debug(msg) + + if not self.allow_zero: + validators_.append( + validators.MinValueValidator( + 1, message=_("Rating cannot be less than %(limit_value)s.") + ) + ) + validators_.append( + validators.MaxValueValidator( + self.scale_maximum, + message=_("Rating cannot be greater than %(limit_value)s."), + ) + ) + + return validators_ + + @override + def formfield( + self, + form_class: type[forms.Field] | None = None, + choices_form_class: type[forms.Field] | None = None, + **kwargs: Any, # pyright: ignore[reportAny, reportExplicitAny] + ) -> forms.Field: + kwargs["form_class"] = forms.ChoiceField + kwargs["choices_form_class"] = None + + start_value = 1 if not self.allow_zero else 0 + choices_tuple = tuple( + (i, str(i)) for i in range(start_value, self.scale_maximum + 1) + ) + kwargs["choices"] = choices_tuple + kwargs["widget"] = forms.Select + + return super().formfield(**kwargs) # pyright: ignore[reportAny] diff --git a/incredible_data/helpers/function_based_views.py b/incredible_data/helpers/function_based_views.py new file mode 100644 index 0000000..3aaeef5 --- /dev/null +++ b/incredible_data/helpers/function_based_views.py @@ -0,0 +1,156 @@ +import abc +import logging +from collections.abc import Callable, Iterator +from http import HTTPMethod +from typing import ( + Any, + Generic, + SupportsIndex, + TypeVar, + overload, + override, +) + +from django.db import models +from django.forms import BaseForm +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect +from django.shortcuts import render +from django.template.loader import render_to_string +from django.utils.functional import Promise +from django.utils.safestring import SafeString + +logger = logging.getLogger(__name__) + + +def generic_create_view( + request: HttpRequest, + form: type[BaseForm], + template_name: str = "object_form.html", + redirect: str | None = None, +) -> HttpResponseRedirect | HttpResponse: + if request.method == HTTPMethod.POST: + form_obj = form(request.POST) + if form_obj.is_valid(): + # some stuff + if redirect is None: + redirect = "/" + return HttpResponseRedirect(redirect) + + return render(request, template_name, {"form": form()}) + + +class DetailWidget: + field_name: str + label: Promise | str | None + empty_value: str + linkify: bool | None + template: str + value_transform: Callable[[str], str] | None + + instance: models.Model | None + field: models.Field | None # pyright: ignore[reportMissingTypeArgument] + + def __init__( # noqa: PLR0913 + self, + field_name: str, + label: Promise | str | None = None, + empty_value: str = "--", + value_transform: Callable[[str], str] | None = None, + template: str = "field_base.html", + linkify: bool | None = None, # noqa: FBT001, RUF100 + ) -> None: + self.field_name = field_name + self.label = label + self.empty_value = empty_value + self.linkify = linkify + self.template = template + self.value_transform = value_transform + + self.instance = None + self.field = None + + @property + def is_bound(self) -> bool: + return self.instance is not None and self.field is not None # pyright: ignore[reportUnknownMemberType] + + def bind(self, instance: models.Model) -> None: + self.instance = instance + model_cls = instance.__class__ + field_properties = model_cls._meta.get_field(self.field_name) # noqa: SLF001 + if self.label is None: + try: + self.label = field_properties.verbose_name + except AttributeError as e: + msg = f'{type(field_properties)} does not support "verbose_name"... please explore' + raise AttributeError(msg) from e + self.field = getattr(instance, self.field_name) or self.empty_value + + msg = f"Field: {self.field}<{type(self.field)}>" # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] + logger.debug(msg) + + def generate_context(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny] + label = self.label + field = ( # pyright: ignore[reportUnknownVariableType] + self.field # pyright: ignore[reportUnknownMemberType] + if self.value_transform is None + else self.value_transform(self.field) # pyright: ignore[reportUnknownMemberType, reportArgumentType] + ) + return {"label": label, "value": field} + + def render(self) -> SafeString: + if not self.is_bound: + msg = f"Field '{self.field_name}' must be bound before rendering." + raise ValueError(msg) + + context = self.generate_context() + + return render_to_string(self.template, context=context) + + +_T = TypeVar("_T") + + +class CustomRoot(Generic[_T], abc.ABC): + root: list[_T] + + def __init__(self, *fields: str | _T) -> None: + root_list: list[_T] = [] + for field in fields: + if isinstance(field, str): + root_list.append(self.validate_field(field)) + else: + root_list.append(field) + self.root = root_list + + @classmethod + @abc.abstractmethod + def validate_field(cls, field: str) -> _T: + raise NotImplementedError + + def __iter__(self) -> Iterator[_T]: + return iter(self.root) + + @overload + def __getitem__(self, item: SupportsIndex, /) -> _T: ... + @overload + def __getitem__(self, item: slice, /) -> list[_T]: ... + def __getitem__(self, item: SupportsIndex | slice, /) -> _T | list[_T]: + return self.root[item] + + def __setitem__(self, key: SupportsIndex, value: _T) -> None: + self.root[key] = value + + +class DetailWidgets(CustomRoot[DetailWidget]): + root: list[DetailWidget] + + @classmethod + @override + def validate_field(cls, field: str) -> DetailWidget: + return DetailWidget(field_name=field) + + def bind(self, instance: models.Model) -> None: + _bound_fields = [field.bind(instance) for field in self.root] + + def render_to_strings(self) -> list[SafeString]: + return [field.render() for field in self.root] diff --git a/incredible_data/helpers/functions.py b/incredible_data/helpers/functions.py index a5dfef1..b791127 100644 --- a/incredible_data/helpers/functions.py +++ b/incredible_data/helpers/functions.py @@ -1,7 +1,7 @@ import logging from pathlib import Path +from typing import Protocol -from django.db.models.fields.files import FieldFile from shortuuid import uuid logger = logging.getLogger(__name__) @@ -25,6 +25,10 @@ def short_uuid( return str(uuid(alphabet=alphabet))[:5] -def create_media_name(field_file: FieldFile) -> str: +class FieldFileProtocol(Protocol): + name: str + + +def create_media_name(field_file: FieldFileProtocol) -> str: """Get only the filename (`stem`) from a FieldFile.""" return Path(field_file.name).stem diff --git a/incredible_data/helpers/helper_views.py b/incredible_data/helpers/helper_views.py index 83bf49a..6528bf3 100644 --- a/incredible_data/helpers/helper_views.py +++ b/incredible_data/helpers/helper_views.py @@ -1,9 +1,36 @@ -from typing import Any +import abc +import logging +from collections.abc import Iterable, Iterator +from typing import TYPE_CHECKING, Any, Generic, NamedTuple, TypeVar, cast, override +from django.contrib.auth.models import AnonymousUser from django.core.exceptions import PermissionDenied +from django.db import models +from django.urls import URLPattern, URLResolver, path +from django.utils.decorators import classonlymethod +from django.utils.functional import Promise from django.views.generic.detail import DetailView from django.views.generic.edit import CreateView from django_tables2 import SingleTableView +from neapolitan.views import Role + +if not TYPE_CHECKING: + # HACK: dirty hack for better type checking + from neapolitan.views import CRUDView as _CRUDView + + _T = TypeVar("_T") + + class CRUDView(_CRUDView, Generic[_T]): ... +else: + from neapolitan.views import CRUDView + + +logger = logging.getLogger(__name__) + + +class ActionTuple(NamedTuple): + label: str | Promise + href: str class SingleTableListView(SingleTableView): @@ -21,11 +48,17 @@ class SingleTableListView(SingleTableView): ``` """ + url_base: str | None = None + lookup_url_kwarg: str | None = None + lookup_field: str = "pk" + path_converter: str = "int" + view_title: str | None = None actions: list[tuple[str, str]] | None = None - template_name = "base_list_tables2.html" + template_name: str = "base_list_tables2.html" - def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + @override + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: # pyright: ignore[reportAny, reportExplicitAny] super_context = super().get_context_data(**kwargs) super_context["view_title"] = ( @@ -43,7 +76,8 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: class ExtraContextDetailView(DetailView): """Adds context to DetailView for full detail URI.""" - def get_context_data(self, **kwargs: Any) -> dict[str, Any]: + @override + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: # pyright: ignore[reportAny, reportExplicitAny] super_context = super().get_context_data(**kwargs) super_context["full_detail_uri"] = self.request.build_absolute_uri() @@ -54,7 +88,8 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: class UserStampedCreateView(CreateView): """Adds created_by and modified_by to initial data for CreateView.""" - def get_initial(self) -> dict[str, Any]: + @override + def get_initial(self) -> dict[str, Any]: # pyright: ignore[reportExplicitAny] initial = super().get_initial() user = self.request.user @@ -62,7 +97,58 @@ def get_initial(self) -> dict[str, Any]: if not user.is_authenticated: raise PermissionDenied - initial["created_by"] = str(user.id) - initial["modified_by"] = str(user.id) + assert not isinstance(user, AnonymousUser), "User must be authenticated." + + initial["created_by"] = str(user.pk) # pyright: ignore[reportAny] + initial["modified_by"] = str(user.pk) # pyright: ignore[reportAny] return initial + + +_ModelT = TypeVar("_ModelT", bound=models.Model) + + +class CustomCRUDView(CRUDView[_ModelT], Generic[_ModelT], abc.ABC): + """Custom CRUDView that can be extended for specific models.""" + + list_view: type[SingleTableListView] | None = None + + @classmethod + def additional_urls(cls) -> list[URLPattern | URLResolver]: + """Override this method to add custom URLs for the CRUDView.""" + return [] + + @classmethod + def _all_roles(cls) -> Iterator[Role]: + return iter(Role) + + @classmethod + def _get_list_pattern(cls) -> URLPattern: + route_pattern = f"{cls.url_base}/" + pattern_name: str = f"{cls.url_base}-list" + + assert cls.list_view is not None, "list_view for `_get_list_pattern`." + + return path(route_pattern, cls.list_view.as_view(), name=pattern_name) + + @classonlymethod # pyright: ignore[reportArgumentType] + @override + def get_urls( + cls, # noqa: N805 + roles: Iterable[Role] | None = None, + ) -> list[URLPattern | URLResolver]: + if roles is None: + roles = cls._all_roles() + list_pattern: URLPattern | None = None + if cls.list_view is not None and Role.LIST in roles: + list_pattern = cls._get_list_pattern() + roles = [role for role in roles if role != Role.LIST] + common = cast("list[URLPattern | URLResolver]", super().get_urls(roles=roles)) + + if list_pattern is not None: + common.append(list_pattern) + common.extend(cls.additional_urls()) + + msg = f"{cls.__name__} URLS: {common}" + logger.debug(msg) + return common diff --git a/incredible_data/helpers/templatetags/__init__.py b/incredible_data/helpers/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/incredible_data/helpers/test_functions.py b/incredible_data/helpers/test_functions.py index 770b254..ffcfbf5 100644 --- a/incredible_data/helpers/test_functions.py +++ b/incredible_data/helpers/test_functions.py @@ -4,7 +4,7 @@ from incredible_data.helpers.functions import create_media_name -@pytest.fixture() +@pytest.fixture def field_file(): class FieldFile: def __init__(self, name: str): @@ -13,9 +13,9 @@ def __init__(self, name: str): return FieldFile("path/to/file.txt") -@pytest.fixture() +@pytest.fixture def long_string(): - return "This is a long string that needs to be truncated to fit within a certain length." # noqa: E501 + return "This is a long string that needs to be truncated to fit within a certain length." @pytest.mark.parametrize( @@ -29,7 +29,7 @@ def long_string(): (50, "This is a long string that needs to be..."), ( 100, - "This is a long string that needs to be truncated to fit within a certain length.", # noqa: E501 + "This is a long string that needs to be truncated to fit within a certain length.", ), ], ) diff --git a/incredible_data/helpers/tests/__init__.py b/incredible_data/helpers/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/incredible_data/helpers/tests/test_context_processors.py b/incredible_data/helpers/tests/test_context_processors.py new file mode 100644 index 0000000..b22fdae --- /dev/null +++ b/incredible_data/helpers/tests/test_context_processors.py @@ -0,0 +1,15 @@ +from incredible_data.helpers.context_processors import ProjectInfo + + +class DummySettings: + VERSION = "1.2.3" + BUILD_NUMBER = "456" + + +def test_project_info_from_settings(monkeypatch): + monkeypatch.setattr("django.conf.settings.VERSION", DummySettings.VERSION) + monkeypatch.setattr("django.conf.settings.BUILD_NUMBER", DummySettings.BUILD_NUMBER) + info = ProjectInfo.from_settings() + assert info.version == DummySettings.VERSION + assert info.commit_hash == DummySettings.BUILD_NUMBER + assert info.full_version == f"{DummySettings.VERSION}+{DummySettings.BUILD_NUMBER}" diff --git a/incredible_data/helpers/tests/test_fields.py b/incredible_data/helpers/tests/test_fields.py new file mode 100644 index 0000000..e69de29 diff --git a/incredible_data/helpers/tests/test_functions.py b/incredible_data/helpers/tests/test_functions.py new file mode 100644 index 0000000..24bc1c7 --- /dev/null +++ b/incredible_data/helpers/tests/test_functions.py @@ -0,0 +1,26 @@ +import pytest + +from incredible_data.helpers.functions import ( + create_media_name, + truncate_string, +) + + +@pytest.fixture +def dummy_field_file(): + class DummyFieldFile: + def __init__(self, name: str): + self.name: str = name + + return DummyFieldFile("path/to/file.txt") + + +def test_truncate_string(): + s = "This is a long string that needs to be truncated to fit within a certain length." + assert truncate_string(s, 19) == "This is a long..." + assert truncate_string(s, 100) == s + assert truncate_string("short", 10) == "short" + + +def test_create_media_name(dummy_field_file): + assert create_media_name(dummy_field_file) == "file" diff --git a/incredible_data/helpers/tests/test_helper_views.py b/incredible_data/helpers/tests/test_helper_views.py new file mode 100644 index 0000000..5a1e42f --- /dev/null +++ b/incredible_data/helpers/tests/test_helper_views.py @@ -0,0 +1,45 @@ +import pytest +from django.core.exceptions import PermissionDenied + +from incredible_data.helpers.helper_views import UserStampedCreateView + + +class DummyRequest: + def __init__(self, user): + self.user = user + + +class DummyUser: + def __init__(self, pk, authenticated=True): # noqa: FBT002 + self.pk = pk + self.is_authenticated = authenticated + + +class DummySuperView: + def get_initial(self): + return {} + + +class TestView(UserStampedCreateView, DummySuperView): + def __init__(self, user): + self.request = DummyRequest(user) + + def get_initial(self): + return super().get_initial() + + +@pytest.mark.skip("this needs some work") +def test_get_initial_authenticated(): + user = DummyUser(pk=123, authenticated=True) + view = TestView(user) + initial = view.get_initial() + assert initial["created_by"] == "123" + assert initial["modified_by"] == "123" + + +@pytest.mark.skip("this needs some work") +def test_get_initial_unauthenticated(): + user = DummyUser(pk=123, authenticated=False) + view = TestView(user) + with pytest.raises(PermissionDenied): + view.get_initial() diff --git a/incredible_data/helpers/tests/test_manual_helper_views.py b/incredible_data/helpers/tests/test_manual_helper_views.py new file mode 100644 index 0000000..34efade --- /dev/null +++ b/incredible_data/helpers/tests/test_manual_helper_views.py @@ -0,0 +1,53 @@ +# pyright: reportAny=false +import http +from typing import TYPE_CHECKING, cast, override + +from django import forms +from django.contrib.auth.models import Permission +from django.test import Client, TestCase +from django.urls import reverse + +from incredible_data.helpers.helper_views import UserStampedCreateView +from incredible_data.users.models import User + +if TYPE_CHECKING: + from django.template.response import TemplateResponse + + +class DummyUserStampedCreateView(UserStampedCreateView): + pass + + +class UserTestCase(TestCase): + @override + def setUp(self): + self.user: User = User.objects.create_user( # pyright: ignore[reportUnknownMemberType, reportUninitializedInstanceVariable] + email="testuser@example.com", + password="testpassword", # noqa: S106 + ) + self.user.user_permissions.add(*Permission.objects.all()) + self.client: Client = Client() + + def test_user_created(self): + user = User.objects.get(email="testuser@example.com") + self.client.force_login(user) + container_add_url = reverse("bins:container_add") + response = cast("TemplateResponse", self.client.get(container_add_url)) + + assert response.status_code == http.HTTPStatus.OK + + context_data = response.context_data + + assert context_data is not None + + form = context_data["form"] + + assert isinstance(form, forms.ModelForm) + + initial = form.initial + + assert "created_by" in initial + + created_by = initial["created_by"] + + assert created_by == str(user.pk) diff --git a/incredible_data/mood/__init__.py b/incredible_data/mood/__init__.py new file mode 100644 index 0000000..2956e57 --- /dev/null +++ b/incredible_data/mood/__init__.py @@ -0,0 +1,62 @@ +import random +from collections.abc import Iterable +from typing import Any, NamedTuple, cast + +from attrmagic import ClassBase, SimpleListRoot +from constance import config # pyright: ignore[reportMissingTypeStubs] +from pydantic import field_validator +from pydantic_extra_types.color import COLORS_BY_NAME, Color + +__all__ = ["COLOR_CHOICES"] + + +class ColorModel(ClassBase): + name: str + rgb: tuple[int, int, int] + + +class PaletteTuple(NamedTuple): + hex: str + name: str + + +class ColorChoices(SimpleListRoot[Color]): + @field_validator("root", mode="before") + @classmethod + def validate_root(cls, data: Any) -> Any: # pyright: ignore[reportExplicitAny, reportAny] + if not isinstance(data, dict): + return data # pyright: ignore[reportAny] + if "root" in data: + return data # pyright: ignore[reportUnknownVariableType] + + return [Color(value) for value in data.values()] # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] + + def get_random_unique(self, existing_hex: set[str]) -> Color: + available = [color for color in self if color.as_hex() not in existing_hex] + if not available: + msg = "No unique color found." + raise ValueError(msg) + return random.choice(available) # noqa: S311 + + def named_color_palette(self, names: Iterable[str], /): + for name in names: + color = next((c for c in self if c.as_named(fallback=True) == name), None) + if color: + yield PaletteTuple(hex=color.as_hex(), name=name) + else: + msg = f"No matching color found for name: {name}" + raise ValueError(msg) + + def random_color_palette(self, count: int = 6) -> list[Color]: + if count < 1: + msg = "Count must be at least 1." + raise ValueError(msg) + return random.sample(self.root, min(count, len(self))) + + +COLOR_CHOICES = ColorChoices.model_validate(COLORS_BY_NAME) + + +def get_color_palette(): + names = cast("list[str]", config.MOOD_GRAPH_COLORS) + return COLOR_CHOICES.named_color_palette(names) diff --git a/incredible_data/mood/admin.py b/incredible_data/mood/admin.py new file mode 100644 index 0000000..29e3efd --- /dev/null +++ b/incredible_data/mood/admin.py @@ -0,0 +1,128 @@ +import logging +from typing import TYPE_CHECKING, Any, final, override + +from django.contrib import admin + +from incredible_data.helpers.admin import ( + GenericModelAdmin, + GenericTabularInline, + UserStampedAdmin, +) +from incredible_data.mood import models as mood_models + +if TYPE_CHECKING: + from django.db import models + from django.forms.models import BaseInlineFormSet + from django.http.request import HttpRequest + +logger = logging.getLogger(__name__) + + +@final +class MoodAdmin(UserStampedAdmin[mood_models.Mood]): + """Admin interface for the Mood model.""" + + list_display = ("timestamp", "entered_by") + search_fields = ("notes",) + list_filter = ("entered_by",) + ordering = ("-timestamp",) + readonly_fields = ("timestamp", "entered_by") + date_hierarchy = "timestamp" + + fieldsets = ( + (None, {"fields": ("timestamp", "entered_by")}), + (None, {"fields": ("notes",)}), + ) + created_by_field = "entered_by" + + +@final +@admin.register(mood_models.MetricType) +class MetricTypeAdmin(GenericModelAdmin[mood_models.MetricType]): + """Admin interface for the MetricType model.""" + + list_display = ("name", "is_score", "graph_color") + search_fields = ("name",) + ordering = ("name",) + list_filter = ("is_score",) + + +@final +class MetricInline(GenericTabularInline[mood_models.Metric]): + model = mood_models.Metric + extra = 0 + + @override + def get_extra( + self, + request: "HttpRequest", + obj: mood_models.Metric | None = None, + **kwargs: Any, # pyright: ignore[reportExplicitAny, reportAny] + ) -> int: + if obj is not None: + return super().get_extra(request, obj, **kwargs) + active_qs = mood_models.MetricType.objects.active_for_user(request.user) + return active_qs.count() + + @override + def get_formset( + self, + request: "HttpRequest", + obj: mood_models.Metric | None = None, + **kwargs: Any, # pyright: ignore[reportExplicitAny, reportAny] + ) -> "type[BaseInlineFormSet]": + return super().get_formset(request, obj, **kwargs) + + +@final +@admin.register(mood_models.Entry) +class EntryAdmin(UserStampedAdmin[mood_models.Entry]): + list_display = ("created_by", "effective_date", "time_of_day", "value_display") + search_fields = ("notes",) + list_filter = ("created_by", "time_of_day") + date_hierarchy = "effective_date" + ordering = ("-effective_date",) + inlines = [MetricInline] + readonly_fields = ("created_by", "created_on") + fieldsets = ( + (None, {"fields": ("effective_date", "time_of_day", "notes")}), + ( + "Details", + {"fields": (("created_by", "created_on"),), "classes": ("collapse",)}, + ), + ) + + @override + def get_queryset(self, request: "HttpRequest"): + qs = super().get_queryset(request) + if request.user.is_superuser: + return qs + return qs.filter(created_by=request.user) + + +@final +@admin.register(mood_models.Metric) +class MetricAdmin(GenericModelAdmin[mood_models.Metric]): + list_display = ( + "entry__created_by", + "entry__effective_date", + "metric_type", + "score_value", + ) + search_fields = ("entry__created_by__first_name", "metric_type__name") + list_filter = ("metric_type",) + ordering = ( + "entry__created_by", + "-entry__effective_date", + ) + + @override + def get_queryset( + self, request: "HttpRequest" + ) -> "models.QuerySet[mood_models.Metric]": + qs = super().get_queryset(request) + user = request.user + if user.is_superuser: + return qs + + return qs.filter(entry__created_by=user) diff --git a/incredible_data/mood/api/__init__.py b/incredible_data/mood/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/incredible_data/mood/api/filters.py b/incredible_data/mood/api/filters.py new file mode 100644 index 0000000..142f111 --- /dev/null +++ b/incredible_data/mood/api/filters.py @@ -0,0 +1,47 @@ +import logging +from typing import final + +from django_filters import rest_framework as filters + +from incredible_data.mood.models import Entry, Metric, MetricType + +logger = logging.getLogger(__name__) + + +@final +class MetricTypeFilter(filters.FilterSet): + min_value = filters.NumericRangeFilter() + max_value = filters.NumericRangeFilter() + + @final + class Meta: + model = MetricType + fields = { + "name": ["iexact", "icontains"], + "is_score": ["exact"], + "higher_is_better": ["exact"], + } + + +@final +class MetricFilter(filters.FilterSet): + date = filters.DateFromToRangeFilter(field_name="entry__created_on") + o = filters.OrderingFilter(fields=(("metric_type__name", "metric_type"),)) + + @final + class Meta: + model = Metric + fields = ["entry", "metric_type"] + + +@final +class EntryFilter(filters.FilterSet): + date = filters.DateFromToRangeFilter(field_name="created_on") + o = filters.OrderingFilter( + fields=(("time_of_day", "time_of_day"), ("created_on", "created_on")) + ) + + @final + class Meta: + model = Entry + fields = ["time_of_day"] diff --git a/incredible_data/mood/api/serializers.py b/incredible_data/mood/api/serializers.py new file mode 100644 index 0000000..cd0e45e --- /dev/null +++ b/incredible_data/mood/api/serializers.py @@ -0,0 +1,205 @@ +import logging +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any, Generic, TypeVar, final, override + +from django.db import models +from pydantic import BaseModel, RootModel, model_validator +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from incredible_data.mood.models import Entry, Metric, MetricType +from incredible_data.users.api.serializers import UserSerializer + +logger = logging.getLogger(__name__) + +_IN = TypeVar("_IN") +_MT = TypeVar("_MT", bound=models.Model) + +if TYPE_CHECKING: + from rest_framework.utils.serializer_helpers import BindingDict + + class GenericSerializer(serializers.Serializer[_IN], Generic[_IN]): + pass + + class GenericModelSerializer(serializers.ModelSerializer[_MT], Generic[_MT]): + pass + + class GenericHyperlinkedModelSerializer( + serializers.HyperlinkedModelSerializer[_MT], Generic[_MT] + ): + pass +else: + + class GenericSerializer(serializers.Serializer, Generic[_IN]): + pass + + class GenericModelSerializer(serializers.ModelSerializer, Generic[_MT]): + pass + + class GenericHyperlinkedModelSerializer( + serializers.HyperlinkedModelSerializer, Generic[_MT] + ): + pass + + +class ExpandableFieldsMixin: + class Meta: + abstract: bool = True + expandable: Sequence[str] = [] + + def __init__( + self, + *args: Any, # pyright: ignore[reportExplicitAny, reportAny] + expand: Sequence[str] | None = None, + **kwargs: str | Any, # pyright: ignore[reportExplicitAny] + ) -> None: + super().__init__(*args, **kwargs) + + expand = expand if expand is not None else [] + + # raise an exception if any of expand not in Meta.expandable + invalid_fields = [ + field for field in expand if field not in self.Meta.expandable + ] + if invalid_fields: + msg = f"Invalid fields for expansion: {invalid_fields}" + raise ValueError(msg) + + if TYPE_CHECKING: + self.fields: BindingDict + + for field in set(self.Meta.expandable) - set(expand): + _ = self.fields.pop(field, None) + + +@final +class MetricTypeSerializer(GenericModelSerializer[MetricType]): + @final + class Meta: # pyright: ignore[reportIncompatibleVariableOverride] + model = MetricType + fields = ( + "id", + "name", + "help_text", + "is_score", + "higher_is_better", + "min_value", + "max_value", + "scale_definition", + "graph_color", + ) + + +class Choice(BaseModel): + id: int + label: str | None + + @model_validator(mode="before") + @classmethod + def from_tuple(cls, data: Any) -> Any: # pyright: ignore[reportAny,reportExplicitAny] + if isinstance(data, tuple) and len(data) == 2: # pyright: ignore[reportUnknownArgumentType] # noqa: PLR2004 + return {"id": data[0], "label": data[1]} # pyright: ignore[reportUnknownVariableType] + return data # pyright: ignore[reportUnknownVariableType] + + +class Choices(RootModel[list[Choice]]): + pass + + +@final +class MetricTypeChoicesSerializer(GenericSerializer[Choice]): + id = serializers.IntegerField(required=True) + label = serializers.CharField(required=False) + + @override + def is_valid(self, *, raise_exception: bool = False): # pyright: ignore[reportIncompatibleMethodOverride] + assert hasattr(self, "initial_data"), ( + "Cannot call `.is_valid()` as no `data=` keyword argument was " + "passed when instantiating the serializer instance." + ) + + if not hasattr(self, "_validated_data"): + try: + self._validated_data = self.run_validation(self.initial_data) # pyright: ignore[reportAny, reportUninitializedInstanceVariable] + except ValidationError as exc: + self._validated_data = {} + self._errors = exc.detail + else: + self._errors = {} # pyright: ignore[reportUninitializedInstanceVariable] + + logger.debug("Validation errors found: %s", self._errors) + + if self._errors and raise_exception: + raise ValidationError(self.errors) # pyright: ignore[reportAny] + + return not bool(self._errors) + + +@final +class MetricSerializer(ExpandableFieldsMixin, GenericModelSerializer[Metric]): # pyright: ignore[reportUnsafeMultipleInheritance] + metric_type = MetricTypeSerializer(read_only=True) + effective_date = serializers.DateField( + source="entry.effective_date", read_only=True + ) + + @final + class Meta: # pyright: ignore[reportIncompatibleVariableOverride] + model = Metric + fields = ( + "id", + "metric_type_id", + "metric_type", + "entry_id", + "score_value", + "effective_date", + ) + expandable = ["metric_type"] + + +@final +class EntrySerializer(ExpandableFieldsMixin, GenericModelSerializer[Entry]): # pyright: ignore[reportUnsafeMultipleInheritance] + metric_set = MetricSerializer(many=True, required=False) + created_by_id = serializers.IntegerField(required=True) + created_by = UserSerializer() + + @final + class Meta: # pyright: ignore[reportIncompatibleVariableOverride] + model = Entry + fields = ( + "id", + "effective_date", + "time_of_day", + "created_by_id", + "created_by", + "metric_set", + ) + expandable = ["metric_set", "created_by"] + + +@final +class DataSerializer(serializers.Serializer): + date = serializers.DateField() + value = serializers.IntegerField() + + +@final +class DatasetSerializer(serializers.Serializer): + label = serializers.CharField() + borderColor = serializers.CharField(source="border_color") # noqa: N815 + data_list = DataSerializer(many=True) + fill = serializers.BooleanField(default=True) + tension = serializers.FloatField(default=0.1) + + @override + def to_representation(self, instance: dict[str, Any]) -> dict[str, Any]: # pyright: ignore[reportExplicitAny] + rep = super().to_representation(instance) # pyright: ignore[reportAny] + rep["data"] = rep.pop("data_list", []) # pyright: ignore[reportAny] + return rep # pyright: ignore[reportAny] + + +@final +class ChartSerializer(serializers.Serializer): + type = serializers.CharField(default="line") + title = serializers.CharField(default="Basic Chart") + labels = serializers.ListField(child=serializers.DateField()) + datasets = DatasetSerializer(many=True) diff --git a/incredible_data/mood/api/views.py b/incredible_data/mood/api/views.py new file mode 100644 index 0000000..4988bff --- /dev/null +++ b/incredible_data/mood/api/views.py @@ -0,0 +1,173 @@ +import logging +from collections.abc import Callable +from typing import ( + TYPE_CHECKING, + Any, + Generic, + Protocol, + TypeVar, + cast, + final, + override, +) + +from django.db import models +from rest_framework import permissions, serializers, status, viewsets +from rest_framework.decorators import action +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response + +from incredible_data.mood.api.filters import EntryFilter, MetricFilter, MetricTypeFilter +from incredible_data.mood.models import Entry, Metric, MetricType + +from .serializers import ( + ChartSerializer, + Choices, + EntrySerializer, + MetricSerializer, + MetricTypeChoicesSerializer, + MetricTypeSerializer, +) + +logger = logging.getLogger(__name__) + + +_MT_co = TypeVar("_MT_co", bound=models.Model, covariant=True) + +if TYPE_CHECKING: + from datetime import date + + from rest_framework.request import Request + + class GenericReadOnlyModelViewSet(viewsets.ReadOnlyModelViewSet, Generic[_MT_co]): + pass + + class GenericModelViewSet(viewsets.ModelViewSet, Generic[_MT_co]): + pass +else: + + class GenericReadOnlyModelViewSet(viewsets.ReadOnlyModelViewSet, Generic[_MT_co]): + pass + + class GenericModelViewSet(viewsets.ModelViewSet, Generic[_MT_co]): + pass + + +class ViewSetProtocol(Protocol): + request: "Request" + get_serializer: Callable[..., serializers.Serializer] + + +class ExpandableFieldsMixin: + def get_serializer( + self: ViewSetProtocol, + *args: Any, # pyright: ignore[reportExplicitAny, reportAny] + **kwargs: Any, # pyright: ignore[reportExplicitAny, reportAny] + ) -> serializers.Serializer: + expand = self.request.query_params.getlist("expand") + kwargs.setdefault("expand", expand) + return super().get_serializer(*args, **kwargs) + + +@final +class MetricTypeViewSet(GenericReadOnlyModelViewSet[MetricType]): + queryset = MetricType.objects.all() + serializer_class = MetricTypeSerializer + permission_classes = [permissions.IsAuthenticated] + filterset_class = MetricTypeFilter + + @action(detail=True, methods=["get"]) + def choices(self, _request: "Request", pk: int | None = None) -> Response: # pyright: ignore[reportUnusedParameter] + """ + Get choices for the metric type. + """ + metric_type = cast("MetricType", self.get_object()) + choices = Choices.model_validate(metric_type.get_choices()) + ser = MetricTypeChoicesSerializer(data=choices.model_dump(), many=True) + if not ser.is_valid(raise_exception=True): + return Response(ser.errors, status=status.HTTP_400_BAD_REQUEST) # pyright: ignore[reportAny] + return Response(ser.data, status=status.HTTP_200_OK) # pyright: ignore[reportAny] + + +@final +class EntryViewSet(GenericModelViewSet[Entry]): + queryset = Entry.objects.all() + serializer_class = EntrySerializer + permission_classes = [permissions.IsAuthenticated] + filterset_class = EntryFilter + + @override + def get_queryset(self) -> "models.QuerySet[Entry]": + qs = cast("models.QuerySet[Entry]", super().get_queryset()) + + return qs.filter(created_by=self.request.user) + + +@final +class MetricViewSet(GenericModelViewSet[Metric]): + queryset = Metric.objects.all().prefetch_related("entry", "metric_type") + serializer_class = MetricSerializer + permission_classes = [permissions.IsAuthenticated] + filterset_class = MetricFilter + + @override + def get_queryset(self) -> "models.QuerySet[Metric]": + qs = cast("models.QuerySet[Metric]", super().get_queryset()) + + # Filter metrics by the user who created the entry + return qs.filter(entry__created_by=self.request.user) + + +@final +class ChartAPIView(GenericAPIView): + permission_classes = [permissions.IsAuthenticated] + serializer_class = ChartSerializer + + # def get_metric_type_queryset(self) -> "models.QuerySet[MetricType]": + + def get(self, request: "Request", _format: str | None = None): + entry_qs = ( + Entry.objects.filter(created_by=request.user) + .order_by("created_on") + .prefetch_related("metric_set") + ) + + labels = entry_qs.values_list("effective_date", flat=True) + + metric_types = MetricType.objects.all() + + datasets: list[dict[str, str | bool | float | list[dict[str, date | int]]]] = [] + + for metric in metric_types: + metric_entries = entry_qs.filter(metric__metric_type=metric) + if not metric_entries.exists(): + continue + + data = [ + { + "date": entry.effective_date, + "value": entry.metric_set.get(metric_type=metric).score_value, + } + for entry in metric_entries + ] + + dataset: dict[str, str | bool | float | list[dict[str, date | int]]] = { + "label": metric.name, + "border_color": metric.graph_color, + "fill": False, + "tension": 0.1, + "data_list": data, + } + datasets.append(dataset) + + logger.debug("Datasets prepared for chart: %s", datasets) + + ser = ChartSerializer( + { + "title": "Metrics Overview", + "labels": labels, + "datasets": datasets, + } + ) + + return Response(ser.data, status=status.HTTP_200_OK) # pyright: ignore[reportAny] diff --git a/incredible_data/mood/apps.py b/incredible_data/mood/apps.py new file mode 100644 index 0000000..5909f8d --- /dev/null +++ b/incredible_data/mood/apps.py @@ -0,0 +1,9 @@ +from typing import final + +from django.apps import AppConfig + + +@final +class MoodConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "incredible_data.mood" diff --git a/incredible_data/mood/chart.py b/incredible_data/mood/chart.py new file mode 100644 index 0000000..fcf0a7b --- /dev/null +++ b/incredible_data/mood/chart.py @@ -0,0 +1,53 @@ +import abc +import datetime as dt +from typing import Any, Literal, Self + +from caseconverter.camel import ( # pyright: ignore[reportMissingTypeStubs] + camelcase, # pyright: ignore[reportUnknownVariableType] +) +from pydantic import BaseModel, ConfigDict, computed_field +from pydantic_extra_types.color import Color + + +class ChartJSBase(BaseModel, abc.ABC): # pyright: ignore[reportUnsafeMultipleInheritance] + model_config: ConfigDict = ConfigDict(alias_generator=lambda s: camelcase(s)) # pyright: ignore[reportIncompatibleVariableOverride] + + +class MetricChartDataPoint(BaseModel): + date: dt.date + value: int + + +class Dataset(ChartJSBase): + label: str + data: list[MetricChartDataPoint] = [] + border_color: Color | None = None + background_color: Color | None = None + + +class ChartData(BaseModel): + datasets: dict[str, list[MetricChartDataPoint]] = {} + + _dates: set[dt.date] = set() + + def populate_dates(self) -> None: + for values in self.datasets.values(): + for point in values: + self._dates.add(point.date) + + def get_dates(self, *, force: bool = False) -> set[dt.date]: + if not self._dates or force: + self.populate_dates() + return self._dates + + @computed_field + @property + def labels(self) -> list[str]: + sorted_dates = sorted(self.get_dates(force=True)) + return list(map(str, sorted_dates)) + + +class Chart(ChartJSBase): + type: Literal["line"] + data: ChartData + options: dict[str, Any] = {} # pyright: ignore[reportExplicitAny] diff --git a/incredible_data/mood/converters.py b/incredible_data/mood/converters.py new file mode 100644 index 0000000..513e225 --- /dev/null +++ b/incredible_data/mood/converters.py @@ -0,0 +1,50 @@ +import logging +from typing import Generic, TypeVar, final + +from django.db import models +from django.shortcuts import get_object_or_404 +from django.urls.converters import IntConverter + +from incredible_data.mood.models import Entry + +logger = logging.getLogger(__name__) + +_ModelT = TypeVar("_ModelT", bound=models.Model) + + +class ModelIdConverter(Generic[_ModelT]): + model: type[_ModelT] + regex: str = IntConverter.regex + + def __init__(self, model: type[_ModelT]) -> None: + self.model = model + super().__init__() + + def to_python(self, value: str | int) -> _ModelT: + value_int = int(value) + try: + instance = self.model.objects.get(id=value_int) + except self.model.DoesNotExist: + msg = f"{self.model.__name__} with id {value_int} does not exist." + raise ValueError(msg) from None + return instance + + def to_url(self, value: _ModelT | int) -> str: + if isinstance(value, models.Model): + return str(value.id) # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType, reportAttributeAccessIssue] + return str(value) + + +@final +class EntryIdConverter: + regex: str = IntConverter.regex + model = Entry + + def to_python(self, value: str | int) -> Entry: + return get_object_or_404(Entry, id=int(value)) + + def to_url(self, value: Entry | int) -> str: + if isinstance(value, Entry): + logger.debug("Entry.id type: %s", type(value.id)) + return str(value.id) + return str(value) diff --git a/incredible_data/mood/forms.py b/incredible_data/mood/forms.py new file mode 100644 index 0000000..88845f2 --- /dev/null +++ b/incredible_data/mood/forms.py @@ -0,0 +1,85 @@ +import logging +from typing import Any, cast, final + +from django import forms +from django.shortcuts import get_object_or_404 + +from .models import Entry, Metric, MetricType, today + +logger = logging.getLogger(__name__) + + +@final +class MetricForm(forms.ModelForm): + metric_type = forms.ModelChoiceField( + queryset=MetricType.objects.all(), + disabled=True, + widget=forms.HiddenInput(), + ) + + def __init__(self, *args: Any, **kwargs: object): # pyright: ignore[reportAny, reportExplicitAny] + super().__init__(*args, **kwargs) # pyright: ignore[reportAny] + # Store metric name for template display + metric_type_raw = cast( + "MetricType | int | None", self.initial.get("metric_type") + ) + if metric_type_raw: + if not isinstance(metric_type_raw, MetricType): + metric_type = get_object_or_404(MetricType, id=metric_type_raw) + else: + metric_type = metric_type_raw + + self.metric_name = metric_type.name + self.metric_help_text = metric_type.help_text + + # Generate choices from min_value to max_value + choices = [ + (value, str(value)) + for value in range(metric_type.min_value, metric_type.max_value + 1) + ] + + # Update score_value field to use Select widget with dynamic choices + self.fields["score_value"] = forms.ChoiceField( + choices=choices, + widget=forms.Select(attrs={"class": "form-select"}), + initial=metric_type.min_value + + (metric_type.max_value - metric_type.min_value) // 2, + ) + else: + self.metric_name = "Unknown Metric" + self.metric_help_text = "" + + @final + class Meta: + model = Metric + fields = ["metric_type", "score_value"] + + +@final +class EntryForm(forms.ModelForm): + notes = forms.CharField( + widget=forms.Textarea( + attrs={ + "class": "form-control", + "placeholder": "How are you feeling today?", + "rows": 4, + "style": "resize:vertical;", + } + ) + ) + effective_date = forms.DateField( + widget=forms.DateInput(attrs={"type": "date"}), initial=today + ) + time_of_day = forms.TypedChoiceField( + choices=Entry.TimeOfDay.choices, + required=False, + widget=forms.Select(attrs={"class": "form-select"}), + empty_value=None, + coerce=int, + initial=Entry.TimeOfDay.get_current, + ) + + @final + class Meta: + model = Entry + fields = ["notes", "effective_date", "time_of_day"] diff --git a/incredible_data/mood/manager.py b/incredible_data/mood/manager.py new file mode 100644 index 0000000..9be54c0 --- /dev/null +++ b/incredible_data/mood/manager.py @@ -0,0 +1,28 @@ +from typing import TYPE_CHECKING, Generic, TypeVar, override + +from django.db import models + +_ModelT = TypeVar("_ModelT", bound=models.Model) + +if TYPE_CHECKING: + from django.http import HttpRequest + + class GenericManager(models.Manager[_ModelT], Generic[_ModelT]): + pass + +else: + + class GenericManager(models.Manager, Generic[_ModelT]): + pass + + +class UserScopedManager(GenericManager[_ModelT], Generic[_ModelT]): + @override + def __init__(self, field_name: str = "created_by") -> None: + super().__init__() + + self.field_name: str = field_name + + def fetch_user_records(self, request: "HttpRequest") -> "models.QuerySet[_ModelT]": + user = request.user + return self.filter(**{self.field_name: user}) diff --git a/incredible_data/mood/migrations/0001_initial.py b/incredible_data/mood/migrations/0001_initial.py new file mode 100644 index 0000000..4d7346e --- /dev/null +++ b/incredible_data/mood/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.2 on 2025-06-12 18:30 + +import django.db.models.deletion +import incredible_data.helpers.fields +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Mood", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "timestamp", + models.DateTimeField( + auto_now_add=True, + help_text="The date and time when the mood was recorded.", + ), + ), + ("anxiety", incredible_data.helpers.fields.RatingField()), + ("energy", incredible_data.helpers.fields.RatingField()), + ("notes", models.TextField(blank=True, help_text="Optional notes.")), + ( + "entered_by", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "ordering": ["timestamp"], + }, + ), + ] diff --git a/incredible_data/mood/migrations/0002_metrictype_remove_mood_anxiety_remove_mood_energy_and_more.py b/incredible_data/mood/migrations/0002_metrictype_remove_mood_anxiety_remove_mood_energy_and_more.py new file mode 100644 index 0000000..e3adebb --- /dev/null +++ b/incredible_data/mood/migrations/0002_metrictype_remove_mood_anxiety_remove_mood_energy_and_more.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.2 on 2025-08-11 16:05 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mood', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='MetricType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, unique=True, verbose_name='name')), + ('is_score', models.BooleanField(default=True)), + ], + ), + migrations.RemoveField( + model_name='mood', + name='anxiety', + ), + migrations.RemoveField( + model_name='mood', + name='energy', + ), + migrations.CreateModel( + name='Entry', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_on', models.DateTimeField(auto_now_add=True)), + ('created_by', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Metric', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('score_value', models.PositiveSmallIntegerField()), + ('entry', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='mood.entry')), + ('metric_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='mood.metrictype')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('entry', 'metric_type'), name='unique_entry_metric')], + }, + ), + ] diff --git a/incredible_data/mood/migrations/0003_alter_entry_options_entry_effective_date_entry_notes_and_more.py b/incredible_data/mood/migrations/0003_alter_entry_options_entry_effective_date_entry_notes_and_more.py new file mode 100644 index 0000000..0605632 --- /dev/null +++ b/incredible_data/mood/migrations/0003_alter_entry_options_entry_effective_date_entry_notes_and_more.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.2 on 2025-08-11 16:31 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mood', '0002_metrictype_remove_mood_anxiety_remove_mood_energy_and_more'), + ] + + operations = [ + migrations.AlterModelOptions( + name='entry', + options={'ordering': ['-created_on'], 'verbose_name_plural': 'Entries'}, + ), + migrations.AddField( + model_name='entry', + name='effective_date', + field=models.DateField(default=datetime.date.today, help_text='The date this entry is effective for.'), + ), + migrations.AddField( + model_name='entry', + name='notes', + field=models.TextField(blank=True, help_text='Optional notes.'), + ), + migrations.AddField( + model_name='metrictype', + name='help_text', + field=models.TextField(blank=True, verbose_name='help text'), + ), + migrations.AddField( + model_name='metrictype', + name='higher_is_better', + field=models.BooleanField(default=True, help_text='Indicates if a higher score is better (True) or a lower score is better (False).'), + ), + ] diff --git a/incredible_data/mood/migrations/0004_entry_time_of_day.py b/incredible_data/mood/migrations/0004_entry_time_of_day.py new file mode 100644 index 0000000..c085a1f --- /dev/null +++ b/incredible_data/mood/migrations/0004_entry_time_of_day.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.2 on 2025-08-12 18:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mood', '0003_alter_entry_options_entry_effective_date_entry_notes_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='entry', + name='time_of_day', + field=models.IntegerField(blank=True, choices=[(None, 'Unspecified'), (1, 'Morning'), (2, 'Afternoon'), (3, 'Evening')], default=None, null=True), + ), + ] diff --git a/incredible_data/mood/migrations/0005_metrictype_max_value_metrictype_min_value_and_more.py b/incredible_data/mood/migrations/0005_metrictype_max_value_metrictype_min_value_and_more.py new file mode 100644 index 0000000..4fd899e --- /dev/null +++ b/incredible_data/mood/migrations/0005_metrictype_max_value_metrictype_min_value_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.2 on 2025-08-14 20:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mood', '0004_entry_time_of_day'), + ] + + operations = [ + migrations.AddField( + model_name='metrictype', + name='max_value', + field=models.IntegerField(default=10, help_text='Maximum allowed value for this metric type.'), + ), + migrations.AddField( + model_name='metrictype', + name='min_value', + field=models.IntegerField(default=0, help_text='Minimum allowed value for this metric type.'), + ), + migrations.AddField( + model_name='metrictype', + name='scale_definition', + field=models.TextField(blank=True, default='', help_text='Optional scale definition (e.g., JSON or description).'), + ), + ] diff --git a/incredible_data/mood/migrations/0006_metrictype_graph_color.py b/incredible_data/mood/migrations/0006_metrictype_graph_color.py new file mode 100644 index 0000000..b89237f --- /dev/null +++ b/incredible_data/mood/migrations/0006_metrictype_graph_color.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.5 on 2025-08-19 23:19 + +import colorfield.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('mood', '0005_metrictype_max_value_metrictype_min_value_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='metrictype', + name='graph_color', + field=colorfield.fields.ColorField(default='#FFFFFF', image_field=None, max_length=25, samples=None), + ), + ] diff --git a/incredible_data/mood/migrations/0007_entry_metrics_metrictype_active_for.py b/incredible_data/mood/migrations/0007_entry_metrics_metrictype_active_for.py new file mode 100644 index 0000000..f20e5a5 --- /dev/null +++ b/incredible_data/mood/migrations/0007_entry_metrics_metrictype_active_for.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.5 on 2025-09-12 06:25 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mood', '0006_metrictype_graph_color'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='entry', + name='metrics', + field=models.ManyToManyField(through='mood.Metric', to='mood.metrictype'), + ), + migrations.AddField( + model_name='metrictype', + name='active_for', + field=models.ManyToManyField(to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/incredible_data/mood/migrations/__init__.py b/incredible_data/mood/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/incredible_data/mood/models.py b/incredible_data/mood/models.py new file mode 100644 index 0000000..cb0b38c --- /dev/null +++ b/incredible_data/mood/models.py @@ -0,0 +1,248 @@ +import datetime as dt +import json +import logging +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any, Self, TypeAlias, cast, final, override + +from colorfield.fields import ColorField +from django.conf import settings +from django.contrib import admin +from django.contrib.auth.models import AbstractUser, AnonymousUser +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from incredible_data.mood.manager import UserScopedManager + +if TYPE_CHECKING: + from incredible_data.users.models import User + + UserForeignKey: TypeAlias = "models.ForeignKey[User]" + +logger = logging.getLogger(__name__) + +_AnyUser: TypeAlias = AbstractUser | AnonymousUser +AUTH_USER_MODEL = cast("str", settings.AUTH_USER_MODEL) + + +# Create your models here. +@final +class Mood(models.Model): + """Log anxiety of the day with a 1-10 scale and optional notes.""" + + timestamp = models.DateTimeField( + auto_now_add=True, help_text="The date and time when the mood was recorded." + ) + entered_by: "models.ForeignKey[User]" = models.ForeignKey( + AUTH_USER_MODEL, on_delete=models.PROTECT + ) + + notes = models.TextField(blank=True, help_text="Optional notes.") + + objects = UserScopedManager["Mood"]() + + @final + class Meta: + ordering = ["timestamp"] + + @override + def __str__(self) -> str: + return f"{self.timestamp:%Y-%m-%d %H:%M} {self.entered_by}" # - Anxiety: {self.anxiety}, Energy: {self.energy}" + + +class ActiveForQuerySet(models.QuerySet["MetricType"]): + def active_for_user(self, user: "_AnyUser") -> Self: + metric_types = self.filter(active_for=user) + logger.debug("Active metric types for user %s: %s", user, metric_types) + return metric_types + + +@final +class MetricType(models.Model): + name = models.CharField(_("name"), max_length=100, unique=True) + help_text = models.TextField(_("help text"), blank=True) + is_score = models.BooleanField(default=True) + higher_is_better = models.BooleanField( + default=True, + help_text="Indicates if a higher score is better (True) or a lower score is better (False).", + ) + min_value = models.IntegerField( + default=0, help_text="Minimum allowed value for this metric type." + ) + max_value = models.IntegerField( + default=10, help_text="Maximum allowed value for this metric type." + ) + scale_definition = models.TextField( + blank=True, + default="", + help_text="Optional scale definition (e.g., JSON or description).", + ) + graph_color = ColorField() + + active_for: "models.ManyToManyField[User, Any]" = models.ManyToManyField( # pyright: ignore[reportExplicitAny] + AUTH_USER_MODEL, + blank=True, + help_text="Users for whom this metric type is active.", + ) + + if TYPE_CHECKING: + objects: ActiveForQuerySet # pyright: ignore[reportIncompatibleVariableOverride] + id: models.BigAutoField # pyright: ignore[reportUninitializedInstanceVariable] + else: + objects = ActiveForQuerySet.as_manager() + + @override + def __str__(self) -> str: + return self.name + + @property + def range(self) -> range: + return range(self.min_value, self.max_value + 1) + + def get_scale_definition(self) -> dict[str, str | None] | None: + try: + return cast("dict[str, str | None]", json.loads(self.scale_definition)) + except json.JSONDecodeError: + return None + + def get_choices(self) -> Iterable[tuple[str, str | None]]: + definition = self.get_scale_definition() + if definition is None: + yield from [(str(i), None) for i in self.range] + else: + yield from definition.items() + + +def today() -> dt.date: + now = timezone.now().astimezone(timezone.get_default_timezone()) + now_date = now.date() + logger.debug("today() called, current date: %s", now_date) + return now_date + + +@final +class Entry(models.Model): + created_by: "UserForeignKey" = models.ForeignKey( + AUTH_USER_MODEL, on_delete=models.PROTECT, editable=False + ) + created_on = models.DateTimeField(auto_now_add=True) + notes = models.TextField(blank=True, help_text="Optional notes.") + effective_date = models.DateField( + default=today, + help_text="The date this entry is effective for.", + ) + metrics: "models.ManyToManyField[MetricType, Metric]" = models.ManyToManyField( + MetricType, through="Metric" + ) + + class TimeOfDay(models.IntegerChoices): + MORNING = 1, _("Morning [0500-11:59]") + AFTERNOON = 2, _("Afternoon [1200-16:59]") + EVENING = 3, _("Evening [1700-20:59]") + NIGHT = 4, _("Night [2100-04:59]") + + __empty__ = _("Unspecified") + + def range(self) -> tuple[int, int]: + match self: + case self.MORNING: + return (5, 12) + case self.AFTERNOON: + return (12, 17) + case self.EVENING: + return (17, 21) + case self.NIGHT: + return (21, 5) + + @classmethod + def from_hour(cls, hour: int) -> Self: + for time_of_day in cls: + start, end = time_of_day.range() + if start < end: + if start <= hour < end: + return time_of_day + elif hour >= start or hour < end: + return time_of_day + msg = f"No TimeOfDay found for hour {hour}" + raise ValueError(msg) + + @classmethod + def get_current(cls) -> "Entry.TimeOfDay": + now = timezone.now().astimezone(timezone.get_default_timezone()) + hour = now.hour + time_of_day = cls.from_hour(hour) + logger.debug( + "Current hour: %d, TimeOfDay: %d [%s]", + hour, + time_of_day, + time_of_day.label, + ) + return time_of_day + + time_of_day = models.IntegerField( + choices=TimeOfDay.choices, + default=TimeOfDay.get_current, + null=True, + blank=True, + ) + + objects = UserScopedManager["Entry"]() + + if TYPE_CHECKING: + metric_set: "models.QuerySet[Metric]" # pyright: ignore[reportUninitializedInstanceVariable] + id: models.BigAutoField # pyright: ignore[reportUninitializedInstanceVariable] + + @final + class Meta: + ordering = ["-created_on"] + verbose_name_plural = "Entries" + + @override + def __str__(self) -> str: + return f"Entry by {self.created_by} on {self.effective_date}" + + def get_absolute_url(self): + return reverse("mood:entry-detail", args=(self.id,)) + + @admin.display(description=_("Metric Values")) + def value_display(self) -> str: + metric_strs = [ + f"{metric.metric_type.name}: {metric.score_value}" + for metric in self.metric_set.all() + ] + if not metric_strs: + return "" + return ", ".join(metric_strs) + + +@final +class Metric(models.Model): + metric_type = models.ForeignKey(MetricType, on_delete=models.PROTECT) + entry = models.ForeignKey(Entry, on_delete=models.PROTECT) + score_value = models.PositiveSmallIntegerField() + + @final + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["entry", "metric_type"], name="unique_entry_metric" + ) + ] + + @override + def __str__(self) -> str: + return f"{self.metric_type.name}({self.score_value})" + + @override + def clean(self): + super().clean() + min_val = self.metric_type.min_value + max_val = self.metric_type.max_value + if not (min_val <= self.score_value <= max_val): + raise ValidationError( + { + "score_value": f"Value must be between {min_val} and {max_val} for metric type '{self.metric_type.name}'." + } + ) diff --git a/incredible_data/mood/templates/mood/mood_entry.html b/incredible_data/mood/templates/mood/mood_entry.html new file mode 100644 index 0000000..c5ac607 --- /dev/null +++ b/incredible_data/mood/templates/mood/mood_entry.html @@ -0,0 +1,132 @@ +{% extends "base.html" %} + +{% block content %} + +
+

Log Your Mood

+
+ {% csrf_token %} +
{{ form.effective_date.label_tag }} {{ form.effective_date }}
+
{{ form.time_of_day.label_tag }} {{ form.time_of_day }}
+
+ {{ form.notes.label_tag }} + {{ form.notes }} +
+

Your Metrics

+ {{ formset.management_form }} + {% for form in formset %} +
+ {{ form.metric_type }} + + {% if form.metric_help_text %}{{ form.metric_help_text }}{% endif %} + {{ form.score_value }} + {% if form.score_value.errors %}
{{ form.score_value.errors }}
{% endif %} +
+ {% endfor %} + +
+
+ + + +{% endblock content %} diff --git a/incredible_data/mood/templates/mood/mood_entry_detail.html b/incredible_data/mood/templates/mood/mood_entry_detail.html new file mode 100644 index 0000000..d9cfda8 --- /dev/null +++ b/incredible_data/mood/templates/mood/mood_entry_detail.html @@ -0,0 +1,270 @@ +{% extends "base.html" %} + +{% block content %} + +
+
+
+

Mood Entry

+
+ 📋 + 📊 + Edit + New Entry +
+
+ + {% if entry.notes %} +
+

Notes

+
+
+ {% endif %} +
+

Metrics

+ {% if metrics %} +
+ {% for metric in metrics %} +
+
+ {{ metric.metric_type.name }} + {{ metric.score_value }} +
+ {% if metric.metric_type.help_text %} + {{ metric.metric_type.help_text }} + {% endif %} +
+
+
+
+
+ {{ metric.metric_type.min_value }} + {{ metric.metric_type.max_value }} +
+
+
+ {% endfor %} +
+ {% else %} +

No metrics recorded for this entry.

+ {% endif %} +
+
+
+ + {% if entry.notes %} + + {% endif %} +{% endblock content %} diff --git a/incredible_data/mood/templates/mood/mood_entry_edit.html b/incredible_data/mood/templates/mood/mood_entry_edit.html new file mode 100644 index 0000000..cc75947 --- /dev/null +++ b/incredible_data/mood/templates/mood/mood_entry_edit.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} + +{% block content %} + +
+
+

Edit Mood Entry

+ Cancel +
+
+ {% csrf_token %} +
{{ form.effective_date.label_tag }} {{ form.effective_date }}
+
{{ form.time_of_day.label_tag }} {{ form.time_of_day }}
+
+ {{ form.notes.label_tag }} + {{ form.notes }} +
+

Your Metrics

+ {{ formset.management_form }} + {% for form in formset %} +
+ {{ form.id }} + {{ form.metric_type }} + + {% if form.instance.metric_type.help_text %} + {{ form.instance.metric_type.help_text }} + {% endif %} + {{ form.score_value }} + {% if form.score_value.errors %}
{{ form.score_value.errors }}
{% endif %} +
+ {% endfor %} +
+ + Cancel +
+
+
+ + + +{% endblock content %} diff --git a/incredible_data/mood/templates/mood/mood_entry_list.html b/incredible_data/mood/templates/mood/mood_entry_list.html new file mode 100644 index 0000000..bd93091 --- /dev/null +++ b/incredible_data/mood/templates/mood/mood_entry_list.html @@ -0,0 +1,393 @@ +{% extends "base.html" %} + +{% block content %} + +
+ + {% if entries %} + + {% else %} +
+
📝
+

No entries yet

+

Start tracking your mood by creating your first entry.

+ Create Entry +
+ {% endif %} +
+ + +{% endblock content %} diff --git a/incredible_data/mood/urls.py b/incredible_data/mood/urls.py new file mode 100644 index 0000000..1fc322b --- /dev/null +++ b/incredible_data/mood/urls.py @@ -0,0 +1,18 @@ +from django.urls import path, register_converter +from django.views.generic.base import TemplateView + +from incredible_data.mood import converters, views + +register_converter(converters.EntryIdConverter, "entry") + +app_name = "mood" +# fmt: off +urlpatterns = [ + path("", views.mood_entry_list, name="entry-list"), + path("rating-widget/", views.rating_widget, name="rating_widget"), + path("user-metric-chart/", TemplateView.as_view(template_name="mood/user_metric_chart.html"), name="user_metric_chart"), + path("add/", views.mood_entry, name="entry-create"), + path("/", views.mood_entry_detail, name="entry-detail"), + path("/edit/", views.mood_entry_edit, name="entry-edit"), +] +# fmt: on diff --git a/incredible_data/mood/views.py b/incredible_data/mood/views.py new file mode 100644 index 0000000..dc7a805 --- /dev/null +++ b/incredible_data/mood/views.py @@ -0,0 +1,204 @@ +import logging +from collections import defaultdict +from typing import TYPE_CHECKING, cast + +from django import forms +from django.contrib.auth.decorators import ( + login_required, + permission_required, +) +from django.core.exceptions import PermissionDenied +from django.http import HttpRequest, HttpResponse +from django.shortcuts import redirect, render +from django.template.loader import render_to_string +from django.urls import reverse +from django.utils.safestring import mark_safe +from rest_framework import status +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from incredible_data.mood.api.serializers import ( + ChartSerializer, +) +from incredible_data.mood.forms import EntryForm, MetricForm + +from .models import Entry, Metric, MetricType + +if TYPE_CHECKING: + import datetime as dt + + from rest_framework.request import Request + + from incredible_data.users.models import User + +logger = logging.getLogger(__name__) + + +def rating_widget(request: "HttpRequest") -> "HttpResponse": + query_dict = request.GET + name = query_dict.get("name") + value = int(query_dict.get("value", 0)) + max_rating = int(query_dict.get("max_rating", 5)) + emoji = query_dict.get("emoji", "⭐") + ratings = list(range(1, max_rating + 1)) + html_context = { + "hx_get_url": reverse("mood:rating_widget"), + "name": name, + "value": value, + "ratings": ratings, + "emoji": emoji, + } + widget_html = render_to_string("mood/rating_widget.html", html_context) + script = f"" + return HttpResponse( + mark_safe( # noqa: S308 + f'
{widget_html}
{script}' + ) + ) + + +@api_view(["GET"]) +def user_metric_chart(request: "Request") -> Response: + entry_qs = ( + Entry.objects.filter(created_by=request.user) + .order_by("created_on") + .prefetch_related("metric_set") + ) + + labels: list[dt.date] = list(entry_qs.values_list("effective_date", flat=True)) + + metrics_dict: dict[str, list[dict[str, dt.date | int]]] = defaultdict(list) + + for entry in entry_qs: + for metric in entry.metric_set.all(): + metrics_dict[metric.metric_type.name].append( + { + "date": entry.effective_date, + "value": metric.score_value, + } + ) + + ser = ChartSerializer({"labels": labels, "metrics": metrics_dict}) + + return Response(ser.data, status=status.HTTP_200_OK) # pyright: ignore[reportAny] + + +@login_required +@permission_required("mood.add_entry", raise_exception=True) +def mood_entry(request: "HttpRequest") -> "HttpResponse": + user = cast("User", request.user) + + active_metrics = MetricType.objects.active_for_user(user) + + formset_initial = [{"metric_type": metric} for metric in active_metrics] + + logger.debug("Initial formset_initial: %s", formset_initial) + + # Create a formset with extra set to the number of active metrics + MetricFormSet = forms.inlineformset_factory( # noqa: N806 + Entry, + Metric, + form=MetricForm, + extra=len(active_metrics), + can_delete=False, + ) + + if request.method == "POST": + form = EntryForm(request.POST) + formset = MetricFormSet(request.POST, initial=formset_initial) + + if form.is_valid() and formset.is_valid(): + entry_instance = cast("Entry", form.save(commit=False)) + entry_instance.created_by = user + entry_instance.save() + + formset.instance = entry_instance + formset.save() + + return redirect(entry_instance.get_absolute_url()) + else: + form = EntryForm() + formset = MetricFormSet(initial=formset_initial) + + logger.debug("Initialized MetricFormSet with %d forms", len(formset.forms)) + + return render( + request, + "mood/mood_entry.html", + { + "user": user, + "form": form, + "formset": formset, + }, + ) + + +@login_required +@permission_required("mood.view_entry", raise_exception=True) +def mood_entry_detail(request: "HttpRequest", entry: Entry) -> "HttpResponse": + return render( + request, + "mood/mood_entry_detail.html", + { + "entry": entry, + "metrics": entry.metric_set.all(), + }, + ) + + +@login_required +@permission_required("mood.change_entry", raise_exception=True) +def mood_entry_edit(request: "HttpRequest", entry: Entry) -> "HttpResponse": + user = cast("User", request.user) + + # Ensure user can only edit their own entries + if entry.created_by != user: + msg = "You do not have permission to edit this entry." + raise PermissionDenied(msg) + + # Create a formset with existing metrics + MetricFormSet = forms.inlineformset_factory( # noqa: N806 + Entry, + Metric, + form=MetricForm, + extra=0, + can_delete=False, + ) + + if request.method == "POST": + form = EntryForm(request.POST, instance=entry) + formset = MetricFormSet(request.POST, instance=entry) + + if form.is_valid() and formset.is_valid(): + form.save() + formset.save() + return redirect(entry.get_absolute_url()) + else: + form = EntryForm(instance=entry) + formset = MetricFormSet(instance=entry) + + return render( + request, + "mood/mood_entry_edit.html", + { + "entry": entry, + "form": form, + "formset": formset, + }, + ) + + +@login_required +@permission_required("mood.view_entry", raise_exception=True) +def mood_entry_list(request: "HttpRequest") -> "HttpResponse": + user = cast("User", request.user) + entries = Entry.objects.filter(created_by=user).order_by("-effective_date") + + return render( + request, + "mood/mood_entry_list.html", + { + "user": user, + "entries": entries, + }, + ) diff --git a/incredible_data/mood/widgets.py b/incredible_data/mood/widgets.py new file mode 100644 index 0000000..e04d17f --- /dev/null +++ b/incredible_data/mood/widgets.py @@ -0,0 +1,52 @@ +from typing import TYPE_CHECKING, override + +from django import forms +from django.template.loader import render_to_string +from django.urls import reverse # pyright: ignore[reportUnknownVariableType] +from django.utils.safestring import SafeString, mark_safe + +if TYPE_CHECKING: + from django.forms.widgets import _OptAttrs # pyright: ignore[reportPrivateUsage] + + +class EmojiDisplayWidget(forms.Widget): + """ + A custom widget to display emojis in a form field. + """ + + template_name: str = "mood/rating_widget.html" + max_rating: int = 5 + emoji: str = "⭐" + + def __init__( + self, max_rating: int = 5, emoji: str = "⭐", attrs: "_OptAttrs | None" = None + ) -> None: + self.max_rating = max_rating + self.emoji = emoji + + super().__init__(attrs) + + @override + def render( + self, + name: str, + value: int = 0, + attrs=None, # pyright: ignore[reportMissingParameterType] + renderer=None, # pyright: ignore[reportMissingParameterType] + ) -> SafeString: + ratings = list(range(1, self.max_rating + 1)) + + html_context = { + "hx_get_url": reverse("mood:rating_widget"), + "name": name, + "value": value, + "ratings": ratings, + "emoji": self.emoji, + } + + widget_html = render_to_string(self.template_name, html_context) + + return mark_safe( # noqa: S308 + f'
{widget_html}
' + f'' # pyright: ignore[reportImplicitStringConcatenation] + ) diff --git a/incredible_data/static/css/invoice.css b/incredible_data/static/css/invoice.css new file mode 100644 index 0000000..2727285 --- /dev/null +++ b/incredible_data/static/css/invoice.css @@ -0,0 +1,109 @@ + +.invoice-box { + max-width: 800px; + margin: auto; + padding: 30px; + border: 1px solid #eee; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.15); + font-size: 16px; + line-height: 24px; + font-family: 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; + color: #555; +} + +.invoice-box table { + width: 100%; + line-height: inherit; + text-align: left; +} + +.invoice-box table td { + padding: 5px; + vertical-align: top; +} + +.invoice-box table tr td:nth-child(2) { + text-align: right; +} + +.invoice-box table tr.top table td { + padding-bottom: 20px; +} + +.invoice-box table tr.top table td.title { + font-size: 45px; + line-height: 45px; + color: #333; +} + +.invoice-box table tr.information table td { + padding-bottom: 40px; +} + +.invoice-box table tr.heading td { + background: #eee; + border-bottom: 1px solid #ddd; + font-weight: bold; +} + +.invoice-box table tr.details td { + padding-bottom: 20px; +} + +.invoice-box table tr.item td { + border-bottom: 1px solid #eee; +} + +.invoice-box table tr.item.last td { + border-bottom: none; +} + +.invoice-box table tr.total td:nth-child(2) { + border-top: 2px solid #eee; + font-weight: bold; +} + +.invoice-box table.terms-field { + border: 2px solid #eee; +} + +@media only screen and (max-width: 600px) { + .invoice-box table tr.top table td { + width: 100%; + display: block; + text-align: center; + } + + .invoice-box table tr.information table td { + width: 100%; + display: block; + text-align: center; + } +} + +/** RTL **/ +.invoice-box.rtl { + direction: rtl; + font-family: Tahoma, 'Helvetica Neue', 'Helvetica', Helvetica, Arial, sans-serif; +} + +.invoice-box.rtl table { + text-align: right; +} + +.invoice-box.rtl table tr td:nth-child(2) { + text-align: left; +} + +img.logo { + width: 100%; + max-width: 300px; +} + +@media print { + .invoice-box { + max-width: unset; + box-shadow: none; + border: 0px; + } +} diff --git a/incredible_data/static/css/project.css b/incredible_data/static/css/project.css index 1b95aca..a9aec8b 100644 --- a/incredible_data/static/css/project.css +++ b/incredible_data/static/css/project.css @@ -15,3 +15,98 @@ .text-magenta { color: mediumvioletred; } + +.bold-highlight { + font-weight: bold; + color: darkblue; +} + +.bold-secondary { + font-weight: bold; + color: lightseagreen; +} + +.related-table-label { + color: var(--colorsecondary); +} + +.check-mark { + color: #33cc33; +} + +.x-mark { + color: black; +} + +.header-table { + border: none; + padding: 5px; +} + +.request-frontmatter-header { + --bs-text-opacity: 1; + color: #ff5532; + text-align: center; +} + +.table-request-frontmatter th { + --bs-text-opacity: 1; + color: #ff5532; + text-align: center; +} + +.table-request-footer th { + --bs-text-opacity: 1; + color: #ff5532; + text-align: start; +} + +.table-request-footer td { + text-align: center; + font-family: 'Brush Script MT', cursive; + font-size: x-large; +} + +.table-request th { + text-align: center; + color: #ff5532 +} + +.title { + color: var(--colorprimary); +} + +.field-label { + font-weight: bold; + color: crimson; +} + +.table-container th a { + color: var(--colorprimary); +} + +.table-container td a { + color: var(--colorprimary); +} + +.card-app { + border-color: darkcyan; +} + +.card-app-header { + color: var(--colorprimary); + text-align: center; + font-weight: bold; +} + +.footer { + width: 100%; +} + +.navbar-padding { + padding-top: 59px; +} + +.navbar-version { + padding-right: 10px; +} diff --git a/incredible_data/static/images/favicons/favicon.ico b/incredible_data/static/images/favicons/favicon.ico index e1c1dd1..3293aec 100644 Binary files a/incredible_data/static/images/favicons/favicon.ico and b/incredible_data/static/images/favicons/favicon.ico differ diff --git a/incredible_data/templates/actions_partial.html b/incredible_data/templates/actions_partial.html new file mode 100644 index 0000000..abc3f33 --- /dev/null +++ b/incredible_data/templates/actions_partial.html @@ -0,0 +1,5 @@ +
+ {% for action in action_links %} + {{ action.label }} + {% endfor %} +
\ No newline at end of file diff --git a/incredible_data/templates/base.html b/incredible_data/templates/base.html index 536a2fa..aca7f85 100644 --- a/incredible_data/templates/base.html +++ b/incredible_data/templates/base.html @@ -18,13 +18,12 @@ {% block css %} + href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.13.1/font/bootstrap-icons.min.css" /> {% compress css %} @@ -36,8 +35,7 @@ {% block javascript %} @@ -76,13 +74,13 @@ {% endblock modal %} {% block inline_javascript %} {% comment %} - Script tags with only code, no src (defer by default). To run - with a "defer" so that you run inline code: - + Script tags with only code, no src (defer by default). To run + with a "defer" so that you run inline code: + {% endcomment %} {% endblock inline_javascript %} diff --git a/incredible_data/templates/base_list_tables2.html b/incredible_data/templates/base_list_tables2.html index 70cb87b..e8d43d9 100644 --- a/incredible_data/templates/base_list_tables2.html +++ b/incredible_data/templates/base_list_tables2.html @@ -2,16 +2,15 @@ {% load render_table from django_tables2 %} +{% block title %} + {{ view_title|title }} +{% endblock title %} {% block content %} -

- {% block title %} - {{ view_title }} - {% endblock title %} +

+ {% block card_title %} + {{ view_title|title }} + {% endblock card_title %}

-
- {% for action in action_links %} - {{ action.label }} - {% endfor %} -
+ {% include "actions_partial.html" %} {% render_table table %} {% endblock content %} diff --git a/incredible_data/templates/business/detail.html b/incredible_data/templates/business/detail.html new file mode 100644 index 0000000..1735719 --- /dev/null +++ b/incredible_data/templates/business/detail.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% load render_table from django_tables2 %} +{% load i18n %} + +{% block title %} + {{ object_context_name|title }} {{ object }} +{% endblock title %} +{% block content %} +

+ {% block card_title %} + {{ object_context_name|title }} {{ object }} + {% endblock card_title %} +

+ {% include "actions_partial.html" %} + {% block fields %} +
    + {% for field in fields %}
  • {{ field }}
  • {% endfor %} + {% if object.created %} +
  • + Created By {{ object.created_by }} on {{ object.created|date }} at {{ object.created|time }} ({{ object.created|timesince }} ago) +
  • + {% endif %} + {% if object.modified %} +
  • + Modified By {{ object.modified_by }} on {{ object.modified|date }} at {{ object.modified|time }} ({{ object.modified|timesince }} ago) +
  • + {% endif %} +
+ {% endblock fields %} + {% if inlines %} +
+ +
+ {% endif %} +{% endblock content %} diff --git a/incredible_data/templates/business/invoice.html b/incredible_data/templates/business/invoice.html index 451b73b..cf1b18d 100644 --- a/incredible_data/templates/business/invoice.html +++ b/incredible_data/templates/business/invoice.html @@ -3,201 +3,96 @@ - - - A simple, clean, and responsive HTML invoice template - - - -
- - - - - - - -
- - - - - -
- - - Invoice #: {{ invoice.number }} -
- Created: {{ invoice.created|date:"F j, Y" }} -
- Due: {{ invoice.due_date|date:"F j, Y" }} -
-
- - - - - -
- Messy House Creations -
- 7711 Thetis Dr -
- Pasco, WA 99301 -
- {{ invoice.customer.organization_name }} -
- {{ invoice.customer.contact_name }} -
- {{ invoice.customer.contact_email }} -
-
- - - - - - - - - {% for line in invoice.invoiceline_set.all|dictsort:"rank" %} - - - - - - - - {% endfor %} - - - - - -
DescriptionQuantityUnit PriceExtended Price
{{ line.rank }}{{ line.description }}{{ line.quantity|floatformat:"2g" }}{{ line.unit_price|usd_accounting }}{{ line.extended_price|usd_accounting }}
Total:{{ invoice.grand_total|usd_accounting }}
- - - - - - - - - - - - - -
Notes
{{ invoice.notes }}
Terms
{{ invoice.terms }}
-
- - + + + {{ invoice }} + + + + + +
+ + + + + + + +
+ + + + + +
+ + + Invoice #: {{ invoice.number }} +
+ Created: {{ invoice.created|date:"F j, Y" }} +
+ Due: {{ invoice.due_date|date:"F j, Y" }} +
+
+ + + + + +
+ Messy House Creations +
+ 7711 Thetis Dr +
+ Pasco, WA 99301 +
+ {{ invoice.customer.name }} +
+ {{ invoice.customer.primary_contact__contact__name }} +
+ {{ invoice.customer.contact_email }} +
+
+ + + + + + + + + {% for line in invoice.invoiceline_set.all|dictsort:"rank" %} + + + + + + + + {% endfor %} + + + + + +
DescriptionQuantityUnit PriceExtended Price
{{ line.rank }}{{ line.description }}{{ line.quantity|floatformat:"2g" }}{{ line.unit_price|usd_accounting }}{{ line.extended_price|usd_accounting }}
Total:{{ invoice.grand_total|usd_accounting }}
+ + + + + + + + + + + + + +
Notes
{{ invoice.notes }}
Terms
{{ invoice.terms }}
+
+ + + \ No newline at end of file diff --git a/incredible_data/templates/field_base.html b/incredible_data/templates/field_base.html new file mode 100644 index 0000000..6a223d6 --- /dev/null +++ b/incredible_data/templates/field_base.html @@ -0,0 +1,2 @@ +{{ label|title }}: +
{{ value }}
diff --git a/incredible_data/templates/field_textarea.html b/incredible_data/templates/field_textarea.html new file mode 100644 index 0000000..42ed44e --- /dev/null +++ b/incredible_data/templates/field_textarea.html @@ -0,0 +1,4 @@ +{{ label|title }}: +{% spaceless %} +
{{ value|linebreaks }}
+{% endspaceless %} diff --git a/incredible_data/templates/mood/rating_widget.html b/incredible_data/templates/mood/rating_widget.html new file mode 100644 index 0000000..bb06b38 --- /dev/null +++ b/incredible_data/templates/mood/rating_widget.html @@ -0,0 +1,9 @@ +{% load loop_extras %} + +{% for i in ratings %} + +{% endfor %} diff --git a/incredible_data/templates/mood/user_metric_chart.html b/incredible_data/templates/mood/user_metric_chart.html new file mode 100644 index 0000000..8dfad0b --- /dev/null +++ b/incredible_data/templates/mood/user_metric_chart.html @@ -0,0 +1,44 @@ +{% extends "base.html" %} + +{% block content %} +

Metric Values Over Time

+ + + + + +{% endblock content %} diff --git a/incredible_data/templates/navbar.html b/incredible_data/templates/navbar.html index 573eb85..7b63ef5 100644 --- a/incredible_data/templates/navbar.html +++ b/incredible_data/templates/navbar.html @@ -18,15 +18,42 @@ - {% if perms.bins %} - + {% if request.user.is_authenticated %} + {% if perms.bins %} + + {% endif %} + {% if perms.business %} + + + + {% endif %} {% endif %} - - {% if request.user.is_staff %}