├── .editorconfig ├── .flake8 ├── .github ├── dependabot.yml └── workflows │ └── project_ci.yml ├── .gitignore ├── .pylintrc ├── .readthedocs.yaml ├── .vscode └── settings.json ├── README.md ├── copier.yml ├── django_hydra.py ├── docs ├── Makefile ├── make.bat └── source │ ├── conf.py │ ├── contributing.rst │ ├── cookbook.rst │ ├── debugging.rst │ ├── deployment.rst │ ├── index.rst │ ├── prerequisites.rst │ ├── setup.rst │ ├── structure.rst │ └── testing.rst ├── pyproject.toml ├── scripts ├── export_project.py └── mac_intel_install.sh ├── template ├── .dockerignore.jinja ├── .editorconfig ├── .github │ ├── dependabot.yml │ ├── pull_request_template.md │ └── workflows │ │ ├── django_ci.yml.jinja │ │ └── fly-deploy.yml ├── .gitignore ├── .ignore ├── .pre-commit-config.yaml.jinja ├── .stylelintrc ├── .vscode │ ├── extensions.json │ ├── launch.json.jinja │ ├── settings.json │ └── tasks.json ├── .watchmanconfig.jinja ├── Dockerfile.jinja ├── README.md.jinja ├── [[ _copier_conf.answers_file ]].jinja ├── [[project_name]] │ ├── __init__.py │ ├── components │ │ ├── __init__.py │ │ ├── alert │ │ │ ├── alert.html │ │ │ └── alert.py │ │ ├── button │ │ │ ├── button.html │ │ │ └── button.py │ │ ├── footer │ │ │ ├── footer.py │ │ │ ├── footer_nav_link.html │ │ │ └── social_link.html │ │ ├── form │ │ │ ├── checkbox.html │ │ │ ├── flatpickr.html │ │ │ ├── form.html │ │ │ ├── form.py │ │ │ ├── input.html │ │ │ ├── label.html │ │ │ ├── radio.html │ │ │ ├── toggle.html │ │ │ └── widgets │ │ │ │ ├── checkbox_select.html │ │ │ │ ├── date.html │ │ │ │ ├── datetime.html │ │ │ │ ├── input.html │ │ │ │ ├── password.html │ │ │ │ ├── radio.html │ │ │ │ ├── select.html │ │ │ │ └── textarea.html │ │ ├── header │ │ │ ├── header.py │ │ │ └── profile-popover.html │ │ ├── link │ │ │ ├── link.html │ │ │ └── link.py │ │ ├── modal │ │ │ ├── modal.html │ │ │ └── modal.py │ │ ├── popover │ │ │ ├── popover.html │ │ │ └── popover.py │ │ ├── svg │ │ │ ├── alpine.svg │ │ │ ├── apple.svg │ │ │ ├── discord.svg │ │ │ ├── django.svg │ │ │ ├── dribbble.svg │ │ │ ├── facebook.svg │ │ │ ├── github.svg │ │ │ ├── google.svg │ │ │ ├── instagram.svg │ │ │ ├── linkedin.svg │ │ │ ├── signal.svg │ │ │ ├── slack.svg │ │ │ ├── svg.py │ │ │ └── twitter.svg │ │ └── tabs │ │ │ ├── tabs.html │ │ │ └── tabs.py │ ├── config │ │ ├── __init__.py │ │ ├── asgi.py.jinja │ │ ├── settings │ │ │ ├── __init__.py │ │ │ ├── base.py.jinja │ │ │ ├── build.py │ │ │ ├── local.py │ │ │ ├── prod.py.jinja │ │ │ └── test.py │ │ ├── urls.py.jinja │ │ ├── websocket.py │ │ └── wsgi.py.jinja │ ├── home │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── apps.py.jinja │ │ ├── forms.py.jinja │ │ ├── management │ │ │ ├── __init__.py │ │ │ └── commands │ │ │ │ └── __init__.py │ │ ├── migrations │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── signals.py │ │ ├── templatetags │ │ │ └── replace.py │ │ ├── tests.py │ │ ├── urls.py │ │ └── views.py │ ├── static_source │ │ ├── .keep │ │ ├── css │ │ │ ├── _tailwind.scss │ │ │ ├── admin.js │ │ │ ├── admin.scss │ │ │ ├── base │ │ │ │ ├── _colors.scss │ │ │ │ ├── _flatpickr.scss │ │ │ │ ├── _fonts.scss │ │ │ │ ├── _forms.scss │ │ │ │ ├── _index.scss │ │ │ │ └── _typography.scss │ │ │ ├── components │ │ │ │ ├── _alert.scss │ │ │ │ ├── _buttons.scss │ │ │ │ ├── _index.scss │ │ │ │ ├── _links.scss │ │ │ │ └── _messages.scss │ │ │ ├── site.scss │ │ │ └── styles.js │ │ ├── img │ │ │ ├── favicons │ │ │ │ ├── favicon.ico │ │ │ │ └── favicon.svg │ │ │ ├── loading.svg │ │ │ ├── logo.svg │ │ │ └── logomark.svg │ │ └── js │ │ │ ├── alpinejs__ui.d.ts │ │ │ ├── components.ts │ │ │ ├── forms │ │ │ ├── common.ts │ │ │ ├── date_datetime.ts │ │ │ ├── input.ts │ │ │ └── select.js │ │ │ ├── global.d.ts │ │ │ ├── index.ts │ │ │ ├── links.ts │ │ │ ├── main.ts.jinja │ │ │ └── test │ │ │ ├── links.test.ts │ │ │ └── main.test.ts │ ├── templates │ │ ├── 400.html │ │ ├── 403.html │ │ ├── 404.html │ │ ├── 500.html │ │ ├── account │ │ │ ├── account_base.html │ │ │ ├── account_inactive.html │ │ │ ├── login.html │ │ │ ├── logout.html │ │ │ ├── password_reset.html │ │ │ ├── password_reset_done.html │ │ │ ├── password_reset_from_key.html │ │ │ ├── password_reset_from_key_done.html │ │ │ ├── signup.html │ │ │ └── snippets │ │ │ │ ├── already_logged_in.html │ │ │ │ └── social_login_buttons.html │ │ ├── admin │ │ │ └── base_site.html.jinja │ │ ├── base.html │ │ ├── components │ │ │ └── open_graph_tags.html │ │ ├── django │ │ │ └── forms │ │ │ │ └── readme.txt │ │ ├── footer.html.jinja │ │ ├── header │ │ │ ├── base.html │ │ │ ├── desktop_center.html │ │ │ ├── end.html │ │ │ ├── header_link.html │ │ │ ├── logo.html │ │ │ ├── mobile_menu.html │ │ │ ├── mobile_menu_button.html │ │ │ └── profile_menu_item.html │ │ ├── home │ │ │ └── form_test.html │ │ ├── index.html │ │ ├── messages.html │ │ └── samples │ │ │ └── current_time.html │ ├── tests.py │ ├── user │ │ ├── __init__.py │ │ ├── adapter.py │ │ ├── admin.py │ │ ├── apps.py.jinja │ │ ├── baker_recipes.py │ │ ├── forms.py │ │ ├── migrations │ │ │ ├── 0001_initial.py.jinja │ │ │ └── __init__.py │ │ ├── models.py │ │ ├── tests.py.jinja │ │ ├── urls.py │ │ └── views.py │ └── util │ │ ├── __init__.py │ │ ├── apps.py.jinja │ │ ├── middleware.py │ │ ├── migrations │ │ ├── 0001_initial.py.jinja │ │ └── __init__.py │ │ ├── models.py │ │ ├── tests.py │ │ ├── util.py │ │ └── widgets.py ├── build.sh ├── conftest.py ├── eslint.config.mjs ├── fly.toml.jinja ├── gunicorn.conf.py.jinja ├── manage.py.jinja ├── mise-tasks │ ├── db │ │ ├── check │ │ ├── create-user │ │ └── setup │ └── utils │ │ └── colors ├── mise.ci.toml ├── mise.toml.jinja ├── package-lock.json.jinja ├── package.json.jinja ├── postcss.config.js ├── pyproject.toml.jinja ├── render.yaml.jinja ├── scripts │ ├── create_patch.sh.jinja │ ├── export_project.py │ └── pull_remote_db.sh.jinja ├── tailwind.config.js.jinja ├── tsconfig.json.jinja ├── uv.lock ├── vite.config.mjs.jinja └── vitest.config.ts ├── test.sh └── todo.txt /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 4 15 | 16 | [*.{js,html,css,yml,xml,scss,json}] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E402 3 | exclude = 4 | max-complexity = 10 -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # TODO: Add `github-action` ecosystem check when PR #171 lands 2 | 3 | version: 2 4 | updates: 5 | - package-ecosystem: "pip" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | target-branch: "develop" 10 | # Limit pull requests to 1 so Dependabot isn't constantly rebasing PRs. 11 | # https://github.com/python-poetry/poetry/issues/496 12 | open-pull-requests-limit: 1 13 | 14 | - package-ecosystem: "pip" 15 | directory: "/[[project_name]]" 16 | schedule: 17 | interval: "weekly" 18 | target-branch: "develop" 19 | open-pull-requests-limit: 1 20 | 21 | - package-ecosystem: "npm" 22 | directory: "/[[project_name]]" 23 | schedule: 24 | interval: "weekly" 25 | target-branch: "develop" 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | static/ 2 | *_flymake* 3 | *.\#* 4 | webpack-stats.json 5 | 6 | 7 | # Byte-compiled / optimized / DLL files 8 | __pycache__/ 9 | *.py[cod] 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | env/ 17 | build/ 18 | develop-eggs/ 19 | dist/ 20 | eggs/ 21 | lib/ 22 | lib64/ 23 | parts/ 24 | sdist/ 25 | var/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | 30 | # environment variables 31 | .env 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | .tox/ 39 | .coverage 40 | .cache 41 | nosetests.xml 42 | coverage.xml 43 | htmlcov/ 44 | 45 | 46 | # Translations 47 | *.mo 48 | 49 | # Mr Developer 50 | .mr.developer.cfg 51 | .project 52 | .pydevproject 53 | 54 | # Rope 55 | .ropeproject 56 | 57 | # Django stuff: 58 | *.log 59 | *.pot 60 | 61 | # Sphinx documentation 62 | docs/_build/ 63 | 64 | 65 | #django media folder 66 | media/ 67 | 68 | #PYSCSS trash 69 | .sass-cache/ 70 | *style.css 71 | *static_source/sass/style.css* 72 | 73 | #VIM 74 | *.swp 75 | ### vim ### 76 | [._]*.s[a-w][a-z] 77 | [._]s[a-w][a-z] 78 | *.un~ 79 | Session.vim 80 | .netrwhist 81 | *~ 82 | 83 | 84 | # Created by http://www.gitignore.io 85 | 86 | ### Emacs ### 87 | # -*- mode: gitignore; -*- 88 | *~ 89 | \#*\# 90 | /.emacs.desktop 91 | /.emacs.desktop.lock 92 | *.elc 93 | auto-save-list 94 | tramp 95 | .\#* 96 | 97 | # Org-mode 98 | .org-id-locations 99 | *_archive 100 | 101 | # flymake-mode 102 | *_flymake.* 103 | 104 | # eshell files 105 | /eshell/history 106 | /eshell/lastdir 107 | 108 | # elpa packages 109 | /elpa/ 110 | 111 | # reftex files 112 | *.rel 113 | 114 | # AUCTeX auto folder 115 | /auto/ 116 | 117 | 118 | # Created by http://www.gitignore.io 119 | 120 | ### SublimeText ### 121 | # workspace files are user-specific 122 | *.sublime-workspace 123 | 124 | # project files should be checked into the repository, unless a significant 125 | # proportion of contributors will probably not be using SublimeText 126 | # *.sublime-project 127 | 128 | #sftp configuration file 129 | sftp-config.json 130 | 131 | 132 | # Created by http://www.gitignore.io 133 | 134 | ### OSX ### 135 | .DS_Store 136 | .AppleDouble 137 | .LSOverride 138 | 139 | # Icon must end with two \r 140 | Icon 141 | 142 | # mailhog 143 | /node_modules 144 | node/mailhog/mailhog 145 | 146 | 147 | # Thumbnails 148 | ._* 149 | 150 | # Files that might appear on external disk 151 | .Spotlight-V100 152 | .Trashes 153 | 154 | # Directories potentially created on remote AFP share 155 | .AppleDB 156 | .AppleDesktop 157 | Network Trash Folder 158 | Temporary Items 159 | .apdisk 160 | 161 | 162 | # Created by http://www.gitignore.io 163 | 164 | ### Linux ### 165 | *~ 166 | 167 | # KDE directory preferences 168 | .directory 169 | 170 | 171 | ### PyCharm ### 172 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 173 | 174 | *.iml 175 | 176 | ## Directory-based project format: 177 | .idea/ 178 | # if you remove the above rule, at least ignore the following: 179 | 180 | # User-specific stuff: 181 | # .idea/workspace.xml 182 | # .idea/tasks.xml 183 | # .idea/dictionaries 184 | 185 | # Sensitive or high-churn files: 186 | # .idea/dataSources.ids 187 | # .idea/dataSources.xml 188 | # .idea/sqlDataSources.xml 189 | # .idea/dynamic.xml 190 | # .idea/uiDesigner.xml 191 | 192 | # Gradle: 193 | # .idea/gradle.xml 194 | # .idea/libraries 195 | 196 | # Mongo Explorer plugin: 197 | # .idea/mongoSettings.xml 198 | 199 | ## File-based project format: 200 | *.ipr 201 | *.iws 202 | 203 | ## Plugin-specific files: 204 | 205 | # IntelliJ 206 | out/ 207 | 208 | # mpeltonen/sbt-idea plugin 209 | .idea_modules/ 210 | 211 | # JIRA plugin 212 | atlassian-ide-plugin.xml 213 | 214 | # Crashlytics plugin (for Android Studio and IntelliJ) 215 | com_crashlytics_export_strings.xml 216 | crashlytics.properties 217 | crashlytics-build.properties 218 | testapp/ 219 | 220 | 221 | # gunicorn pid file 222 | gunicorn.pid 223 | junit.xml 224 | 225 | #yarn stuf 226 | .yarn/* 227 | !.yarn/cache 228 | !.yarn/releases 229 | !.yarn/plugins 230 | !.yarn/sdks 231 | !.yarn/versions 232 | 233 | #project export for ai 234 | output.xml 235 | 236 | #stylelint cache 237 | .stylelintcache 238 | 239 | 240 | .mypy_cache/ 241 | TAGS 242 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yaml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | version: 2 5 | 6 | build: 7 | os: ubuntu-20.04 8 | tools: 9 | python: "3" 10 | jobs: 11 | post_install: 12 | - pip install poetry 13 | - poetry config virtualenvs.create false 14 | - poetry install --with docs 15 | 16 | sphinx: 17 | configuration: docs/source/conf.py 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports": true 5 | } 6 | }, 7 | "editor.rulers": [92], 8 | "eslint.workingDirectories": ["."], 9 | "files.insertFinalNewline": true, 10 | "files.trimFinalNewlines": true, 11 | "files.trimTrailingWhitespace": true, 12 | "prettier.configPath": ".prettierrc.json", 13 | "python.formatting.provider": "black", 14 | "python.linting.flake8Args": [ 15 | "--extend-exclude", 16 | "README.rst,README.md,*/settings/*,*/migrations/*", 17 | "--extend-ignore", 18 | "S322,W503,E5110,S101", 19 | "--format", 20 | "grouped", 21 | "--max-line-length", 22 | "92", 23 | "--show-source" 24 | ], 25 | "python.linting.flake8Enabled": true, 26 | "python.testing.pytestEnabled": true, 27 | "python.testing.unittestEnabled": false, 28 | "scss.lint.unknownAtRules": "ignore", 29 | "dotenv.enableAutocloaking": false, 30 | } 31 | -------------------------------------------------------------------------------- /copier.yml: -------------------------------------------------------------------------------- 1 | project_name: 2 | type: str 3 | help: >- 4 | Project name - used as both the Python package name and service name. 5 | Must be lowercase, start with a letter, and use only letters and underscores. 6 | Examples: my_project, awesome_django_app 7 | default: "sampleapp" 8 | validator: >- 9 | [% if not project_name %] 10 | Project name cannot be empty 11 | [% elif not (project_name | regex_search('^[a-z][a-z_]*[a-z]$')) %] 12 | Project name must be lowercase, start and end with a letter, and contain only letters and underscores 13 | [% elif '_-' in project_name or '-_' in project_name or '__' in project_name %] 14 | Project name cannot contain consecutive separators 15 | [% elif project_name | length > 50 %] 16 | Project name must be less than 50 characters 17 | [% endif %] 18 | 19 | project_name_verbose: 20 | type: str 21 | help: Human-friendly project name 22 | default: "[[ project_name | replace('_', ' ') | title ]]" 23 | 24 | author_name: 25 | type: str 26 | help: Author's name for pyproject and django admins 27 | default: Anonymous 28 | 29 | domain_name: 30 | type: str 31 | help: Production domain name 32 | default: example.com 33 | 34 | author_email: 35 | type: str 36 | help: Author's email for pyproject and django admins 37 | default: "[[ author_name | lower | replace(' ', '-') ]]@[[domain_name]]" 38 | 39 | description: 40 | type: str 41 | multiline: true 42 | help: A short description of the project 43 | default: A short description of the project. 44 | 45 | version: 46 | type: str 47 | help: Initial version number 48 | default: 0.1.0 49 | 50 | _subdirectory: template 51 | 52 | # Templates Customization 53 | _envops: 54 | block_end_string: "%]" 55 | block_start_string: "[%" 56 | comment_end_string: "#]" 57 | comment_start_string: "[#" 58 | keep_trailing_newline: true 59 | variable_end_string: "]]" 60 | variable_start_string: "[[" 61 | 62 | _tasks: 63 | - "chmod +x manage.py" 64 | - "chmod +x scripts/*.sh" 65 | - "mise trust" 66 | 67 | _message_after_copy: | 68 | Project successfully created! 69 | For new projects, run `mise new-project` from the project root to: 70 | - setup database 71 | - install python packages 72 | - install node packages 73 | -------------------------------------------------------------------------------- /django_hydra.py: -------------------------------------------------------------------------------- 1 | # This exists purely so `poetry build` will work 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = "Hydra" 10 | copyright = "2023, Lightmatter Team" 11 | author = "Lightmatter Team" 12 | release = "3.0" 13 | 14 | # -- General configuration --------------------------------------------------- 15 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 16 | 17 | extensions = [] 18 | 19 | templates_path = ["_templates"] 20 | exclude_patterns = [] 21 | 22 | 23 | # -- Options for HTML output ------------------------------------------------- 24 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 25 | 26 | html_theme = "furo" 27 | html_static_path = ["_static"] 28 | -------------------------------------------------------------------------------- /docs/source/contributing.rst: -------------------------------------------------------------------------------- 1 | Contributing to the Template 2 | ============================ 3 | 4 | 5 | 1. Ensure git is configured globally to use ``main`` as the default branch name. 6 | 7 | .. code-block:: console 8 | 9 | $ git config --global init.defaultBranch main 10 | 11 | 12 | 2. Follow the steps in :ref:`setup` to create a new project. 13 | 14 | 3. Make your changes in this new project, then commit to git on a new feature branch. 15 | 16 | 4. From this project's directory (default ``django-hydra``) run retrocookie. 17 | 18 | This will attempt to take the git diff of the prior commit and apply it back to the template. 19 | 20 | .. code-block:: console 21 | 22 | $ poetry shell # enter the poetry virtual env first 23 | $ retrocookie --branch=your-branch-name ../your-project-name 24 | 25 | 26 | .. warning:: 27 | 28 | When adding new dependencies to a project, always delete the `poetry.lock` file and recreate it before committing, otherwise it won't merge correctly. 29 | 30 | Additionally, retrocookie does not currently support ignoring jinja syntax. Therefore you will need to manually backport any changes to jinja templates. 31 | 32 | The documentation for retrocookie is here: https://pypi.org/project/retrocookie/ 33 | 34 | 35 | Upcoming Features 36 | ================= 37 | 38 | Things we still want to do 39 | 40 | * caching everything possible (middleware for sure) 41 | * user useradmin 42 | * django-secure 43 | * django robots 44 | * user feedback 45 | * add django password validators 46 | * Front end updates 47 | * SEO compatibility scrub 48 | * Accessibility compatibility scrub 49 | -------------------------------------------------------------------------------- /docs/source/debugging.rst: -------------------------------------------------------------------------------- 1 | Local Development & Debugging 2 | ============================== 3 | 4 | Running the local development servers 5 | -------------------------------------- 6 | 7 | This app uses vite to compile/transpile assets. The app is equipped to be served from `127.0.0.1:8000` or `localhost:8000`. 8 | 9 | First run the python server: 10 | 11 | .. code-block:: console 12 | 13 | $ ./manage.py runserver_plus 14 | 15 | Then in a new tab, run the vite server: 16 | 17 | .. code-block:: console 18 | 19 | $ npm run dev 20 | 21 | Debugging 22 | ---------- 23 | 24 | To access a python shell pre-populated with Django models and local env: 25 | 26 | .. code-block:: console 27 | 28 | $ ./manage.py shell_plus 29 | 30 | To add a breakpoint in your python code, add the following code to your `.bashrc` or `.zshrc`: 31 | 32 | .. code-block:: console 33 | 34 | $ export PYTHONBREAKPOINT="pudb.set_trace" 35 | 36 | Then add the following to your python code: 37 | 38 | .. code-block:: python 39 | 40 | breakpoint() 41 | 42 | If the above fails or you prefer a more immediate solution, you can add the following to your code: 43 | 44 | .. code-block:: python 45 | 46 | import pudb; pu.db 47 | 48 | As an alternative to pudb and its debugger, this project also has the IPython debugger (ipdb). You can access ipdb by adding the following to your code: 49 | 50 | .. code-block:: python 51 | 52 | import ipdb; 53 | ipdb.set_trace() 54 | 55 | For ease of local development, `icecream `_ is preconfigured and ready to use. 56 | 57 | Logging 58 | ------- 59 | 60 | Logging is configured in the base.py settings. To use the logger in your backend code you can add the following: 61 | 62 | .. code-block:: python 63 | 64 | import logging 65 | 66 | logger = logging.getLogger(__name__) 67 | 68 | logger.info("this is an info level log") 69 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | .. Hydra documentation master file, created by 2 | sphinx-quickstart on Thu Sep 22 09:37:05 2022. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Hydra 7 | ======================================================= 8 | 9 | About 10 | ----- 11 | 12 | Hydra is a robust project template which uses Django 4 on the backend and HTMX, AlpineJS, and Tailwind on the frontend. 13 | 14 | This combination of technologies means: 15 | 16 | - You'll spend less time writing custom Javascript 17 | - Keep frontend code near the locality of behavior 18 | - You'll leverage the strengths of both Django and consise templates to render content quickly and easily 19 | - You'll be easily able to extend this template for customized use cases 20 | - But perhaps the best thing about Hydra is that once you're familiar with it, *it's just fun to use*! 21 | 22 | .. toctree:: 23 | :maxdepth: 1 24 | :caption: Contents: 25 | 26 | prerequisites 27 | setup 28 | structure 29 | testing 30 | debugging 31 | cookbook 32 | deployment 33 | contributing 34 | -------------------------------------------------------------------------------- /docs/source/testing.rst: -------------------------------------------------------------------------------- 1 | Testing 2 | ======== 3 | 4 | Testing the Template 5 | --------------------- 6 | 7 | To ensure that your template is working, you can run the :code:`test.sh` script. 8 | The :code:`test.sh` will do a run of the template, and then run the django tests and `prospector `_ against it. 9 | 10 | .. code-block:: console 11 | 12 | $ test.sh keepenv 13 | 14 | .. note:: 15 | If you do not pass the argument keepenv, it will delete the old virtualenvironment. If you want to do this, simply run: 16 | 17 | .. code-block:: console 18 | 19 | $ test.sh 20 | 21 | Testing/Validation within your Project 22 | --------------------------------------- 23 | 24 | 25 | This will be run automatically when you attempt to commit code but if you want to manually validate/fix your code syntax during development you can run: 26 | 27 | .. code-block:: console 28 | 29 | $ poetry run pre-commit run --all-files 30 | 31 | This project uses the `pytest `_ framework with `pytest-django `_ enabling Django tests and `pytest-playwright `_ for end-to-end testing. This replaces the default Django tests using unittest. 32 | 33 | Django tests can be run by running: 34 | 35 | .. code-block:: console 36 | 37 | $ ./manage.py test 38 | 39 | 40 | .. warning:: 41 | When doing one of the following, be sure to build Vite assets before running tests: 42 | 43 | * Initializing the project manually 44 | * Adding a new Vite asset (see :ref:`new_vite_assets`) 45 | 46 | To build the assets run: 47 | 48 | .. code-block:: console 49 | 50 | $ npm run build 51 | 52 | If you don't run this command before tests run, some tests may fail even if they would 53 | normally pass. 54 | 55 | Pytest 56 | ****** 57 | 58 | While pytest is backwards-compatible with unittest, there are some key differences that implementers need to understand. If you're new to pytest in Django or playwright testing, reviewing the documentation for these libraries is well worth the time. 59 | 60 | 61 | Playwright 62 | ********** 63 | 64 | `Playwright `_ allows for robust frontend testing across browser engines 65 | 66 | Of note is Playwright's `codegen `_ feature, which allows you to perform actions in the browser and have Playwright generate the code to perform those actions automatically. 67 | 68 | Rarely is codegen's generated code production ready immediately after recording, but it will get you most of the way through your end-to-end testing. 69 | 70 | `Coverage.py `_ can come in handy here in ensuring that the tests you write cover all of the code you write. 71 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-hydra" 3 | version = "3.0" 4 | description = "A generic project template for django" 5 | authors = ["Ben Beecher "] 6 | 7 | [tool.poetry.dependencies] 8 | python = "^3.11" 9 | 10 | [tool.poetry.group.dev.dependencies] 11 | isort = "^5.13.2" 12 | 13 | [tool.poetry.group.docs.dependencies] 14 | Sphinx = "^8.1.3" 15 | furo = "^2024.7.18" 16 | 17 | [build-system] 18 | requires = ["poetry-core>=1.0.0"] 19 | build-backend = "poetry.core.masonry.api" 20 | -------------------------------------------------------------------------------- /scripts/export_project.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import html 3 | import os 4 | from fnmatch import fnmatch 5 | from pathlib import Path 6 | 7 | MANUAL_EXCLUDES = { 8 | "uv.lock", 9 | ".pytest_cache", 10 | ".coverage", 11 | ".ruff_cache", 12 | "dist", 13 | "build", 14 | "__pycache__", 15 | "*.pyc", 16 | "node_modules", 17 | "package-lock.json", 18 | ".venv", 19 | ".git", 20 | "static_source/assets/*", 21 | "*.svg", 22 | "docs/*", 23 | "todo.txt", 24 | } 25 | 26 | 27 | def parse_gitignore(gitignore_path: Path) -> set[str]: 28 | if not gitignore_path.exists(): 29 | return set() 30 | 31 | patterns = set() 32 | with open(gitignore_path) as f: 33 | for line in f: 34 | line = line.strip() 35 | if not line or line.startswith("#"): 36 | continue 37 | 38 | # Normalize pattern 39 | if line.startswith("/"): 40 | line = line[1:] 41 | if line.endswith("/"): 42 | patterns.add(f"{line}**") 43 | line = line[:-1] 44 | 45 | patterns.add(line) 46 | # Add pattern with and without leading **/ to catch both absolute and relative paths 47 | if not line.startswith("**/"): 48 | patterns.add(f"**/{line}") 49 | 50 | return patterns 51 | 52 | 53 | def should_include(path: Path, gitignore_patterns: set[str], source_root: Path) -> bool: 54 | try: 55 | rel_path = str(path.relative_to(source_root)) 56 | except ValueError: 57 | return True 58 | 59 | # Check manual excludes first 60 | for pattern in MANUAL_EXCLUDES: 61 | if fnmatch(rel_path, pattern) or fnmatch(path.name, pattern): 62 | return False 63 | 64 | # Check gitignore patterns using full path 65 | for pattern in gitignore_patterns: 66 | if fnmatch(rel_path, pattern): 67 | return False 68 | 69 | return True 70 | 71 | 72 | def export_project(source_dir: str, output_file: str): 73 | source_path = Path(source_dir).resolve() 74 | gitignore_patterns = parse_gitignore(source_path / ".gitignore") 75 | 76 | with open(output_file, "w", encoding="utf-8") as f: 77 | f.write("\n") 78 | 79 | file_count = 0 80 | for root_dir, dirs, files in os.walk(source_path): 81 | root_path = Path(root_dir) 82 | dirs[:] = [d for d in dirs if should_include(root_path / d, gitignore_patterns, source_path)] 83 | 84 | for file in files: 85 | file_path = root_path / file 86 | if not should_include(file_path, gitignore_patterns, source_path): 87 | continue 88 | 89 | try: 90 | with open(file_path, encoding="utf-8") as src: 91 | content = src.read() 92 | 93 | relative_path = file_path.relative_to(source_path) 94 | f.write(f'\n') 95 | f.write(f"{html.escape(str(relative_path))}\n") 96 | f.write( 97 | f"{html.escape(content)}\n", 98 | ) 99 | f.write("\n") 100 | file_count += 1 101 | print(f"included {file_path}") 102 | 103 | except UnicodeDecodeError: 104 | print(f"Skipping binary file: {file_path}") 105 | continue 106 | 107 | f.write("") 108 | print(f"Exported {file_count} files") 109 | 110 | 111 | if __name__ == "__main__": 112 | import argparse 113 | 114 | parser = argparse.ArgumentParser() 115 | parser.add_argument("source", help="Source directory") 116 | parser.add_argument("output", help="Output XML file") 117 | args = parser.parse_args() 118 | 119 | export_project(args.source, args.output) 120 | -------------------------------------------------------------------------------- /scripts/mac_intel_install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/zsh 2 | set -e 3 | # The purpose of the prefix is to give an indication of the point 4 | # of execution of this script, so that if something breaks it's easier 5 | # to see where that broke. 6 | prefix="[LM Install Script] " 7 | 8 | # Credit, Taken from: https://stackoverflow.com/a/34389425 9 | # Installs homebrew if it does not exist, or updates it if it does. 10 | which -s brew 11 | if [[ $? != 0 ]] ; then 12 | echo "${prefix}Installing homebrew" 13 | ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)" 14 | else 15 | echo "${prefix}Updating homebrew" 16 | brew update 17 | fi 18 | 19 | echo "${prefix}Installing node" 20 | brew install node 21 | 22 | echo "${prefix}Installing node version manager (nvm)" 23 | brew install nvm 24 | 25 | echo "${prefix}Installing pyenv" 26 | brew install pyenv 27 | 28 | echo "${prefix}Attemping to change pyenv version to 3.11.1" 29 | pyenv install 3.11.1 30 | pyenv global 3.11.1 31 | 32 | echo "${prefix}Installing git" 33 | brew install git 34 | 35 | echo "${prefix}Installing direnv" 36 | brew install direnv 37 | 38 | echo "${prefix}Installing postgres" 39 | brew install postgresql 40 | 41 | echo "${prefix}Installing libpq" 42 | brew install libpq 43 | 44 | echo "${prefix}Installing watchman" 45 | brew install watchman 46 | 47 | echo "${prefix}Adding pyenv, direnv, poetry and path config to .zshrc" 48 | 49 | echo "# START LM 3.0 Configuration" >> ~/.zshrc 50 | echo "NVM_DIR=~/.nvm" >> ~/.zshrc 51 | echo "eval \"source \$(brew --prefix nvm)/nvm.sh\"" >> ~/.zshrc 52 | echo "eval \"\$(pyenv init --path)\"" >> ~/.zshrc 53 | echo "eval \"\$(pyenv init -)\"" >> ~/.zshrc 54 | echo "eval \"\$(direnv hook zsh)\"" >> ~/.zshrc 55 | echo "export WORKON_HOME=\"~/.virtualenvs\"" >> ~/.zshrc 56 | echo "export PATH=\"\$HOME/.local/bin:\$PATH\"" >> ~/.zshrc 57 | 58 | echo "${prefix}Reloading zsh" 59 | source ~/.zshrc 60 | 61 | echo "${prefix}Attempting to change nvm version to v16.14.0(default)" 62 | nvm install v16.14.0 63 | nvm alias default v16.14.0 64 | nvm use default 65 | 66 | echo "${prefix}Attempting to install poetry" 67 | curl -sSL https://install.python-poetry.org | python3 - 68 | 69 | echo "# END LM 3.0 Configuration" >> ~/.zshrc 70 | 71 | echo "${prefix}Reloading zsh" 72 | source ~/.zshrc 73 | 74 | echo "${prefix}Creating default DB for postgres" 75 | 76 | db_name=$(whoami) 77 | 78 | if psql -lqt | cut -d \| -f 1 | grep -qw "${db_name}"; then 79 | echo "${prefix}Database ${db_name} already exists, skipping creation." 80 | else 81 | echo "${prefix}Database ${db_name} does not exist, creating." 82 | createdb "${db_name}" 83 | fi 84 | 85 | echo "Provided everything in this script executed without error" 86 | echo "You should now be setup" 87 | echo "You should check your ~/.zshrc" 88 | echo "Important: Now that you have completed the fresh machine setup, you should begin to setup the project." 89 | echo "If you are in an existing project, that is! You can do this with /scripts/setup_existing_project.sh" 90 | -------------------------------------------------------------------------------- /template/.dockerignore.jinja: -------------------------------------------------------------------------------- 1 | **/*.pyc 2 | **/*.pyo 3 | **/.DS_Store 4 | .editorconfig 5 | .env 6 | .venv 7 | node_modules 8 | fly.toml 9 | .git/ 10 | *.sqlite3 11 | /[[project_name]]/static 12 | -------------------------------------------------------------------------------- /template/.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | indent_style = space 14 | indent_size = 4 15 | 16 | [*.{js,html,css,yml,xml,scss,json,ts,jinja}] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | -------------------------------------------------------------------------------- /template/.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | #fix when uv support comes out 9 | # - package-ecosystem: "pip" 10 | # directory: "/" 11 | # schedule: 12 | # interval: "weekly" 13 | # target-branch: "develop" 14 | # # Limit pull requests to 1 so Dependabot isn't constantly rebasing PRs. 15 | # # https://github.com/python-poetry/poetry/issues/496 16 | # open-pull-requests-limit: 1 17 | 18 | - package-ecosystem: "npm" 19 | directory: "/" 20 | schedule: 21 | interval: "weekly" 22 | target-branch: "develop" 23 | -------------------------------------------------------------------------------- /template/.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Short description 2 | 3 | This pull request... 4 | 5 | # Any point in the PR you think needs extra attention 6 | 7 | N/A 8 | 9 | # New Features or Fixes 10 | 11 | 1. 12 | 13 | # Video of feature working 14 | 15 | 16 | 17 | # Pull Request Checklist 18 | 19 | - [ ] Where relevant, I've added new tests and docs 20 | - [ ] My branch is pulled off of the latest develop 21 | - [ ] I've used a rebase strategy for any commits I made 22 | - [ ] I've double checked my code works in chrome & 1 other browser 23 | - [ ] I've double checked the design & ticket requirements 24 | -------------------------------------------------------------------------------- /template/.github/workflows/django_ci.yml.jinja: -------------------------------------------------------------------------------- 1 | name: Django CI 2 | on: 3 | pull_request: 4 | push: 5 | branches: 6 | - master 7 | - develop 8 | 9 | concurrency: 10 | group: ${{ github.workflow }}-${{ github.ref }} 11 | cancel-in-progress: true 12 | 13 | jobs: 14 | precommit: 15 | name: Precommit linting 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: actions/cache@v4 20 | with: 21 | path: | 22 | ~/.cache/pre-commit 23 | node_modules 24 | key: precommit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml', '**/package-lock.json') }} 25 | restore-keys: | 26 | precommit-${{ runner.os }}- 27 | 28 | - uses: jdx/mise-action@v2 29 | - run: npm install 30 | - run: mise pre-commit --all-files --color=always ${{ inputs.extra_args }} 31 | 32 | test: 33 | name: Django CI 34 | runs-on: ubuntu-latest 35 | env: 36 | UV_CACHE_DIR: /tmp/.uv-cache 37 | MISE_ENV: ci 38 | 39 | steps: 40 | - uses: actions/checkout@v4 41 | - uses: actions/cache@v4 42 | with: 43 | path: | 44 | /tmp/.uv-cache 45 | ~/.cache/ms-playwright 46 | node_modules 47 | key: ${{ runner.os }}-deps-${{ hashFiles('**/uv.lock', '**/package-lock.json') }} 48 | restore-keys: | 49 | ${{ runner.os }}-deps- 50 | - uses: jdx/mise-action@v2 51 | - name: Install env 52 | run: | 53 | mise setup-js 54 | mise uv 55 | mise x -- playwright install chromium 56 | 57 | - name: Run tests 58 | run: mise test-all --cov 59 | 60 | - name: Minimize uv cache 61 | run: mise x -- uv cache prune --ci 62 | 63 | 64 | services: 65 | postgres: 66 | image: postgres:16-alpine 67 | env: 68 | POSTGRES_PASSWORD: postgres 69 | 70 | options: >- 71 | --health-cmd pg_isready 72 | --health-interval 5s 73 | --health-timeout 5s 74 | --health-retries 3 75 | ports: 76 | - 5432:5432 77 | 78 | deploy-master: 79 | name: Deploy to Production 80 | runs-on: ubuntu-latest 81 | if: github.ref == 'refs/heads/master' 82 | needs: [precommit, test] 83 | environment: 84 | name: production 85 | # url: # Optional: URL where your app is deployed 86 | concurrency: deploy-production 87 | steps: 88 | - uses: actions/checkout@v4 89 | - uses: superfly/flyctl-actions/setup-flyctl@master 90 | - run: flyctl deploy --remote-only 91 | env: 92 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 93 | 94 | deploy-develop: 95 | name: Deploy to Staging 96 | runs-on: ubuntu-latest 97 | if: github.ref == 'refs/heads/develop' 98 | needs: [precommit, test] 99 | environment: 100 | name: staging 101 | # url: # Optional: URL where your staging app is deployed 102 | concurrency: deploy-staging 103 | steps: 104 | - uses: actions/checkout@v4 105 | - uses: superfly/flyctl-actions/setup-flyctl@master 106 | - run: flyctl deploy --remote-only 107 | env: 108 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 109 | 110 | 111 | dependabot_approve: 112 | name: Auto Merge Dependabot prs 113 | if: ${{ github.actor == 'dependabot[bot]' }} 114 | runs-on: ubuntu-latest 115 | 116 | permissions: 117 | pull-requests: write 118 | contents: write 119 | needs: test 120 | 121 | steps: 122 | - uses: dependabot/fetch-metadata@v2 123 | - run: gh pr review --approve "$PR_URL" && gh pr merge --auto --squash "$PR_URL" 124 | env: 125 | PR_URL: ${{github.event.pull_request.html_url}} 126 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 127 | -------------------------------------------------------------------------------- /template/.github/workflows/fly-deploy.yml: -------------------------------------------------------------------------------- 1 | # See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/ 2 | 3 | name: Fly Deploy 4 | on: 5 | push: 6 | branches: 7 | - master 8 | jobs: 9 | deploy: 10 | name: Deploy app 11 | runs-on: ubuntu-latest 12 | concurrency: deploy-group # optional: ensure only one action runs at a time 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: superfly/flyctl-actions/setup-flyctl@master 16 | - run: flyctl deploy --remote-only 17 | env: 18 | FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} 19 | -------------------------------------------------------------------------------- /template/.ignore: -------------------------------------------------------------------------------- 1 | # this file tells emacs not to grep these files 2 | migrations 3 | fontawesome.css 4 | fontawesome.min.css 5 | all.min.css 6 | */static_source/assets/* 7 | -------------------------------------------------------------------------------- /template/.pre-commit-config.yaml.jinja: -------------------------------------------------------------------------------- 1 | repos: 2 | ## system 3 | - repo: https://github.com/adamchainz/django-upgrade 4 | rev: 1.22.2 5 | hooks: 6 | - id: django-upgrade 7 | args: [--target-version, "5.1"] 8 | 9 | - repo: https://github.com/pre-commit/pre-commit-hooks 10 | rev: v5.0.0 11 | hooks: 12 | - id: check-yaml 13 | - id: check-merge-conflict 14 | - id: check-toml 15 | - id: trailing-whitespace 16 | - id: name-tests-test 17 | - id: debug-statements 18 | - id: mixed-line-ending 19 | - repo: https://github.com/pycqa/doc8 20 | rev: v1.1.2 21 | hooks: 22 | - id: doc8 23 | 24 | #python 25 | - repo: https://github.com/astral-sh/ruff-pre-commit 26 | rev: v0.9.1 27 | hooks: 28 | - id: ruff 29 | args: [--fix] 30 | - id: ruff-format 31 | - repo: https://github.com/astral-sh/uv-pre-commit 32 | rev: 0.5.18 33 | hooks: 34 | - id: uv-lock 35 | 36 | - repo: https://github.com/djlint/djLint 37 | rev: v1.36.4 38 | hooks: 39 | # TODO: Turn on when this works with django component and alpine.js 40 | # - id: djlint-reformat-django 41 | # files: "\\.html" 42 | - id: djlint-django 43 | files: "\\.html" 44 | 45 | # TODO: https://github.com/adamchainz/pre-commit-oxipng 46 | 47 | 48 | - repo: local 49 | hooks: 50 | - id: stylelint 51 | name: stylelint 52 | entry: npx stylelint 53 | language: node 54 | files: ^[[project_name]]/static_source/ 55 | types_or: [css, scss] 56 | args: [--fix, --allow-empty-input, --cache] 57 | 58 | - id: eslint 59 | name: eslint 60 | entry: npx eslint 61 | language: node 62 | files: ^[[project_name]]/static_source/ 63 | types_or: [javascript, ts] 64 | args: [--fix] 65 | 66 | - id: tsc 67 | name: tsc 68 | entry: npx tsc --noEmit 69 | files: ^[[project_name]]/static_source/ 70 | language: system 71 | types_or: [javascript, ts] 72 | pass_filenames: false 73 | -------------------------------------------------------------------------------- /template/.stylelintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-standard-scss", 3 | "reportDescriptionlessDisables": true, 4 | "reportInvalidScopeDisables": true, 5 | "reportNeedlessDisables": true, 6 | "rules": { 7 | "function-url-quotes": ["always", { "except": "empty"}], 8 | "scss/at-import-partial-extension": null, 9 | "scss/at-rule-no-unknown": [ true, { 10 | "ignoreAtRules": [ 11 | "extends", 12 | "apply", 13 | "tailwind", 14 | "components", 15 | "utilities", 16 | "screen", 17 | "layer" 18 | ] 19 | }], 20 | "rule-empty-line-before": [ 21 | "always", 22 | { 23 | "except": [ 24 | "first-nested" 25 | ], 26 | "ignore": [ 27 | "after-comment" 28 | ] 29 | } 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /template/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "batisteo.vscode-django", 4 | "bradlc.vscode-tailwindcss", 5 | "dbaeumer.vscode-eslint", 6 | "mikestead.dotenv", 7 | "monosans.djlint", 8 | "ms-python.pylint", 9 | "ms-python.python", 10 | "ms-python.vscode-pylance" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /template/.vscode/launch.json.jinja: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "compounds": [ 7 | { 8 | "name": "Debug [[ project_name_verbose ]]", 9 | "configurations": ["Django", "Vite", "Browser Debug"], 10 | "stopAll": true 11 | } 12 | ], 13 | "configurations": [ 14 | { 15 | "name": "Django Command", 16 | "type": "python", 17 | "request": "launch", 18 | "program": "${workspaceFolder}/manage.py", 19 | "args": "${input:djangoManage}", 20 | "django": true, 21 | "justMyCode": true, 22 | "cwd": "${workspaceFolder}", 23 | "preLaunchTask": "poetryInstall" 24 | }, 25 | { 26 | "name": "Django", 27 | "type": "python", 28 | "request": "launch", 29 | "program": "${workspaceFolder}/manage.py", 30 | "args": ["runserver"], 31 | "django": true, 32 | "justMyCode": true, 33 | "presentation": { "hidden": true }, 34 | "cwd": "${workspaceFolder}", 35 | "preLaunchTask": "poetryInstall" 36 | // Disabled this in favor of "Browser Debug" as this doesn't stop automatically 37 | // https://github.com/microsoft/vscode/issues/163124 38 | // 39 | // "serverReadyAction": { 40 | // "pattern": ".*(https?:\\/\\/\\S+:[0-9]+\\/?).*", 41 | // "uriFormat": "%s", 42 | // "action": "debugWithChrome" 43 | // } 44 | }, 45 | { 46 | "name": "Browser Debug", 47 | "type": "chrome", 48 | "request": "launch", 49 | "presentation": { "hidden": true }, 50 | "url": "http://localhost:8000" 51 | }, 52 | { 53 | "name": "Vite", 54 | "type": "node", 55 | "request": "launch", 56 | "runtimeExecutable": "npm", 57 | "runtimeArgs": ["run", "dev"], 58 | "presentation": { "hidden": true }, 59 | "cwd": "${workspaceFolder}", 60 | "preLaunchTask": "npmInstall" 61 | }, 62 | { 63 | "name": "Python: Debug Test", 64 | "type": "python", 65 | "request": "launch", 66 | "presentation": { "hidden": true }, 67 | "program": "${file}", 68 | "purpose": ["debug-test"], 69 | "justMyCode": true, 70 | "preLaunchTask": "testSetup" 71 | } 72 | ], 73 | "inputs": [ 74 | { 75 | "id": "djangoManage", 76 | "type": "promptString", 77 | "description": "Django admin commands.", 78 | "default": "migrate" 79 | } 80 | ] 81 | } 82 | -------------------------------------------------------------------------------- /template/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.codeActionsOnSave": { 4 | "source.organizeImports": true 5 | } 6 | }, 7 | "editor.rulers": [92], 8 | "eslint.workingDirectories": ["."], 9 | "files.insertFinalNewline": true, 10 | "files.trimFinalNewlines": true, 11 | "files.trimTrailingWhitespace": true, 12 | "prettier.configPath": ".prettierrc.json", 13 | "python.formatting.provider": "black", 14 | "python.linting.flake8Args": [ 15 | "--extend-exclude", 16 | "README.rst,README.md,*/settings/*,*/migrations/*", 17 | "--extend-ignore", 18 | "S322,W503,E5110,S101", 19 | "--format", 20 | "grouped", 21 | "--max-line-length", 22 | "92", 23 | "--show-source" 24 | ], 25 | "python.linting.flake8Enabled": true, 26 | "python.testing.pytestEnabled": true, 27 | "python.testing.unittestEnabled": false, 28 | "scss.lint.unknownAtRules": "ignore" 29 | } 30 | -------------------------------------------------------------------------------- /template/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "poetryInstall", 6 | "type": "shell", 7 | "command": "poetry", 8 | "args": ["install"], 9 | "options": { 10 | "cwd": "${workspaceFolder}" 11 | } 12 | }, 13 | { 14 | "label": "npmInstall", 15 | "type": "shell", 16 | "command": "npm", 17 | "args": ["install"], 18 | "options": { 19 | "cwd": "${workspaceFolder}" 20 | } 21 | }, 22 | { 23 | "label": "playwrightInstall", 24 | "type": "shell", 25 | "command": "poetry", 26 | "args": ["run", "playwright", "install"], 27 | "options": { 28 | "cwd": "${workspaceFolder}" 29 | } 30 | }, 31 | { 32 | "label": "setup", 33 | "dependsOn": ["poetryInstall", "npmInstall"] 34 | }, 35 | { 36 | "label": "testSetup", 37 | "dependsOn": ["poetryInstall", "npmInstall", "playwrightInstall"] 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /template/.watchmanconfig.jinja: -------------------------------------------------------------------------------- 1 | { 2 | "ignore_dirs": [ 3 | "node_modules", 4 | ".venv", 5 | "__pycache__", 6 | ".git", 7 | "scripts", 8 | "[[project_name]]/static", 9 | "[[project_name]]/static_source", 10 | "[[project_name]]/media", 11 | ".pytest_cache", 12 | "*.egg-info" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /template/Dockerfile.jinja: -------------------------------------------------------------------------------- 1 | FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim AS builder 2 | ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy 3 | WORKDIR /app 4 | RUN --mount=type=cache,target=/root/.cache/uv \ 5 | --mount=type=bind,source=uv.lock,target=uv.lock \ 6 | --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ 7 | uv sync --frozen --no-install-project --no-dev 8 | COPY . /app 9 | RUN --mount=type=cache,target=/root/.cache/uv \ 10 | uv sync --frozen --no-dev 11 | 12 | 13 | FROM node:20-slim as js-builder 14 | WORKDIR /app 15 | COPY package*.json ./ 16 | RUN npm ci 17 | COPY . . 18 | RUN npm run build 19 | 20 | # In your main stage: 21 | 22 | FROM python:3.13-slim-bookworm as web 23 | # Copy the application from the builder 24 | COPY --from=builder --chown=app:app /app /app 25 | COPY --from=js-builder /app/[[project_name]]/static_source/assets /app/[[project_name]]/static_source/assets 26 | COPY --from=js-builder /app/[[project_name]]/static_source/manifest.json /app/[[project_name]]/static_source/ 27 | 28 | # Place executables in the environment at the front of the path 29 | WORKDIR /app 30 | ENV PATH="/app/.venv/bin:$PATH" 31 | RUN python manage.py collectstatic --noinput --settings [[project_name]].config.settings.build 32 | EXPOSE 8000 33 | 34 | CMD ["gunicorn"] 35 | -------------------------------------------------------------------------------- /template/[[ _copier_conf.answers_file ]].jinja: -------------------------------------------------------------------------------- 1 | # Changes here will be overwritten by Copier; NEVER EDIT MANUALLY 2 | [[ _copier_answers|to_nice_yaml -]] 3 | -------------------------------------------------------------------------------- /template/[[project_name]]/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/__init__.py -------------------------------------------------------------------------------- /template/[[project_name]]/components/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/alert/alert.html: -------------------------------------------------------------------------------- 1 | {% load heroicons %} 2 | 3 |
17 |
18 |
{% heroicon_solid icon class="h-5 w-5" %}
19 |
20 |

{{ message }}

21 |
22 |
23 |
24 | 30 |
31 |
32 |
33 |
34 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/alert/alert.py: -------------------------------------------------------------------------------- 1 | from django_components import Component, register 2 | 3 | 4 | @register("alert") 5 | class Alert(Component): 6 | """An alert component that works with Django's messages framework. 7 | 8 | Usage: 9 | {% component "alert" message=message %}{% endcomponent %} 10 | """ 11 | 12 | template_name = "alert.html" 13 | 14 | level_icons = { 15 | "error": "exclamation-circle", 16 | "warning": "exclamation-triangle", 17 | "success": "check-circle", 18 | "info": "information-circle", 19 | "debug": "information-circle", 20 | } 21 | 22 | level_classes = { 23 | "debug": "bg-info/5 text-info border border-info/10 ring-1 ring-info/10", 24 | "info": "bg-info/5 text-info border border-info/10 ring-1 ring-info/10", 25 | "success": "bg-success/5 text-success border border-success/10 ring-1 ring-success/10", 26 | "warning": "bg-warning/5 text-warning border border-warning/10 ring-1 ring-warning/10", 27 | "error": "bg-error/5 text-error border border-error/10 ring-1 ring-error/10", 28 | } 29 | 30 | def get_context_data(self, message): 31 | level_tag = message.level_tag 32 | level_class = self.level_classes.get(level_tag, self.level_classes["info"]) 33 | icon = self.level_icons.get(level_tag, self.level_icons["info"]) 34 | 35 | return { 36 | "message": message, 37 | "icon": icon, 38 | "level_class": level_class, 39 | "timeout": 9000, 40 | } 41 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/button/button.html: -------------------------------------------------------------------------------- 1 | {% if not href %} 2 | 13 | {% else %} 14 | 15 | {% slot "content" default required %} 16 | {% endslot %} 17 | 18 | {% endif %} 19 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/button/button.py: -------------------------------------------------------------------------------- 1 | from django_components import Component, register 2 | 3 | 4 | @register("button") 5 | class Button(Component): 6 | """ 7 | Button component with HTMX support. 8 | 9 | Colors: 10 | - primary: Main action color 11 | - secondary: Less prominent actions 12 | Sizes: 13 | - sm: Small buttons (px-3 py-1.5 text-sm) 14 | - md: Medium buttons (px-4 py-2 text-base) [default] 15 | - lg: Large buttons (px-5 py-2.5 text-lg) 16 | Variants: 17 | - normal: Solid background color [default] 18 | - outline: Bordered with transparent background 19 | Slots: 20 | - content (required): Main button text 21 | - loading: Custom loading indicator for HTMX requests 22 | 23 | Usage: 24 | Basic: 25 | {% component "button" %} 26 | Click Me 27 | {% endcomponent %} 28 | 29 | With Icons: 30 | {% component "button" color="secondary" %} 31 | {% heroicon_mini "user" %} 32 | Profile 33 | {% heroicon_mini "arrow-right" %} 34 | {% endcomponent %} 35 | 36 | Outline Variant: 37 | {% component "button" variant="outline" %} 38 | Secondary Action 39 | {% endcomponent %} 40 | 41 | HTMX Integration: 42 | {% component "button" 43 | attrs:hx-post="{% url 'save'%}" 44 | attrs:hx-target="#result" 45 | %} 46 | Save 47 | {% endcomponent %} 48 | """ 49 | 50 | template_name = "button.html" 51 | 52 | def get_context_data( 53 | self, 54 | variant: str = "normal", 55 | color: str = "primary", 56 | size: str = "md", 57 | disabled: bool = False, 58 | attrs: dict[str, str] | None = None, 59 | link: str = "", 60 | target: str = "", 61 | ): 62 | if attrs is None: 63 | attrs = {} 64 | 65 | # Build base classes 66 | classes = [ 67 | "btn", 68 | f"btn-{size}", 69 | f"btn-{color}", 70 | ] 71 | 72 | if variant == "outline": 73 | classes.append("btn-outline") 74 | 75 | attrs["class"] = f"{' '.join(classes)} {attrs.get('class', '')}".strip() 76 | 77 | # Check for HTMX usage 78 | is_htmx = any(k.startswith("hx-") for k in attrs) 79 | 80 | # Handle disabled state 81 | if disabled: 82 | attrs["disabled"] = True 83 | attrs["aria-disabled"] = "true" 84 | attrs["class"] += " disabled" 85 | 86 | return { 87 | "attrs": attrs, 88 | "is_htmx": is_htmx, 89 | } 90 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/footer/footer.py: -------------------------------------------------------------------------------- 1 | from django_components import Component, register 2 | 3 | 4 | @register("footer_nav_link") 5 | class FooterNavLink(Component): 6 | template_name = "footer_nav_link.html" 7 | 8 | def get_context_data(self, href, text): 9 | return {"href": href, "text": text} 10 | 11 | 12 | @register("social_link") 13 | class SocialLink(Component): 14 | template_name = "social_link.html" 15 | 16 | def get_context_data(self, name, href="#", icon_type=None, size="5"): 17 | return { 18 | "href": href, 19 | "name": name, 20 | "icon_type": icon_type or name.lower(), 21 | "size": size, 22 | } 23 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/footer/footer_nav_link.html: -------------------------------------------------------------------------------- 1 |
2 | {{ text }} 3 |
4 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/footer/social_link.html: -------------------------------------------------------------------------------- 1 | 3 | {{ name }} 4 | {% component "svg" name="{{ name }}" size="{{ size }}" %} 5 | {% endcomponent %} 6 | 7 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/checkbox.html: -------------------------------------------------------------------------------- 1 |
2 |
{% component "widget" field=field / %}
3 |
4 | {% if field.label %} 5 | {% component "checkbox_label" field=field %} 6 | {{ field.label }} 7 | {% endcomponent %} 8 | {% endif %} 9 | {% if field.help_text %} 10 |

{{ field.help_text|safe }}

12 | {% endif %} 13 | {% if field.errors %}
{{ field.errors }}
{% endif %} 14 |
15 |
16 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/flatpickr.html: -------------------------------------------------------------------------------- 1 | {% load heroicons %} 2 |
6 | 7 | {% heroicon_outline "calendar" class="absolute right-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400 pointer-events-none" %} 8 |
9 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/form.html: -------------------------------------------------------------------------------- 1 | {% provide "form_context" form=form %} 2 |
3 | {{ errors }} 4 | {% csrf_token %} 5 | {% for field, errors in fields %} 6 | {% component "field" field=field / %} 7 | {% endfor %} 8 | 13 |
14 | {% endprovide %} 15 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/input.html: -------------------------------------------------------------------------------- 1 | {% load heroicons %} 2 |
3 |
6 | {% component "widget" field=field / %} 7 | {% if field.label %} 8 | {% component "label" field=field %} 9 | {{ field.label }} 10 | {% endcomponent %} 11 | {% endif %} 12 |
13 | {% if field.errors %} 14 |
{% heroicon_solid "exclamation-circle" class="text-error" %}
15 |
{{ field.errors }}
16 | {% endif %} 17 | {% if field.help_text %} 18 |
{{ field.help_text|safe }}
20 | {% endif %} 21 |
22 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/label.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/radio.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | {% component "label" field=field %} 4 | {{ field.label }} 5 | {% endcomponent %} 6 | 7 | {% component "widget" field=field / %} 8 | {% if field.help_text %} 9 |

{{ field.help_text|safe }}

11 | {% endif %} 12 | {% if field.errors %}
{{ field.errors }}
{% endif %} 13 |
14 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/toggle.html: -------------------------------------------------------------------------------- 1 |
2 |
4 | {% if field.label %} 5 | {% component "label" field=field %} 6 | {{ field.label }} 7 | {% endcomponent %} 8 | {% endif %} 9 |
16 | 23 | 31 |
32 |
33 | {% if field.errors %}
{{ field.errors }}
{% endif %} 34 | {% if field.help_text %} 35 |

{{ field.help_text|safe }}

37 | {% endif %} 38 |
39 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/widgets/checkbox_select.html: -------------------------------------------------------------------------------- 1 |
2 | {% for group, options, index in widget.optgroups %} 3 | {% if group %}
{{ group }}
{% endif %} 4 | {% for option in options %} 5 | 15 | {% endfor %} 16 | {% endfor %} 17 |
18 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/widgets/date.html: -------------------------------------------------------------------------------- 1 | {% component "flatpickr" 2 | type="date" 3 | value=widget.value 4 | id=widget.id 5 | input:name=widget.name 6 | input:required=widget.required 7 | / %} 8 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/widgets/datetime.html: -------------------------------------------------------------------------------- 1 | {% component "flatpickr" 2 | type="datetime" 3 | value=widget.value 4 | id=widget.id 5 | input:name=widget.name 6 | input:required=widget.required 7 | %} 8 | {% endcomponent %} 9 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/widgets/input.html: -------------------------------------------------------------------------------- 1 | {% load replace %} 2 | 12 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/widgets/password.html: -------------------------------------------------------------------------------- 1 | {% load replace %} 2 | {% load heroicons %} 3 |
8 | 14 |
{% heroicon_outline "eye-slash" class="w-6 h-6" %}
18 |
{% heroicon_outline "eye" class="w-6 h-6" %}
21 |
22 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/widgets/radio.html: -------------------------------------------------------------------------------- 1 | {# todo: get widget.required working, but need peer-invalid error messages first #} 2 |
6 | {% for group, options, index in widget.optgroups %} 7 | {% if group %}{% endif %} 8 | {% for option in options %} 9 |
10 | 16 | 19 | 20 |

{{ option.label }}

21 |
22 |
23 | {% endfor %} 24 | {% endfor %} 25 |
26 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/widgets/select.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/form/widgets/textarea.html: -------------------------------------------------------------------------------- 1 | {% load replace %} 2 |
7 | 13 |
14 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/header/header.py: -------------------------------------------------------------------------------- 1 | from django_components import Component, register 2 | 3 | 4 | @register("profile-popover") 5 | class ProfilePopover(Component): 6 | """ 7 | Profile-specific popover component extending the base popover functionality. 8 | 9 | Template slots: 10 | - menu_items: Menu items to be displayed in the popover 11 | 12 | Context variables: 13 | - avatar_url: URL for the user's avatar image 14 | - user_name: User's display name 15 | - user_email: User's email address 16 | - show_chevron: Whether to show the dropdown chevron (default: True) 17 | - show_profile: Whether to show the profile header section (default: True) 18 | """ 19 | 20 | template_name = "profile-popover.html" 21 | 22 | def get_context_data(self, **kwargs): 23 | return kwargs 24 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/header/profile-popover.html: -------------------------------------------------------------------------------- 1 | {% load heroicons %} 2 |
3 | {% component "popover" button_class="group flex items-center rounded-lg w-full p-1 hover:bg-gray-800/10" %} 4 | {% fill "trigger" %} 5 | Open user menu 6 |
7 | {{ user_name|default:'Profile' }} 10 | 11 | {{ user_name }} 12 | {% if show_chevron|default:True %} 13 | {% heroicon_micro "chevron-down" %} 14 | {% endif %} 15 | 16 |
17 | {% endfill %} 18 | {% fill "content" %} 19 |
20 | {% slot "menu_items" default %} 21 | {% endslot %} 22 |
23 | {% endfill %} 24 | {% endcomponent %} 25 |
26 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/link/link.html: -------------------------------------------------------------------------------- 1 | {{ text }} 2 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/link/link.py: -------------------------------------------------------------------------------- 1 | from django_components import Component, register 2 | 3 | 4 | @register("link") 5 | class Button(Component): 6 | """ 7 | Link component 8 | 9 | """ 10 | 11 | template_name = "link.html" 12 | 13 | def get_context_data( 14 | self, 15 | text: str, 16 | url: str, 17 | attrs: dict[str, str] | None = None, 18 | ): 19 | if attrs is None: 20 | attrs = {} 21 | 22 | # Build base classes 23 | classes = [ 24 | "text-blue-500", 25 | "hover:text-blue-700", 26 | "underline", 27 | ] 28 | 29 | attrs["class"] = f"{' '.join(classes)} {attrs.get('class', '')}".strip() 30 | 31 | return { 32 | "text": text, 33 | "url": url, 34 | "attrs": attrs, 35 | } 36 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/modal/modal.html: -------------------------------------------------------------------------------- 1 | {% load heroicons %} 2 |
4 | 5 | 6 | {% slot "trigger" %} 7 | {% component "button" %} 8 | Open Dialog 9 | {% endcomponent %} 10 | {% endslot %} 11 | 12 | 13 |
17 | 18 |
21 | 22 |
23 |
27 | 28 | {% slot "close" %} 29 |
30 | 36 |
37 | {% endslot %} 38 | 39 |
40 | {% slot "body" %} 41 | 42 |

43 | {% slot "title" %} 44 | {% endslot "title" %} 45 |

46 | 47 | {% slot "content" required default %} 48 |
49 |

Once published, your content will be visible to everyone.

50 |
51 | {% endslot %} 52 | {% endslot %} 53 |
54 | 55 |
56 | {% slot "footer" %} 57 | {% component "button" attrs:x-on:click="$dialog.close()" %} 58 | Ok 59 | {% endcomponent %} 60 | {% endslot %} 61 |
62 |
63 |
64 |
65 |
66 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/modal/modal.py: -------------------------------------------------------------------------------- 1 | from django_components import Component, register 2 | 3 | 4 | @register("modal") 5 | class Modal(Component): 6 | template_name = "modal.html" 7 | 8 | def get_context_data(self, open=False): # noqa A002 9 | return {"open": open} 10 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/popover/popover.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | {% slot "trigger" %} 4 | {% component "button" %} 5 | open menu 6 | {% endcomponent %} 7 | {% endslot %} 8 | 9 |
13 | {% slot "content" %} 14 | Content goes here 15 | {% endslot %} 16 |
17 |
18 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/popover/popover.py: -------------------------------------------------------------------------------- 1 | from django_components import Component, register 2 | 3 | 4 | @register("popover") 5 | class Popover(Component): 6 | """ 7 | Base popover component using Alpine.js UI. 8 | 9 | Template slots: 10 | - trigger: Content for the button that triggers the popover 11 | - content: Content displayed in the popover panel 12 | 13 | Context variables: 14 | - wrapper_class: Classes for the wrapper div (default: 'relative') 15 | - button_class: Classes for the trigger button 16 | - panel_class: Classes for the popover panel 17 | """ 18 | 19 | template_name = "popover.html" 20 | 21 | def get_context_data(self, **kwargs): 22 | return kwargs 23 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/svg/alpine.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 15 | 16 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/svg/apple.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/svg/discord.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/svg/django.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/svg/dribbble.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/svg/facebook.svg: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/svg/github.svg: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/svg/google.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 23 | 24 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/svg/instagram.svg: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/svg/linkedin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/svg/slack.svg: -------------------------------------------------------------------------------- 1 | 2 | 22 | 23 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/svg/svg.py: -------------------------------------------------------------------------------- 1 | from django_components import Component, register 2 | 3 | 4 | @register("svg") 5 | class Svg(Component): 6 | def get_template_name(self, context): 7 | return f"svg/{context['name']}.svg" 8 | 9 | def get_context_data(self, name, size="5"): 10 | return { 11 | "name": name.lower(), 12 | "size": size, 13 | } 14 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/svg/twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | -------------------------------------------------------------------------------- /template/[[project_name]]/components/tabs/tabs.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {% for tab in tabs %} 4 | 9 | {% endfor %} 10 |
11 |
12 | {% for tab in tabs %} 13 |
14 | {{ tab.content }} 15 |
16 | {% endfor %} 17 |
18 |
19 | -------------------------------------------------------------------------------- /template/[[project_name]]/config/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/config/__init__.py -------------------------------------------------------------------------------- /template/[[project_name]]/config/asgi.py.jinja: -------------------------------------------------------------------------------- 1 | # pylint: disable-all 2 | 3 | """ 4 | ASGI config for asgi project. 5 | 6 | It exposes the ASGI callable as a module-level variable named ``application``. 7 | 8 | For more information on this file, see 9 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ 10 | """ 11 | 12 | import os 13 | 14 | from django.core.asgi import get_asgi_application 15 | 16 | # Import websocket application here, so apps from django_application are loaded first 17 | from [[project_name]].config.websocket import websocket_application # noqa isort:skip 18 | 19 | # fmt: off 20 | os.environ.setdefault( 21 | "DJANGO_SETTINGS_MODULE", 22 | "[[project_name]].config.settings.prod", 23 | ) 24 | # fmt: on 25 | 26 | django_application = get_asgi_application() 27 | # Apply ASGI middleware here. 28 | 29 | 30 | async def application(scope, receive, send): 31 | if scope["type"] == "http": 32 | await django_application(scope, receive, send) 33 | elif scope["type"] == "websocket": 34 | await websocket_application(scope, receive, send) 35 | else: 36 | raise NotImplementedError(f"Unknown scope type {scope['type']}") 37 | -------------------------------------------------------------------------------- /template/[[project_name]]/config/settings/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/config/settings/__init__.py -------------------------------------------------------------------------------- /template/[[project_name]]/config/settings/build.py: -------------------------------------------------------------------------------- 1 | # required settings for build command like collectstatic 2 | import os 3 | 4 | REQUIRED_KEYS = [ 5 | "SECRET_KEY", 6 | "DATABASE_URL", 7 | "DJANGO_ALLOWED_HOSTS", 8 | "REDIS_URL", 9 | "AWS_ACCESS_KEY_ID", 10 | "AWS_SECRET_ACCESS_KEY", 11 | "BUCKET_NAME", 12 | "AWS_ENDPOINT_URL_S3", 13 | ] 14 | for key in REQUIRED_KEYS: 15 | os.environ[key] = "dummy" 16 | os.environ["SENTRY_DSN"] = "https://dummy@dummy.ingest.sentry.io/1234567" 17 | 18 | from .prod import * 19 | -------------------------------------------------------------------------------- /template/[[project_name]]/config/settings/test.py: -------------------------------------------------------------------------------- 1 | # pylint: disable-all 2 | 3 | from .local import * # noqa 4 | 5 | # PASSWORDS 6 | # ------------------------------------------------------------------------------ 7 | # https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers 8 | PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] 9 | 10 | MIDDLEWARE.remove("debug_toolbar.middleware.DebugToolbarMiddleware") 11 | INSTALLED_APPS.remove("debug_toolbar") 12 | 13 | TEMPLATE_DEBUG = False 14 | DJANGO_VITE_DEV_MODE = False 15 | DJANGO_VITE_MANIFEST_PATH = DJANGO_VITE_ASSETS_PATH / "manifest.json" 16 | -------------------------------------------------------------------------------- /template/[[project_name]]/config/urls.py.jinja: -------------------------------------------------------------------------------- 1 | # flake8: noqa: F811 2 | from django.conf import settings 3 | from django.conf.urls import handler400, handler403, handler404, handler500 4 | from django.contrib import admin 5 | from django.urls import include, path, re_path 6 | 7 | from [[project_name]].home.views import FourHundy, FourOhFour, FourOhThree, WorkedLocally 8 | 9 | handler400 = FourHundy 10 | handler403 = FourOhThree 11 | handler404 = FourOhFour 12 | handler500 = WorkedLocally 13 | 14 | 15 | urlpatterns = [] 16 | 17 | if "silk" in settings.INSTALLED_APPS: 18 | urlpatterns += [path("silk", include("silk.urls", namespace="silk"))] 19 | 20 | if settings.DEBUG: 21 | # This allows the error pages to be debugged during development, just visit 22 | # these url in browser to see how these error pages look like. 23 | urlpatterns += [ 24 | path( 25 | "400/", 26 | handler400, 27 | kwargs={"exception": Exception("Bad Request!")}, 28 | ), 29 | path( 30 | "403/", 31 | handler403, 32 | kwargs={"exception": Exception("Permission Denied")}, 33 | ), 34 | path( 35 | "404/", 36 | handler404, 37 | kwargs={"exception": Exception("Page not Found")}, 38 | ), 39 | path("500/", handler500), 40 | ] 41 | 42 | if "robots" in settings.INSTALLED_APPS: 43 | urlpatterns.append( 44 | re_path(r"^robots\.txt", include("robots.urls")), 45 | ) 46 | 47 | if "debug_toolbar" in settings.INSTALLED_APPS: 48 | import debug_toolbar 49 | 50 | urlpatterns = [path("__debug__/", include(debug_toolbar.urls))] + urlpatterns 51 | 52 | 53 | urlpatterns += [ 54 | path("account/", include("[[project_name]].user.urls", namespace="user")), 55 | path("account/", include("allauth.urls")), 56 | path("admin/", admin.site.urls), 57 | path("hijack/", include("hijack.urls")), 58 | path("", include("[[project_name]].home.urls")), 59 | path("", include("django_components.urls")), 60 | ] 61 | -------------------------------------------------------------------------------- /template/[[project_name]]/config/websocket.py: -------------------------------------------------------------------------------- 1 | async def websocket_application(scope, receive, send): 2 | # pylint: disable=unused-argument 3 | while True: 4 | event = await receive() 5 | 6 | if event["type"] == "websocket.connect": 7 | await send({"type": "websocket.accept"}) 8 | 9 | if event["type"] == "websocket.disconnect": 10 | break 11 | 12 | if event["type"] == "websocket.receive": 13 | if event["text"] == "ping": 14 | await send({"type": "websocket.send", "text": "pong!"}) 15 | -------------------------------------------------------------------------------- /template/[[project_name]]/config/wsgi.py.jinja: -------------------------------------------------------------------------------- 1 | # pylint: disable-all 2 | 3 | """ 4 | WSGI config for [[project_name]] project. 5 | This module contains the WSGI application used by Django's development server 6 | and any production WSGI deployments. It should expose a module-level variable 7 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 8 | this application via the ``WSGI_APPLICATION`` setting. 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | """ 15 | 16 | import os 17 | 18 | from django.core.wsgi import get_wsgi_application 19 | 20 | # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks 21 | # if running multiple sites in the same mod_wsgi process. To fix this, use 22 | # mod_wsgi daemon mode with each site in its own daemon process, or use 23 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "[[project_name]].config.settings.production") 24 | 25 | # This application object is used by any WSGI server configured to use this 26 | # file. This includes Django's development server, if the WSGI_APPLICATION 27 | # setting points here. 28 | application = get_wsgi_application() 29 | -------------------------------------------------------------------------------- /template/[[project_name]]/home/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/home/__init__.py -------------------------------------------------------------------------------- /template/[[project_name]]/home/admin.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/home/admin.py -------------------------------------------------------------------------------- /template/[[project_name]]/home/apps.py.jinja: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig as DjangoAppConfig 2 | 3 | 4 | class HomeConfig(DjangoAppConfig): 5 | name = "[[project_name]].home" 6 | verbose_name = "Home App" 7 | -------------------------------------------------------------------------------- /template/[[project_name]]/home/forms.py.jinja: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from [[project_name]].util.widgets import ToggleWidget 5 | 6 | 7 | class TestForm(forms.Form): 8 | text_input = forms.CharField( 9 | label=_("Text Input"), 10 | help_text=_("This is a basic text input field"), 11 | widget=forms.TextInput(attrs={"placeholder": "this is a text input"}), 12 | required=False, 13 | ) 14 | 15 | email_input = forms.EmailField( 16 | label=_("Email Input"), 17 | help_text=_("This is an email field"), 18 | required=False, 19 | ) 20 | 21 | password_input = forms.CharField( 22 | label=_("Password Input"), 23 | widget=forms.PasswordInput, 24 | help_text=_("This is a password field"), 25 | required=False, 26 | ) 27 | 28 | textarea = forms.CharField( 29 | label=_("Text Area"), 30 | widget=forms.Textarea, 31 | help_text=_("This is a textarea field"), 32 | required=False, 33 | ) 34 | 35 | select = forms.ChoiceField( 36 | label=_("Select"), 37 | choices=[ 38 | ("", "Select an option"), 39 | ("1", "Option 1"), 40 | ("2", "Option 2"), 41 | ("3", "Option 3"), 42 | ], 43 | help_text=_("This is a select field"), 44 | required=False, 45 | ) 46 | 47 | radio = forms.ChoiceField( 48 | label=_("Radio"), 49 | widget=forms.RadioSelect, 50 | choices=[ 51 | ("1", "Radio 1"), 52 | ("2", "Radio 2"), 53 | ("3", "Radio 3"), 54 | ], 55 | help_text=_("This is a radio field"), 56 | ) 57 | 58 | checkbox = forms.BooleanField(label=_("Checkbox"), help_text=_("This is a checkbox field")) 59 | 60 | toggle = forms.BooleanField( 61 | label=_("Toggle"), required=False, widget=ToggleWidget, help_text=_("This is a toggle field") 62 | ) 63 | 64 | date = forms.DateField(label=_("Date"), widget=forms.DateInput, help_text=_("This is a date field")) 65 | 66 | datetime = forms.DateTimeField( 67 | label=_("DateTime"), widget=forms.DateTimeInput, help_text=_("This is a datetime field") 68 | ) 69 | 70 | favorite_colors = forms.MultipleChoiceField( 71 | label=_("Favorite Colors"), 72 | widget=forms.CheckboxSelectMultiple, 73 | choices=[ 74 | ("red", "Red"), 75 | ("blue", "Blue"), 76 | ("green", "Green"), 77 | ("yellow", "Yellow"), 78 | ], 79 | help_text=_("Select your favorite colors"), 80 | required=True, 81 | error_messages={ 82 | "required": _("Please select at least one color."), 83 | }, 84 | ) 85 | -------------------------------------------------------------------------------- /template/[[project_name]]/home/management/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/home/management/__init__.py -------------------------------------------------------------------------------- /template/[[project_name]]/home/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/home/management/commands/__init__.py -------------------------------------------------------------------------------- /template/[[project_name]]/home/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/home/migrations/__init__.py -------------------------------------------------------------------------------- /template/[[project_name]]/home/models.py: -------------------------------------------------------------------------------- 1 | # from django.db import models # NOQA 2 | 3 | # from django_extensions.db.models import AutoSlugField # NOQA 4 | # from model_utils.models import TimeStampedModel # NOQA 5 | 6 | # Create your models here. 7 | -------------------------------------------------------------------------------- /template/[[project_name]]/home/signals.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/home/signals.py -------------------------------------------------------------------------------- /template/[[project_name]]/home/templatetags/replace.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | 3 | register = template.Library() 4 | 5 | 6 | @register.filter 7 | def replace(value, arg): 8 | """ 9 | Replacing filter 10 | Use `{{ "aaa"|replace:"a|b" }}` 11 | """ 12 | if len(arg.split("|")) != 2: 13 | return value 14 | 15 | what, to = arg.split("|") 16 | return value.replace(what, to) 17 | -------------------------------------------------------------------------------- /template/[[project_name]]/home/tests.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | import pytest 4 | from django.urls import reverse 5 | 6 | 7 | @pytest.mark.django_db 8 | def test_home(client): 9 | response = client.get(reverse("home")) 10 | 11 | assert response.status_code == HTTPStatus.OK 12 | 13 | 14 | @pytest.mark.django_db 15 | def test_error_route(client): 16 | with pytest.raises(Exception, match="Make response code 500!"): 17 | client.get(reverse("error")) 18 | -------------------------------------------------------------------------------- /template/[[project_name]]/home/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.views.generic.base import TemplateView 3 | 4 | from .views import FormTestView, current_time, error, test_message_redirect, test_message_refresh 5 | 6 | urlpatterns = [ 7 | path("form-test/", FormTestView.as_view(), name="form_test"), 8 | path("current-time/", current_time, name="current_time"), 9 | path("test-refresh/", test_message_refresh, name="test_refresh"), 10 | path("test-redirect/", test_message_redirect, name="test_redirect"), 11 | path("error/", error, name="error"), 12 | path("", TemplateView.as_view(template_name="index.html"), name="home"), 13 | ] 14 | -------------------------------------------------------------------------------- /template/[[project_name]]/home/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | 3 | from django.contrib import messages 4 | from django.template.response import TemplateResponse 5 | from django.views.defaults import ( 6 | bad_request, 7 | page_not_found, 8 | permission_denied, 9 | server_error, 10 | ) 11 | from django.views.generic.edit import FormView 12 | from django_htmx.http import HttpResponseClientRedirect, HttpResponseClientRefresh 13 | 14 | from .forms import TestForm 15 | 16 | 17 | def error(request): 18 | """Generate an exception. Useful for e.g. configuring Sentry""" 19 | raise Exception("Make response code 500!") 20 | 21 | 22 | def current_time(request): 23 | """Generate the current time. Useful for testing htmx""" 24 | messages.info(request, "updated the current time") 25 | return TemplateResponse(request, "samples/current_time.html") 26 | 27 | 28 | def test_message_redirect(request): 29 | messages.info(request, "testing redirect") 30 | return HttpResponseClientRedirect("/") 31 | 32 | 33 | def test_message_refresh(request): 34 | messages.info(request, "testing refresh") 35 | return HttpResponseClientRefresh() 36 | 37 | 38 | def FourHundy(request, exception): 39 | return bad_request(request, exception, template_name="400.html") 40 | 41 | 42 | def FourOhThree(request, exception): 43 | return permission_denied(request, exception, template_name="403.html") 44 | 45 | 46 | def FourOhFour(request, exception): 47 | return page_not_found(request, exception, template_name="404.html") 48 | 49 | 50 | def WorkedLocally(request): 51 | return server_error(request, template_name="500.html") 52 | 53 | 54 | class FormTestView(FormView): 55 | template_name = "home/form_test.html" 56 | form_class = TestForm 57 | success_url = "/" 58 | 59 | def form_valid(self, form): 60 | return super().form_invalid(form) 61 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/.keep: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/_tailwind.scss: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/admin.js: -------------------------------------------------------------------------------- 1 | // styles for the django admin interface 2 | import "@/css/admin.scss"; 3 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/admin.scss: -------------------------------------------------------------------------------- 1 | @use "tailwind"; 2 | 3 | :root { 4 | --admin-secret-message: "If you're seeing this, you're awesome! 🚀"; 5 | } 6 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/base/_colors.scss: -------------------------------------------------------------------------------- 1 | // Sass Color variables 2 | $primary: #2d36a8; 3 | $primary-focus: #00075e; 4 | $accent: #111827; 5 | $accent-focus: #030509; 6 | $error: #f87171; 7 | $error-focus: #991b1b; 8 | $success: #4ade80; 9 | $success-focus: #166534; 10 | $white: #fff; 11 | $eggshell: #fef2f2; 12 | $grey: #d1d5db; 13 | $dark-grey: #d8d8d8; 14 | $grey-focus: #e5e8ed; 15 | $black: #000; 16 | $info: #60a5fa; 17 | $info-focus: #1e40af; 18 | $info-content: #eff6ff; 19 | 20 | // CSS Custom Properties for use in tailwind config 21 | :root { 22 | --accent: #{$accent}; 23 | --accent-focus: #{$accent-focus}; 24 | --accent-content: #{$white}; 25 | --primary: #{$primary}; 26 | --primary-focus: #{$primary-focus}; 27 | --primary-content: #{$white}; 28 | --secondary: #{$grey}; 29 | --secondary-focus: #{$grey-focus}; 30 | --secondary-content: #{$black}; 31 | --error: #{$error}; 32 | --error-focus: #{$error-focus}; 33 | --error-content: #{$eggshell}; 34 | --warning: #facc15; 35 | --warning-focus: #854d0e; 36 | --warning-content: #fefce8; 37 | --success: #{$success}; 38 | --success-focus: #{$success-focus}; 39 | --success-content: #{$eggshell}; 40 | --info: #{$info}; 41 | --info-focus: #{$info-focus}; 42 | --info-content: #{$info-content}; 43 | --slate: #54565a; 44 | --silver: #b1b1b1; 45 | } 46 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/base/_flatpickr.scss: -------------------------------------------------------------------------------- 1 | // stylelint-disable selector-class-pattern -- We can't control the class names so: 2 | 3 | .flatpickr-calendar { 4 | width: auto !important; 5 | padding: 10px !important; 6 | border-radius: 15px !important; 7 | 8 | @apply bg-white #{!important}; 9 | } 10 | 11 | .flatpickr-current-month .numInputWrapper { 12 | margin-left: 0.5em !important; 13 | } 14 | 15 | .flatpickr-months .flatpickr-month, 16 | .flatpickr-monthDropdown-months, 17 | .flatpickr-weekdays, 18 | span.flatpickr-weekday { 19 | @apply bg-white #{!important}; 20 | } 21 | 22 | .flatpickr-current-month input.cur-year, 23 | .flatpickr-current-month .flatpickr-monthDropdown-months { 24 | border-radius: 3px !important; 25 | } 26 | 27 | .flatpickr-months { 28 | position: relative; 29 | } 30 | 31 | .flatpickr-months .flatpickr-prev-month:hover svg, 32 | .flatpickr-months .flatpickr-next-month:hover svg { 33 | @apply fill-red-500 #{!important}; 34 | } 35 | 36 | .flatpickr-weekdays { 37 | padding: 25px 0 15px; 38 | } 39 | 40 | .flatpickr-innerContainer { 41 | border: 0 !important; 42 | } 43 | 44 | .flatpickr-days { 45 | border: 0 !important; 46 | } 47 | 48 | .flatpickr-day.selected, 49 | .flatpickr-day.startRange, 50 | .flatpickr-day.endRange, 51 | .flatpickr-day.selected.inRange, 52 | .flatpickr-day.startRange.inRange, 53 | .flatpickr-day.endRange.inRange, 54 | .flatpickr-day.selected:focus, 55 | .flatpickr-day.startRange:focus, 56 | .flatpickr-day.endRange:focus, 57 | .flatpickr-day.selected:hover, 58 | .flatpickr-day.startRange:hover, 59 | .flatpickr-day.endRange:hover, 60 | .flatpickr-day.selected.prevMonthDay, 61 | .flatpickr-day.startRange.prevMonthDay, 62 | .flatpickr-day.endRange.prevMonthDay, 63 | .flatpickr-day.selected.nextMonthDay, 64 | .flatpickr-day.startRange.nextMonthDay, 65 | .flatpickr-day.endRange.nextMonthDay { 66 | @apply bg-red-500 border-red-500 #{!important}; 67 | } 68 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/base/_fonts.scss: -------------------------------------------------------------------------------- 1 | // Example font family inclusion 2 | // @font-face { 3 | // font-family: "GT Walsheim Pro"; 4 | // src: url("/static/fonts/GT-Walsheim-Pro-Regular.otf") format("opentype"); 5 | // font-weight: 400; 6 | // font-style: normal; 7 | // } 8 | 9 | // @font-face { 10 | // font-family: "GT Walsheim Pro"; 11 | // src: url("/static/fonts/GT-Walsheim-Pro-Medium.otf") format("opentype"); 12 | // font-weight: 500; 13 | // font-style: normal; 14 | // } 15 | 16 | // @font-face { 17 | // font-family: "GT Walsheim Pro"; 18 | // src: url("/static/fonts/GT-Walsheim-Pro-Bold.otf") format("opentype"); 19 | // font-weight: 700; 20 | // font-style: normal; 21 | // } 22 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/base/_index.scss: -------------------------------------------------------------------------------- 1 | @forward "colors"; 2 | @forward "typography"; 3 | @forward "fonts"; 4 | @forward "forms"; 5 | @forward "flatpickr"; 6 | 7 | /* sticky footer */ 8 | body { 9 | display: flex; 10 | flex-direction: column; 11 | min-height: 100vh; 12 | height: 100%; 13 | overflow-x: hidden; 14 | 15 | // Example font-family inclusion 16 | // font-family: "GT Walsheim Pro", sans-serif; 17 | } 18 | 19 | html { 20 | -webkit-tap-highlight-color: transparent; 21 | 22 | // For better cross-browser consistency 23 | width: 100vw; 24 | } 25 | 26 | 27 | // for center positioned elements, that should stay in the same position 28 | // when the y-scrollbar is present or not 29 | // you could also do this as "padding-left: calc(100vw - 100%);" 30 | // which will shrink the viewport vs hiding under the scrollbar 31 | @mixin no-jitter-scrollbar { 32 | margin-right: calc(-1 * (100vw - 100%)); 33 | } 34 | 35 | #app { 36 | flex: 1 0 auto; 37 | flex-direction: column; 38 | 39 | @include no-jitter-scrollbar; 40 | } 41 | 42 | header { 43 | @include no-jitter-scrollbar; 44 | } 45 | 46 | footer { 47 | flex-shrink: 0; 48 | 49 | @include no-jitter-scrollbar; 50 | } 51 | 52 | #loading-body { 53 | position: fixed; 54 | top: 50%; 55 | z-index: 40; 56 | } 57 | 58 | /* alpine */ 59 | [x-cloak] { 60 | display: none !important; 61 | } 62 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/base/_typography.scss: -------------------------------------------------------------------------------- 1 | @layer base { 2 | h1 { 3 | @apply text-4xl font-bold mb-3; 4 | } 5 | 6 | h2 { 7 | @apply text-3xl font-bold mb-2; 8 | } 9 | 10 | 11 | h3 { 12 | @apply text-3xl font-semibold mb-2; 13 | } 14 | 15 | h4 { 16 | @apply text-2xl font-semibold mb-1; 17 | } 18 | 19 | h5 { 20 | @apply text-xl mb-1; 21 | } 22 | 23 | h6 { 24 | @apply text-lg; 25 | } 26 | 27 | ul { 28 | @apply list-disc list-inside; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/components/_alert.scss: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .alert-message { 3 | transform: translateZ(0); /* Force GPU acceleration */ 4 | backface-visibility: hidden; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/components/_buttons.scss: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .btn { 3 | @apply inline-flex items-center justify-center; 4 | @apply rounded focus:outline-none focus:ring-2 focus:ring-offset-2; 5 | 6 | /* Base spacing */ 7 | @apply px-4 py-2; 8 | @apply space-x-2; 9 | 10 | /* Size variants */ 11 | &-sm { 12 | @apply px-3 py-1.5 text-sm space-x-1.5; 13 | } 14 | 15 | &-md { 16 | @apply px-4 py-2 text-base space-x-2; 17 | } 18 | 19 | &-lg { 20 | @apply px-5 py-2.5 text-lg space-x-2.5; 21 | } 22 | 23 | /* Color variants using our theme variables */ 24 | &.btn-primary { 25 | @apply bg-primary text-primary-content; 26 | @apply hover:bg-primary-focus; 27 | @apply focus:ring-primary; 28 | 29 | &.btn-outline { 30 | @apply bg-transparent; 31 | @apply border border-primary; 32 | @apply text-primary; 33 | @apply hover:bg-primary hover:text-primary-content; 34 | @apply focus:ring-primary; 35 | } 36 | } 37 | 38 | &.btn-secondary { 39 | @apply bg-secondary text-secondary-content; 40 | @apply hover:bg-secondary-focus; 41 | @apply focus:ring-secondary; 42 | 43 | &.btn-outline { 44 | @apply bg-transparent; 45 | @apply border border-secondary; 46 | @apply text-secondary; 47 | @apply hover:bg-secondary-focus hover:text-secondary-content; 48 | @apply focus:ring-secondary; 49 | } 50 | } 51 | 52 | 53 | 54 | /* Disabled state */ 55 | &[disabled], &.disabled { 56 | @apply opacity-50 cursor-not-allowed pointer-events-none; 57 | } 58 | 59 | /* HTMX States */ 60 | &.htmx-request { 61 | @apply opacity-75 cursor-wait; 62 | } 63 | 64 | &.htmx-swapping { 65 | @apply opacity-50 transition-opacity duration-200; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/components/_index.scss: -------------------------------------------------------------------------------- 1 | @forward "messages"; 2 | @forward "buttons"; 3 | @forward "alert"; 4 | @forward "links"; 5 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/components/_links.scss: -------------------------------------------------------------------------------- 1 | .link { 2 | @apply cursor-pointer no-underline; 3 | @apply text-indigo-600; 4 | @apply hover:underline; 5 | @apply hover:text-indigo-800; 6 | } 7 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/components/_messages.scss: -------------------------------------------------------------------------------- 1 | @layer components { 2 | .messages { 3 | position: fixed; 4 | left: 40px; 5 | bottom: 40px; 6 | list-style: none; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/site.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * This injects Tailwind's base styles, which is a combination of 3 | * Normalize.css and some additional base styles. We import preflight manually to allow 4 | * admin to not use it 5 | */ 6 | @use "tailwindcss/lib/css/preflight"; 7 | @use "tailwind"; 8 | @use "base"; 9 | @use "components"; 10 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/css/styles.js: -------------------------------------------------------------------------------- 1 | // vite will only build js files 2 | import "@/css/site.scss"; 3 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/img/favicons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/static_source/img/favicons/favicon.ico -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/img/favicons/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | 21 | 22 | 27 | 28 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/img/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 11 | 12 | 13 | 17 | 21 | 22 | 23 | 27 | 31 | 32 | 33 | 37 | 41 | 42 | 43 | 47 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/img/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 17 | 18 | 23 | 24 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 42 | DJANGO-HYDRA 43 | 44 | 45 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/img/logomark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 14 | 15 | 16 | 21 | 22 | 27 | 28 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/js/alpinejs__ui.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@alpinejs/ui"; 2 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/js/components.ts: -------------------------------------------------------------------------------- 1 | import.meta.glob("@/../components/**/*.js", { eager: true }); 2 | import.meta.glob("@/../components/**/*.ts", { eager: true }); 3 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/js/forms/common.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | interface Element { 3 | active: boolean; 4 | } 5 | } 6 | 7 | function getInput(component: Element): HTMLInputElement | HTMLTextAreaElement | null { 8 | if (component.matches("input, textarea")) { 9 | return component as HTMLInputElement; 10 | } else if (component.querySelector("input, textarea")) { 11 | return component.querySelector("input, textarea") as HTMLInputElement; 12 | } 13 | return null; 14 | } 15 | 16 | function updateFlag(this: HTMLElement & { $el: Element }, e: Event) { 17 | const currentInput: HTMLInputElement | HTMLTextAreaElement | null = getInput(this.$el); 18 | 19 | if (!currentInput) { 20 | console.warn("alpine input attached to something that isn't an input"); 21 | return; 22 | } 23 | 24 | switch (e.type) { 25 | case "focus": 26 | case "input": 27 | this.active = true; 28 | break; 29 | case "blur": 30 | if (!currentInput.value) { 31 | this.active = false; 32 | } else { 33 | this.active = true; 34 | } 35 | break; 36 | default: 37 | } 38 | 39 | if (currentInput.classList.contains("autofilled")) { 40 | currentInput.classList.remove("autofilled"); 41 | } 42 | }; 43 | 44 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 45 | export default function inputListener(this:any) { 46 | const currentComponent = this.$el as HTMLInputElement; 47 | const currentInput = getInput(currentComponent); 48 | if (!currentInput) { 49 | return; 50 | } 51 | 52 | // Each of these events can have a different effect on the active state of the input. 53 | ["blur", "focus", "input"].forEach((eventName) => { 54 | currentInput.addEventListener(eventName, updateFlag.bind(this)); 55 | }); 56 | 57 | // We're using the animationstart event to detect when the browser has autofilled 58 | currentInput.addEventListener("animationstart", (e: Event) => { 59 | const { animationName } = e as AnimationEvent; 60 | if (!animationName) { 61 | return; 62 | } 63 | switch (animationName) { 64 | case "autofill-start": 65 | this.active = true; 66 | currentInput.classList.add("autofilled"); 67 | break; 68 | case "autofill-cancel": 69 | if (document.activeElement === currentInput) { 70 | this.active = true; 71 | } else if (this.value === "") { 72 | this.active = false; 73 | } 74 | if (!currentInput.value) { 75 | currentInput.classList.remove("autofilled"); 76 | } 77 | break; 78 | default: 79 | } 80 | }); 81 | } 82 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/js/forms/date_datetime.ts: -------------------------------------------------------------------------------- 1 | import AlpineInstance, { AlpineComponent } from "alpinejs"; 2 | import flatpickr from "flatpickr"; 3 | 4 | import "flatpickr/dist/themes/light.css"; 5 | import inputListener from "./common"; 6 | 7 | interface DateTime{ 8 | //callback requires indexing to string and symbol 9 | [key: string]: unknown; 10 | [key: symbol]: unknown; 11 | //real types 12 | eventName: string; 13 | value: string; 14 | enableTime: boolean; 15 | picker: flatpickr.Instance | null; 16 | } 17 | 18 | 19 | const dateTime = (...args: unknown[]): AlpineComponent => { 20 | const [eventName, value, enableTime] = args as [string, string, boolean]; 21 | return { 22 | eventName, 23 | value, 24 | enableTime, 25 | picker: null, 26 | active: false, 27 | init() { 28 | inputListener.call(this); 29 | 30 | if (this.value === "None") { 31 | this.value = ""; 32 | } 33 | 34 | // see https://flatpickr.js.org/formatting/ 35 | const dateFormat = enableTime ? "m/d/Y H:i" : "m/d/Y"; 36 | 37 | this.picker = flatpickr(this.$refs.picker, { 38 | mode: "single", 39 | enableTime, 40 | dateFormat, 41 | allowInput: true, 42 | defaultDate: value, 43 | onChange: (_, dateString) => { 44 | this.value = dateString; 45 | }, 46 | }); 47 | 48 | this.$watch("value", () => { 49 | //this.picker?.setDate(this.value); //this is in the alpine docs but doesn't work with allow input 50 | if (this.eventName !== "") this.$dispatch(this.eventName, { value: this.value }); 51 | }); 52 | 53 | if (this.value) { 54 | this.active = true; 55 | } 56 | }, 57 | } 58 | }; 59 | 60 | AlpineInstance.data("dateTime", dateTime); 61 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/js/forms/input.ts: -------------------------------------------------------------------------------- 1 | import AlpineInstance, { AlpineComponent } from "alpinejs"; 2 | import inputListener from "./common"; 3 | 4 | interface Input { 5 | //callback requires indexing to string and symbol 6 | [key: string]: unknown; 7 | [key: symbol]: unknown; 8 | //real types 9 | eventName: string; 10 | value: string | boolean; 11 | type: string; 12 | active: boolean; 13 | } 14 | 15 | 16 | const input = (...args: unknown[]): AlpineComponent => { 17 | const [eventName, value, type] = args as [string, string | boolean, string]; 18 | return { 19 | eventName, 20 | value, 21 | type, 22 | active: false, 23 | init() { 24 | inputListener.call(this); 25 | 26 | if (this.type === 'checkbox') { 27 | // For checkboxes, initial state can come from either value or checked attribute 28 | const input = this.$el as HTMLInputElement; 29 | const isChecked = value == true || value === 'on' || input.hasAttribute('checked'); 30 | this.value = isChecked ? 'on' : ''; 31 | input.checked = isChecked; 32 | 33 | // Convert boolean true to 'on' for Django compatibility 34 | this.$watch('value', (newVal: string | boolean) => { 35 | const checked = newVal === true || newVal === 'on'; 36 | input.checked = checked; 37 | this.value = checked ? 'on' : ''; 38 | }); 39 | // Also watch the input's checked state 40 | input.addEventListener('change', () => { 41 | this.value = input.checked ? 'on' : ''; 42 | }); 43 | 44 | } else if (this.value === "None") { 45 | this.value = ""; 46 | } else if (this.$refs !== undefined && "input" in this.$refs) { 47 | // Toggle the focused state on an input when an initial value is set. 48 | this.active = !this.active; 49 | } 50 | if (this.eventName !== "input") { 51 | this.$watch("value", () => { 52 | this.$dispatch( 53 | this.eventName, 54 | { value: this.value }, 55 | ); 56 | }); 57 | } 58 | }, 59 | } 60 | } 61 | AlpineInstance.data("input", input); 62 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/js/forms/select.js: -------------------------------------------------------------------------------- 1 | import Alpine from "alpinejs"; 2 | import TomSelect from "tom-select"; 3 | import "tom-select/dist/css/tom-select.bootstrap5.css"; // doesn't actually include bootstrap, easy to style 4 | 5 | const select = () => ({ 6 | init() { 7 | // give a timeout to let htmx finish swapping content in 8 | // eslint-disable-next-line no-undef 9 | setTimeout(() => { 10 | const control = this.$el; 11 | 12 | new TomSelect(control, { 13 | }); 14 | }, 80); 15 | }, 16 | }); 17 | 18 | Alpine.data("select", select); 19 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/js/global.d.ts: -------------------------------------------------------------------------------- 1 | import { Alpine as AlpineType } from "alpinejs"; 2 | 3 | declare global { 4 | interface Window { 5 | Alpine: AlpineType; 6 | } 7 | const Alpine: AlpineType; 8 | } 9 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/js/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/static_source/js/index.ts -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/js/links.ts: -------------------------------------------------------------------------------- 1 | export function isExternalLink(link: HTMLAnchorElement) { 2 | const { href } = link; 3 | 4 | return !( 5 | !href 6 | || href.startsWith(window.location.origin) 7 | || href[0] === "?" 8 | || href[0] === "/" 9 | || href[0] === "#" 10 | || href.substring(0, 4) === "tel:" 11 | || href.substring(0, 7) === "mailto:" 12 | ); 13 | } 14 | 15 | export function isCurrentPage(link: HTMLAnchorElement) { 16 | //javascript controls tend to point to # 17 | if (link.getAttribute('href') === '#') { 18 | return false; 19 | } 20 | 21 | const currentUrl = window.location.href; 22 | const currentPath = window.location.pathname; 23 | const href = link.href.split("#")[0]; 24 | 25 | return href === currentUrl || href === currentPath; 26 | } 27 | 28 | export default function linksInit() { 29 | const links = document.getElementsByTagName("a"); 30 | 31 | Array.from(links).forEach((link) => { 32 | link.classList.remove("active"); 33 | 34 | if (isExternalLink(link)) { 35 | link.target = "_blank"; 36 | } 37 | 38 | if (isCurrentPage(link)) { 39 | link.classList.add("active"); 40 | } 41 | }); 42 | } 43 | 44 | if (typeof document !== "undefined") { 45 | document.addEventListener("htmx:pushedIntoHistory", linksInit); 46 | document.addEventListener("DOMContentLoaded", linksInit); 47 | } 48 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/js/main.ts.jinja: -------------------------------------------------------------------------------- 1 | import focus from "@alpinejs/focus"; 2 | import mask from "@alpinejs/mask"; 3 | import ui from "@alpinejs/ui"; 4 | 5 | import htmx from "htmx.org"; 6 | import Alpine from "alpinejs"; 7 | import Cookies from "js-cookie"; 8 | 9 | import "./links.ts"; 10 | import "./forms/input.ts"; 11 | import "./forms/select.js"; 12 | import "./forms/date_datetime.js"; 13 | 14 | if (import.meta.env.MODE !== "development") { 15 | // // @ts-expect-error // this whole system is broken w/ vite 16 | // import("vite/modulepreload-polyfill"); // eslint-disable-line import/no-unresolved 17 | // https://github.com/vitejs/vite/issues/4786 18 | } 19 | 20 | // Turn off the history cache - have found this is generally error prone 21 | htmx.config.historyCacheSize = 0; 22 | 23 | htmx.defineExtension("get-csrf", { 24 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 25 | onEvent(name: string, evt: any): boolean { 26 | if (name === "htmx:configRequest") { 27 | evt.detail.headers["X-CSRFToken"] = Cookies.get( 28 | "[[project_name]]_csrftoken" 29 | ); 30 | } 31 | return true 32 | }, 33 | }); 34 | 35 | htmx.defineExtension("get-timezone", { 36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 | onEvent: function(name: string, evt: any): boolean { 38 | if (name === "htmx:configRequest") { 39 | evt.detail.headers["X-Timezone"] = Intl.DateTimeFormat().resolvedOptions().timeZone; 40 | } 41 | return true 42 | } 43 | }); 44 | 45 | // This function will listen for HTMX errors and display the appropriate page 46 | // as needed. Without debug mode enabled, HTMX will normally refuse to 47 | // serve any HTML attached to an HTTP error code. This will allow us to present 48 | // users with custom error pages. 49 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 50 | htmx.on("htmx:beforeOnLoad", (event:any) => { 51 | const { xhr } = event.detail; 52 | if (xhr.status === 500 || xhr.status === 404) { 53 | event.stopPropagation(); 54 | document.children[0].innerHTML = xhr.response; 55 | } 56 | }); 57 | 58 | if (import.meta.hot) { 59 | import.meta.hot.on("template-hmr", () => { 60 | const dest = document.location.href; 61 | //switch to morph when ideomorph is ready 62 | htmx.ajax("get", dest, { target: "body" }); 63 | }); 64 | } 65 | 66 | window.Alpine = Alpine; 67 | Alpine.plugin(focus); 68 | Alpine.plugin(mask); 69 | Alpine.plugin(ui); 70 | Alpine.start(); 71 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/js/test/links.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, describe, expect, it } from "vitest"; 2 | import linksInit, { isCurrentPage, isExternalLink } from "../links"; 3 | 4 | const mockAnchor = (href: string) => ({ href } as HTMLAnchorElement); 5 | 6 | describe("links", () => { 7 | beforeAll(() => { 8 | window.location.href = "https://test.com/example/"; 9 | }); 10 | it("isExternalLink", () => { 11 | // internal links 12 | expect(isExternalLink(mockAnchor(""))).toBe(false); 13 | expect(isExternalLink(mockAnchor("https://test.com/a#foo"))).toBe(false); 14 | expect(isExternalLink(mockAnchor("/newpage"))).toBe(false); 15 | expect(isExternalLink(mockAnchor("#anchor-ref"))).toBe(false); 16 | expect(isExternalLink(mockAnchor("?foo=bar"))).toBe(false); 17 | expect(isExternalLink(mockAnchor("tel:2013334444"))).toBe(false); 18 | expect(isExternalLink(mockAnchor("mailto:example@test.com"))).toBe(false); 19 | expect(isExternalLink(mockAnchor("https://test.com"))).toBe(false); 20 | 21 | // external links 22 | expect(isExternalLink(mockAnchor("https://example.com"))).toBe(true); 23 | expect(isExternalLink(mockAnchor("https://example.com/#anchor-ref"))).toBe(true); 24 | expect(isExternalLink(mockAnchor("ftp://test.com"))).toBe(true); 25 | }); 26 | 27 | it("isCurrentPage", () => { 28 | // same page 29 | expect(isCurrentPage(mockAnchor("https://test.com/example/"))).toBe(true); 30 | expect(isCurrentPage(mockAnchor("/example/#anchor-ref"))).toBe(true); 31 | expect(isCurrentPage(mockAnchor("https://test.com/example/#anchor-ref"))).toBe(true); 32 | // diff page 33 | expect(isCurrentPage(mockAnchor("https://test.com"))).toBe(false); // needs trailing slash 34 | expect(isCurrentPage(mockAnchor("https://example.com/"))).toBe(false); // other domain 35 | expect(isCurrentPage(mockAnchor("https://test.com/otherpage"))).toBe(false); // other page on same domain 36 | }); 37 | 38 | it("linksInit", () => { 39 | document.body.innerHTML = ` 40 | 41 | 42 | 43 | `; 44 | const activeLink = document.getElementById("active") as HTMLAnchorElement; 45 | const externalLink = document.getElementById("external") as HTMLAnchorElement; 46 | const currentPageLink = document.getElementById("current") as HTMLAnchorElement; 47 | 48 | linksInit(); 49 | 50 | expect(activeLink.classList.contains("active")).toBe(false); 51 | expect(externalLink.target).toBe("_blank"); 52 | expect(currentPageLink.classList.contains("active")).toBe(true); 53 | expect(currentPageLink.classList.contains("active")).toBe(true); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /template/[[project_name]]/static_source/js/test/main.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | describe("main", () => { 4 | it("vite hmr available in tests", () => { 5 | if (import.meta.hot) { 6 | expect(import.meta.hot).toBe(true); 7 | } 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/400.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %} 3 | {% translate "Bad Request (400)" %} 4 | {% endblock title %} 5 | {% block content %} 6 |
7 |
8 |

{% translate "Bad Request (400)" %}

9 |

10 | {% if exception %} 11 | {{ exception }} 12 | {% else %} 13 | {% translate "This request was a bad, bad request." %} 14 | {% endif %} 15 |

16 |
17 |
18 | {% endblock content %} 19 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/403.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %} 3 | {% translate "Forbidden (403)" %} 4 | {% endblock title %} 5 | {% block content %} 6 |
7 |
8 |

{% translate "Forbidden (403)" %}

9 |

10 | {% if exception %} 11 | {{ exception }} 12 | {% else %} 13 | {% translate "You're not allowed to access this page." %} 14 | {% endif %} 15 |

16 |
17 |
18 | {% endblock content %} 19 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/404.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %} 3 | {% translate "Page not found" %} 4 | {% endblock title %} 5 | {% block content %} 6 |
7 |
8 |

{% translate "Page not found" %}

9 |

{% translate "This is not the page you were looking for." %}

10 |

11 | {% if exception %}{{ exception }}{% endif %} 12 |

13 |
14 |
15 | {% endblock content %} 16 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/500.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %} 3 | {% translate "Server Error" %} 4 | {% endblock title %} 5 | {% block content %} 6 |
7 |
8 |

{% translate "Ooops!!! 500" %}

9 |

{% translate "Looks like something went wrong!" %}

10 |

11 | {% translate "We track these errors automatically, but if the problem persists feel free to contact us. In the meantime, try refreshing." %} 12 |

13 |
14 |
15 | {% endblock content %} 16 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/account/account_base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %} 3 | Account 4 | {% endblock title %} 5 | {% block content %} 6 |
7 |
8 |
9 | {% block account_back %} 10 | {% endblock account_back %} 11 |
12 | 13 | logo 18 |

19 | {% block account_title %} 20 | {% endblock account_title %} 21 |

22 | {% block account_content %} 23 | {% endblock account_content %} 24 |
25 |
26 | {% endblock content %} 27 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/account/account_inactive.html: -------------------------------------------------------------------------------- 1 | {% extends "account/account_base.html" %} 2 | {% block account_title %} 3 | {% translate "Account Inactive" %} 4 | {% endblock account_title %} 5 | {% block account_content %} 6 |
{% translate "This account is inactive" %}
7 | {% endblock account_content %} 8 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/account/login.html: -------------------------------------------------------------------------------- 1 | {% extends "account/account_base.html" %} 2 | {% block title %} 3 | {{ block.super }} | Welcome back! 4 | {% endblock title %} 5 | {% block account_title %} 6 | {% translate "Sign In" %} 7 | {% endblock account_title %} 8 | {% block account_content %} 9 |
14 | {% component "form" form=form / %} 15 | {% if redirect_field_value %} 16 | 19 | {% endif %} 20 | {% component "button" attrs:type="submit" %} 21 | {% translate "Login" %} 22 | {% endcomponent %} 23 |
24 |

25 | {% translate "Need an account?" %} {% translate "Register" %} 26 |

27 |
28 |
29 |
30 |
31 |
32 | {% translate "Or" %} 33 |
34 |
35 |
36 |
{% include "account/snippets/social_login_buttons.html" %}
37 |
38 | {% endblock account_content %} 39 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/account/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "account/account_base.html" %} 2 | {% block account_title %} 3 | {% translate "Sign Out" %} 4 | {% endblock account_title %} 5 | {% block account_content %} 6 |

{% translate "Are you sure you want to sign out?" %}

7 |
10 | {% csrf_token %} 11 | {% if redirect_field_value %} 12 | 15 | {% endif %} 16 | {% component "button" attrs:type="submit" %} 17 | {% translate "Sign Out" %} 18 | {% endcomponent %} 19 |
20 | {% endblock account_content %} 21 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/account/password_reset.html: -------------------------------------------------------------------------------- 1 | {% extends "account/account_base.html" %} 2 | {% load heroicons %} 3 | {% block account_title %} 4 | {% translate "Reset Password" %} 5 | {% endblock account_title %} 6 | {% block account_back %} 7 | 8 | {% heroicon_micro "arrow-long-left" class="inline-block" %} {% translate "Sign In" %} 9 | 10 | {% endblock account_back %} 11 | {% block account_content %} 12 | {% if user.is_authenticated %} 13 | {% include "account/snippets/already_logged_in.html" %} 14 | {% else %} 15 |
16 |
{% translate "Forgotten your password?" %}
17 |

{% translate "Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %}

18 |
19 |
22 | {% csrf_token %} 23 | {% component "form" form=form / %} 24 | {% component "button" attrs:type="submit" attrs:class="w-full" %} 25 | {% translate "Reset My Password" %} 26 | {% endcomponent %} 27 |
28 |

{% translate "Please contact us if you have any trouble resetting your password." %}

29 | {% endif %} 30 | {% endblock account_content %} 31 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/account/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends "account/account_base.html" %} 2 | {% load heroicons %} 3 | {% block account_title %} 4 | {% translate "Password Reset" %} 5 | {% endblock account_title %} 6 | {% block account_back %} 7 | 8 | {% heroicon_micro "arrow-long-left" class="inline-block" %} {% translate "Sign In" %} 9 | 10 | {% endblock account_back %} 11 | {% block account_content %} 12 | {% if user.is_authenticated %} 13 | {% include "account/snippets/already_logged_in.html" %} 14 | {% else %} 15 |
16 |

{% translate "We have sent you an e-mail." %}

17 |

18 | {% translate "If you have not received it please check your spam folder. Otherwise contact us if you do not receive it in a few minutes." %} 19 |

20 |
21 | {% endif %} 22 | {% endblock account_content %} 23 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/account/password_reset_from_key.html: -------------------------------------------------------------------------------- 1 | {% extends "account/account_base.html" %} 2 | {% block account_title %} 3 | {% if token_fail %} 4 | {% translate "Bad Token" %} 5 | {% else %} 6 | {% translate "Change Password" %} 7 | {% endif %} 8 | {% endblock account_title %} 9 | {% block account_content %} 10 | {% if token_fail %} 11 |

12 | {% url 'account_reset_password' as passwd_reset_url %} 13 | {% blocktranslate %}The password reset link was invalid, possibly because it has already been used. Please request a new password reset.{% endblocktranslate %} 14 |

15 | {% else %} 16 |
17 | {% csrf_token %} 18 | {% component "form" form=form / %} 19 | {% component "button" attrs:type="submit" attrs:class="w-full" %} 20 | {% translate "Change Password" %} 21 | {% endcomponent %} 22 |
23 | {% endif %} 24 | {% endblock account_content %} 25 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/account/password_reset_from_key_done.html: -------------------------------------------------------------------------------- 1 | {% extends "account/account_base.html" %} 2 | {% block account_title %} 3 | {% translate "Change Password" %} 4 | {% endblock account_title %} 5 | {% block account_content %} 6 |

{% translate "Your password is now changed." %}

7 | {% endblock account_content %} 8 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/account/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "account/account_base.html" %} 2 | {% block title %} 3 | {{ block.super }}| {% translate "Register" %} 4 | {% endblock title %} 5 | {% block account_title %} 6 | {% translate "Register" %} 7 | {% endblock account_title %} 8 | {% block account_content %} 9 |
14 | {% component "form" form=form / %} 15 | {% if redirect_field_value %} 16 | 19 | {% endif %} 20 | {% component "button" attrs:type="submit" %} 21 | {% translate "Create Account" %} 22 | {% endcomponent %} 23 |
24 | {% translate "Already have an account?" %} 25 |
26 |
27 |
28 |
29 |
30 | {% translate "Or" %} 31 |
32 |
33 |
34 |
{% include "account/snippets/social_login_buttons.html" %}
35 |
36 | {% endblock account_content %} 37 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/account/snippets/already_logged_in.html: -------------------------------------------------------------------------------- 1 |
You are already logged in as {{ user.email }}
2 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/account/snippets/social_login_buttons.html: -------------------------------------------------------------------------------- 1 | 9 | 17 | 25 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/admin/base_site.html.jinja: -------------------------------------------------------------------------------- 1 | {% extends "admin/base_site.html" %} 2 | {% load django_vite %} 3 | {% block extrahead %} 4 | {{ block.super }} 5 | {% vite_hmr_client %} 6 | 7 | {% endblock extrahead %} 8 | {% block title %} 9 | {% if subtitle %}{{ subtitle }} |{% endif %} 10 | {{ title }} | {{ site_title|default:_("[[project_name_verbose]] site admin") }} 11 | {% endblock title %} 12 | {% block branding %} 13 |

14 | {{ site_header|default:_("[[project_name_verbose]] administration") }} 15 |

16 | {% endblock branding %} 17 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load django_vite %} 2 | {% load django_htmx %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {% block opengraph %} 12 | {% include "components/open_graph_tags.html" with title="TODO | Home" url="TODO" description="TODO" image_url="TODO" %} 13 | {% endblock opengraph %} 14 | 15 | {% vite_hmr_client %} 16 | {% vite_asset "css/styles.js" %} 17 | {% vite_asset "js/components.ts" %} 18 | {% vite_asset "js/main.ts" %} 19 | {% django_htmx_script %} 20 | 21 | {% block title %} 22 | {% endblock title %} 23 | 24 | {% block extra_head %} 25 | {% endblock extra_head %} 26 | 27 | 31 | {% include "header/base.html" %} 32 |
33 | {% block content %} 34 | {% endblock content %} 35 | {# hx-preserve persists this element on htmx swaps 36 | so even if swapping an entire new page this element exists 37 | for content to be swapped in as an oob-swap #} 38 |
    39 | {% include "messages.html" %} 40 |
41 |
42 | {% include "footer.html" %} 43 | 44 | 45 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/components/open_graph_tags.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/django/forms/readme.txt: -------------------------------------------------------------------------------- 1 | These files are just to overwrite the default forms in django 2 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/footer.html.jinja: -------------------------------------------------------------------------------- 1 |
2 |
3 | 11 |
12 | {% component "social_link" name="Facebook" href="#" icon_type="facebook" / %} 13 | {% component "social_link" name="Instagram" href="#" icon_type="instagram" size="6" / %} 14 | {% component "social_link" name="Twitter" href="#" icon_type="twitter" / %} 15 | {% component "social_link" name="GitHub" href="#" icon_type="github" / %} 16 | {% component "social_link" name="Dribbble" href="#" icon_type="dribbble" size="6" / %} 17 |
18 |

© {% now "Y" %} [[project_name]], Inc. All rights reserved.

19 |
20 |
21 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/header/base.html: -------------------------------------------------------------------------------- 1 |
2 | 15 |
16 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/header/desktop_center.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/header/end.html: -------------------------------------------------------------------------------- 1 | {% if not request or not request.user.is_authenticated %} 2 | 8 | {% else %} 9 | 21 | {% endif %} 22 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/header/header_link.html: -------------------------------------------------------------------------------- 1 | 3 | {{ text }} 4 | 5 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/header/logo.html: -------------------------------------------------------------------------------- 1 | 15 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/header/mobile_menu.html: -------------------------------------------------------------------------------- 1 | {% if request %} 2 |
12 |
13 | 14 | Dashboard 16 | Team 18 | Projects 20 | Calendar 22 |
23 |
24 |
25 |
26 | {{ request.user.first_name }} {{ request.user.last_name }} Profile 31 |
32 |
33 |
{{ request.user.first_name }} {{ request.user.last_name }}
34 |
{{ request.user.email }}
35 |
36 | 40 |
41 |
42 | Your Profile 44 | Settings 46 | Sign out 48 |
49 |
50 |
51 | {% endif %} 52 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/header/mobile_menu_button.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 18 |
19 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/header/profile_menu_item.html: -------------------------------------------------------------------------------- 1 | {% load heroicons %} 2 | 6 | {% if icon %} 7 | {% heroicon_solid icon class="shrink-0 size-5 mr-2 text-gray-400 group-focus-visible:text-gray-800 group-hover:text-gray-800" %} 8 | {% endif %} 9 | {{ label }} 10 | 11 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/home/form_test.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% block title %} 3 | Form Test 4 | {% endblock title %} 5 | {% block content %} 6 |
7 |

Form Field Test

8 |
9 | {% component "form" form=form / %} 10 |
11 | {% component "button" attrs:type="submit" %} 12 | Submit Form 13 | {% endcomponent %} 14 |
15 |
16 |
17 | {% endblock content %} 18 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load heroicons %} 3 | {% block title %} 4 | Index 5 | {% endblock title %} 6 | {% block content %} 7 |
8 |

9 | Hello There! 10 |

11 |
{% include "samples/current_time.html" %}
12 | {% component "button" 13 | attrs:hx-get="{% url 'current_time' %}" 14 | attrs:hx-target="#current-time" 15 | attrs:hx-swap="innerHTML" 16 | attrs:hx-push-url="false" %} 17 | Refresh time 18 | {% endcomponent %} 19 | {% component "button" 20 | color="secondary" 21 | attrs:hx-get="{% url 'test_redirect' %}" 22 | attrs:hx-push-url="false" %} 23 | Test hx-redirect with messages 24 | {% endcomponent %} 25 | {% component "button" 26 | variant="outline" 27 | attrs:hx-get="{% url 'test_refresh' %}" 28 | %} 29 | Test hx-refresh with messages 30 | {% endcomponent %} 31 |
32 | {% component "popover" button_class="group flex items-center rounded-lg w-full p-1 hover:bg-gray-800/10" %} 33 | {% fill "trigger" %} 34 | {% component "button" %} 35 | open menu {% heroicon_micro "chevron-down" %} 36 | {% endcomponent %} 37 | {% endfill %} 38 | {% fill "content" %} 39 | Menu Item One 40 | Menu Item two 41 | {% endfill %} 42 | {% endcomponent %} 43 | {% component "modal" %} 44 | {% fill "title" %} 45 | welcome to hydra! 46 | {% endfill %} 47 | {% fill "content" %} 48 | This is a hydra modal 49 | {% endfill %} 50 | {% endcomponent %} 51 | {% component "tabs" %} 52 | {% component "tab_item" header="TabOne" %} 53 |
54 |

Testing 123

55 |
56 | {% endcomponent %} 57 | {% component "tab_item" header="TabTwo" %} 58 |
59 |

Testing 124

60 |
61 | {% endcomponent %} 62 | {% endcomponent %} 63 |
64 |
65 | {% endblock content %} 66 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/messages.html: -------------------------------------------------------------------------------- 1 | {% for message in messages %} 2 |
  • {% component "alert" message=message / %}
  • 3 | {% endfor %} 4 | -------------------------------------------------------------------------------- /template/[[project_name]]/templates/samples/current_time.html: -------------------------------------------------------------------------------- 1 |
    2 |

    Using backend:{% now "Y-m-d H:i:s" %}

    3 |

    4 | Using alpine: 5 |

    6 |

    7 |
    8 | -------------------------------------------------------------------------------- /template/[[project_name]]/tests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from django.core import management 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_no_migrations(capsys): 7 | management.call_command("makemigrations", dry_run=True) 8 | 9 | captured = capsys.readouterr() 10 | assert "No changes detected" in captured.out 11 | -------------------------------------------------------------------------------- /template/[[project_name]]/user/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/user/__init__.py -------------------------------------------------------------------------------- /template/[[project_name]]/user/adapter.py: -------------------------------------------------------------------------------- 1 | from allauth.account.adapter import DefaultAccountAdapter 2 | from django.http import HttpResponseRedirect 3 | from django.urls import reverse 4 | from django_htmx.http import HttpResponseClientRedirect 5 | 6 | 7 | class HTMXAccountAdapter(DefaultAccountAdapter): 8 | def respond_email_verification_sent(self, request, user): 9 | url = reverse("account_email_verification_sent") 10 | if request.htmx: 11 | return HttpResponseClientRedirect(url) 12 | return HttpResponseRedirect(url) 13 | 14 | def post_login(self, request, *args, **kwargs): 15 | response = super().post_login(request, *args, **kwargs) 16 | if request.htmx: 17 | # htmxify the response 18 | response.status_code = 200 19 | response["HX-Redirect"] = response["Location"] 20 | del response["Location"] 21 | return response 22 | -------------------------------------------------------------------------------- /template/[[project_name]]/user/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin 3 | from django.contrib.auth.forms import UserChangeForm as DjangoUserChangeForm 4 | from django.contrib.auth.forms import UserCreationForm as DjangoUserCreationForm 5 | from django.utils.translation import gettext_lazy as _ 6 | 7 | from .models import User 8 | 9 | 10 | class UserCreationForm(DjangoUserCreationForm): 11 | class Meta: 12 | model = User 13 | fields = ("email",) 14 | 15 | 16 | class UserChangeForm(DjangoUserChangeForm): 17 | class Meta: 18 | model = User 19 | fields = "__all__" 20 | 21 | 22 | @admin.register(User) 23 | class UserAdmin(DjangoUserAdmin): 24 | fieldsets = ( 25 | (None, {"fields": ("email", "password")}), 26 | (_("Personal info"), {"fields": ("first_name", "last_name")}), 27 | ( 28 | _("Permissions"), 29 | { 30 | "fields": ( 31 | "is_active", 32 | "is_staff", 33 | "is_superuser", 34 | "groups", 35 | "user_permissions", 36 | ), 37 | }, 38 | ), 39 | (_("Important dates"), {"fields": ("last_login", "created")}), 40 | ) 41 | add_fieldsets = ((None, {"classes": ("wide",), "fields": ("email", "password1", "password2")}),) 42 | 43 | add_form = UserCreationForm 44 | form = UserChangeForm 45 | list_per_page = 25 46 | search_fields = ("email", "first_name", "last_name") 47 | readonly_fields = ("created", "last_login") 48 | list_display = ("email", "first_name", "last_name", "created", "is_superuser") 49 | ordering = ("email",) 50 | -------------------------------------------------------------------------------- /template/[[project_name]]/user/apps.py.jinja: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UserConfig(AppConfig): 5 | name = "[[project_name]].user" 6 | verbose_name = "User" 7 | -------------------------------------------------------------------------------- /template/[[project_name]]/user/baker_recipes.py: -------------------------------------------------------------------------------- 1 | from model_bakery.recipe import Recipe, seq 2 | 3 | from .models import User 4 | 5 | email_seq = seq("test@lightmatter.com") 6 | 7 | user = Recipe( 8 | User, 9 | first_name="Johnny", 10 | last_name="Rico", 11 | email="jonnyrico@fednet.gov", 12 | ) 13 | user_seq = Recipe(User, first_name="Johnny", last_name=seq("User"), email=email_seq) 14 | -------------------------------------------------------------------------------- /template/[[project_name]]/user/forms.py: -------------------------------------------------------------------------------- 1 | from allauth.account.forms import LoginForm as AllAuthLoginForm 2 | from allauth.account.forms import SignupForm as AllAuthSignupForm 3 | from django import forms 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from .models import User 7 | 8 | 9 | class LoginForm(AllAuthLoginForm): 10 | remember = forms.BooleanField( 11 | help_text=_("For 2 weeks"), 12 | label=_("Remember Me"), 13 | required=False, 14 | ) 15 | 16 | def __init__(self, *args, **kwargs): 17 | super().__init__(*args, **kwargs) 18 | 19 | 20 | class SignupForm(AllAuthSignupForm): 21 | first_name = forms.CharField( 22 | label=_("First Name"), 23 | min_length=1, 24 | max_length=User._meta.get_field("first_name").max_length, 25 | widget=forms.TextInput( 26 | attrs={"placeholder": _("First Name"), "autocomplete": "given-name"}, 27 | ), 28 | ) 29 | 30 | last_name = forms.CharField( 31 | label=_("Last Name"), 32 | min_length=1, 33 | max_length=User._meta.get_field("last_name").max_length, 34 | widget=forms.TextInput( 35 | attrs={"placeholder": _("Last Name"), "autocomplete": "family-name"}, 36 | ), 37 | ) 38 | 39 | def __init__(self, *args, **kwargs): 40 | super().__init__(*args, **kwargs) 41 | self.fields["email"].label = "Email" 42 | self.fields["email2"].label = "Confirm Email" 43 | self.fields["password2"].label = "Confirm Password" 44 | -------------------------------------------------------------------------------- /template/[[project_name]]/user/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/user/migrations/__init__.py -------------------------------------------------------------------------------- /template/[[project_name]]/user/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views import user_redirect_view 4 | 5 | app_name = "user" 6 | urlpatterns = [ 7 | path("~redirect/", view=user_redirect_view, name="redirect"), 8 | ] 9 | -------------------------------------------------------------------------------- /template/[[project_name]]/user/views.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth.mixins import LoginRequiredMixin 2 | from django.views.generic import RedirectView 3 | from django_htmx.http import HttpResponseClientRedirect 4 | 5 | 6 | class UserRedirectView(LoginRequiredMixin, RedirectView): 7 | permanent = False 8 | 9 | def get(self, request, *args, **kwargs): 10 | url = self.get_redirect_url(*args, **kwargs) 11 | if request.htmx: 12 | return HttpResponseClientRedirect(url) 13 | return super().get(request, *args, **kwargs) 14 | 15 | def get_redirect_url(self, *args, **kwargs): 16 | return "/" 17 | 18 | 19 | user_redirect_view = UserRedirectView.as_view() 20 | -------------------------------------------------------------------------------- /template/[[project_name]]/util/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/util/__init__.py -------------------------------------------------------------------------------- /template/[[project_name]]/util/apps.py.jinja: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UtilConfig(AppConfig): 5 | name = "[[project_name]].util" 6 | verbose_name = "Util" 7 | -------------------------------------------------------------------------------- /template/[[project_name]]/util/middleware.py: -------------------------------------------------------------------------------- 1 | import zoneinfo 2 | 3 | from django.contrib.messages import get_messages 4 | from django.template.loader import render_to_string 5 | from django.utils import timezone 6 | 7 | 8 | def attach_messages(response): 9 | if not (req_messages := get_messages(response._request)).used: 10 | messages = render_to_string( 11 | "messages.html", 12 | {"messages": req_messages}, # NOQA 13 | ) 14 | response.content = response.content + messages.encode(response.charset) 15 | return response 16 | 17 | 18 | class HTMXMessageMiddleware: 19 | def __init__(self, get_response): 20 | self.get_response = get_response 21 | 22 | def __call__(self, request): 23 | response = self.get_response(request) 24 | return response 25 | 26 | def process_template_response(self, request, response): 27 | if request.htmx and not request.htmx.boosted: 28 | response.add_post_render_callback(attach_messages) 29 | return response 30 | 31 | 32 | class TimezoneMiddleware: 33 | def __init__(self, get_response): 34 | self.get_response = get_response 35 | 36 | def __call__(self, request): 37 | tzname = request.headers.get("X-Timezone", None) 38 | if tzname: 39 | timezone.activate(zoneinfo.ZoneInfo(tzname)) 40 | else: 41 | timezone.deactivate() 42 | return self.get_response(request) 43 | -------------------------------------------------------------------------------- /template/[[project_name]]/util/migrations/0001_initial.py.jinja: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.0 on 2022-01-11 20:07 2 | 3 | from django.db import migrations, models 4 | 5 | import [[project_name]].util.util 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='TestFileModel', 18 | fields=[ 19 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('file_field', models.ImageField(upload_to=[[project_name]].util.util.file_url('filez'), verbose_name='foo')), 21 | ], 22 | options={ 23 | 'verbose_name': 'test', 24 | 'verbose_name_plural': 'tests', 25 | }, 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /template/[[project_name]]/util/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gone/django-hydra/f743935a4d75030ebaa3f422ec8d953fc5b6439c/template/[[project_name]]/util/migrations/__init__.py -------------------------------------------------------------------------------- /template/[[project_name]]/util/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from .util import file_url 5 | 6 | 7 | class TestFileModel(models.Model): 8 | file_field = models.ImageField(_("foo"), upload_to=file_url("filez")) 9 | 10 | class Meta: 11 | verbose_name = _("test") 12 | verbose_name_plural = _("tests") 13 | 14 | def __str__(self): 15 | return f"Test Model: {self.file_field}" 16 | -------------------------------------------------------------------------------- /template/[[project_name]]/util/tests.py: -------------------------------------------------------------------------------- 1 | import time 2 | from datetime import datetime 3 | 4 | import pytest 5 | from django.conf import settings 6 | from django.core import mail 7 | from django.core.files.uploadedfile import SimpleUploadedFile 8 | 9 | from .models import TestFileModel 10 | from .util import file_url 11 | 12 | 13 | def test_file_url(): 14 | file_url_obj = file_url("foo") 15 | assert file_url_obj.category == "foo" 16 | 17 | timestamp = int(time.time()) 18 | actual = file_url_obj("trash", "some_filename") 19 | now = datetime.now() 20 | 21 | expected = f"uploads/foo/{now:%Y/%m/%d}/{timestamp}/some_filename" 22 | assert actual == expected 23 | 24 | 25 | @pytest.mark.django_db 26 | def test_file_upload(): 27 | fake_file = SimpleUploadedFile("some_file.txt", b"asdf", content_type="text") 28 | now = datetime.now() 29 | timestamp = int(time.time()) 30 | 31 | file_field_url = TestFileModel.objects.create(file_field=fake_file).file_field.url 32 | expected = f"{settings.MEDIA_URL}uploads/filez/{now:%Y/%m/%d}/{timestamp}/some_file.txt" 33 | assert file_field_url == expected 34 | 35 | 36 | def test_send_email(mailoutbox): 37 | mail.send_mail("subject", "body", "from@lightmatter.com", ["to@lightmatter.com"]) 38 | assert len(mailoutbox) == 1 39 | 40 | m = mailoutbox[0] 41 | assert m.subject == "subject" 42 | assert m.body == "body" 43 | assert m.from_email == "from@lightmatter.com" 44 | assert list(m.to) == ["to@lightmatter.com"] 45 | -------------------------------------------------------------------------------- /template/[[project_name]]/util/util.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | import time 4 | 5 | from django.utils.deconstruct import deconstructible 6 | 7 | 8 | @deconstructible 9 | class file_url: # NOQA 10 | path = "uploads/{0}/{1.year:04}/{1.month:02}/{1.day:02}/{2}/{3}" # NOQA 11 | 12 | def __init__(self, category): 13 | self.category = category 14 | 15 | def __call__(self, instance, filename): 16 | r = re.compile(r"[^\S]") 17 | filename = r.sub("", filename) 18 | now = datetime.datetime.now() 19 | timestamp = int(time.time()) 20 | return self.path.format(self.category, now, timestamp, filename) 21 | -------------------------------------------------------------------------------- /template/[[project_name]]/util/widgets.py: -------------------------------------------------------------------------------- 1 | from django.forms.widgets import CheckboxInput 2 | 3 | 4 | class ToggleWidget(CheckboxInput): 5 | """ 6 | Change a CheckboxInput to a Toggle widget 7 | 8 | It is modeled after: https://alpinejs.dev/component/toggle 9 | 10 | Usage: 11 | agree_terms = forms.BooleanField(widget=ToggleWidget, required=True, label="Agree to terms") 12 | """ 13 | 14 | input_type = "toggle" 15 | -------------------------------------------------------------------------------- /template/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # exit on error 3 | set -o errexit 4 | 5 | uv sync 6 | 7 | npm install --no-fund 8 | npm run build 9 | 10 | python manage.py collectstatic --no-input 11 | python manage.py migrate 12 | 13 | 14 | poetry run python ./scripts/create_bucket.py 15 | -------------------------------------------------------------------------------- /template/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | from collections.abc import Generator 3 | 4 | import pytest 5 | from playwright.sync_api import BrowserContext, ConsoleMessage, Error, Page, Playwright, expect 6 | 7 | 8 | # See https://docs.pytest.org/en/7.1.x/reference/reference.html#pytest.hookspec.pytest_collection_modifyitems 9 | def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: 10 | """ 11 | Check if any tests are marked as integration and append the `vite` fixture to them if so. 12 | """ 13 | for item in items: 14 | if item.get_closest_marker("integration"): 15 | item.fixturenames.append("vite") # type: ignore 16 | 17 | 18 | @pytest.fixture(scope="session") 19 | def vite() -> None: 20 | import platform 21 | import subprocess 22 | import sys 23 | 24 | completed_process = subprocess.run( 25 | ["npm", "run", "build"], 26 | check=False, 27 | shell=platform.system() == "Windows", 28 | ) 29 | if completed_process.returncode != 0: 30 | print(completed_process.stderr) 31 | sys.exit(-1) 32 | 33 | 34 | @pytest.fixture(scope="session") 35 | def playwright(playwright: Playwright) -> Generator[Playwright]: 36 | """Override of playwright fixture so we can set up for use with Django. 37 | 38 | Background: https://github.com/microsoft/playwright-python/issues/439 39 | """ 40 | os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" 41 | 42 | yield playwright 43 | 44 | 45 | @pytest.fixture 46 | def context(context: BrowserContext) -> Generator[BrowserContext]: 47 | # Uncomment to disable or modify Playwright timeout 48 | context.set_default_timeout(1000) # 1 second in milliseconds 49 | 50 | yield context 51 | 52 | 53 | @pytest.fixture 54 | def page(page: Page) -> Generator[Page]: 55 | """Override of playwright page fixture that raises any console errors.""" 56 | page.on("console", raise_error) 57 | page.set_default_timeout(1000) # For actions like click/fill 58 | expect.set_options(timeout=1000) # For assertions 59 | yield page 60 | 61 | 62 | def raise_error(msg: ConsoleMessage) -> None: 63 | """Raise an error if a console error occurs. 64 | 65 | Args: 66 | msg (ConsoleMessage): A console message. 67 | Raises: 68 | Error: An error message. 69 | """ 70 | if msg.type != "error": 71 | return 72 | 73 | raise Error(f"error: {msg.text}, {msg.location['url']}") 74 | -------------------------------------------------------------------------------- /template/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | import eslint from '@eslint/js'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | export default tseslint.config( 7 | eslint.configs.recommended, 8 | tseslint.configs.recommended, 9 | ); 10 | -------------------------------------------------------------------------------- /template/fly.toml.jinja: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated on 2025-01-26T16:37:12-05:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "[[project_name]]" 7 | primary_region = 'ewr' 8 | console_command = 'python manage.py shell_plus' 9 | 10 | [build] 11 | 12 | [deploy] 13 | release_command = 'python manage.py migrate --noinput' 14 | 15 | [env] 16 | PORT = "8000" 17 | DJANGO_SETTINGS_MODULE = "[[project_name]].config.settings.prod" 18 | DJANGO_ALLOWED_HOSTS = "*" 19 | 20 | [http_service] 21 | internal_port = 8000 22 | force_https = true 23 | auto_stop_machines = 'suspend' 24 | auto_start_machines = true 25 | min_machines_running = 1 26 | processes = ['app'] 27 | 28 | [% raw %] 29 | [[vm]] 30 | memory = '1gb' 31 | cpu_kind = 'shared' 32 | cpus = 1 33 | 34 | [[statics]] 35 | guest_path = '/app/static' 36 | url_prefix = '/static/' 37 | [% endraw %] 38 | -------------------------------------------------------------------------------- /template/gunicorn.conf.py.jinja: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | 3 | accesslog = "-" 4 | bind = "0.0.0.0:8000" 5 | preload_app = True 6 | worker_class = "gthread" 7 | worker_tmp_dir = "/dev/shm" # noqa 8 | # See https://docs.gunicorn.org/en/stable/design.html#how-many-workers 9 | workers = multiprocessing.cpu_count() * 2 + 1 10 | wsgi_app = "[[project_name]].config.wsgi:application" 11 | -------------------------------------------------------------------------------- /template/manage.py.jinja: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "[[project_name]].config.settings.local") 7 | 8 | try: 9 | from django.core.management import execute_from_command_line 10 | except ImportError: 11 | # The above import may fail for some other reason. Ensure that the 12 | # issue is really that Django is missing to avoid masking other 13 | # exceptions on Python 2. 14 | try: 15 | import django # pylint: disable=unused-import # noqa 16 | except ImportError: 17 | raise ImportError( 18 | "Couldn't import Django. Are you sure it's installed and " 19 | "available on your PYTHONPATH environment variable? Did you " 20 | "forget to activate a virtual environment? Do you have Direnv installed?" 21 | ) 22 | raise 23 | 24 | execute_from_command_line(sys.argv) 25 | -------------------------------------------------------------------------------- /template/mise-tasks/db/check: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # @desc Check if current user has PostgreSQL permissions 3 | 4 | set -euo pipefail 5 | 6 | # Source colors 7 | source "$(dirname "$0")/../utils/colors" 8 | 9 | if ! psql postgres -c '\du' &>/dev/null; then 10 | color_error "PostgreSQL permissions not configured for current user" 11 | exit 1 12 | fi 13 | 14 | color_success "PostgreSQL permissions verified" 15 | -------------------------------------------------------------------------------- /template/mise-tasks/db/create-user: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # @desc Create PostgreSQL user for current system user 3 | # @depends db:check 4 | 5 | set -euo pipefail 6 | 7 | # Source colors 8 | source "$(dirname "$0")/../utils/colors" 9 | 10 | color_info "Creating PostgreSQL user..." 11 | 12 | # Check if createuser exists 13 | if ! command -v createuser >/dev/null 2>&1; then 14 | color_error "PostgreSQL client tools not found. Please install PostgreSQL." 15 | exit 1 16 | fi 17 | 18 | color_info "Setting up PostgreSQL user..." 19 | 20 | # Try to create user, ignore if already exists 21 | createuser -sdl "$USER" 2>/dev/null || true 22 | 23 | # Try to create database, ignore if already exists 24 | createdb "$USER" 2>/dev/null || true 25 | 26 | color_success "PostgreSQL user setup complete" 27 | -------------------------------------------------------------------------------- /template/mise-tasks/db/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # @desc Set up project database 3 | # @depends db:create-user 4 | 5 | set -euo pipefail 6 | 7 | # Source colors 8 | source "$(dirname "$0")/../utils/colors" 9 | 10 | # Get project name from mise config 11 | project_name=$ENV_NAME 12 | 13 | if [ -z "$project_name" ]; then 14 | color_error "Error: project_name not set in mise.toml" 15 | exit 1 16 | fi 17 | 18 | # Check if database exists 19 | if [ "$(psql -tAc "SELECT 1 FROM pg_database WHERE datname='$project_name'" postgres)" != "1" ]; then 20 | color_info "Creating project database..." 21 | if psql postgres -c "create role $project_name with createdb encrypted password '$project_name' login;" &&\ 22 | psql postgres -c "alter user $project_name superuser;" &&\ 23 | psql postgres -c "create database $project_name with owner $project_name;"; then 24 | color_success "Project database created successfully" 25 | else 26 | color_error "Failed to create project database" 27 | exit 1 28 | fi 29 | else 30 | color_warning "Project database already exists" 31 | fi 32 | -------------------------------------------------------------------------------- /template/mise-tasks/utils/colors: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Reset 4 | export COLOR_RESET='\033[0m' 5 | 6 | # Regular Colors 7 | export COLOR_BLACK='\033[0;30m' 8 | export COLOR_RED='\033[0;31m' 9 | export COLOR_GREEN='\033[0;32m' 10 | export COLOR_YELLOW='\033[0;33m' 11 | export COLOR_BLUE='\033[0;34m' 12 | export COLOR_PURPLE='\033[0;35m' 13 | export COLOR_CYAN='\033[0;36m' 14 | export COLOR_WHITE='\033[0;37m' 15 | 16 | # Bold Colors 17 | export COLOR_BOLD_BLACK='\033[1;30m' 18 | export COLOR_BOLD_RED='\033[1;31m' 19 | export COLOR_BOLD_GREEN='\033[1;32m' 20 | export COLOR_BOLD_YELLOW='\033[1;33m' 21 | export COLOR_BOLD_BLUE='\033[1;34m' 22 | export COLOR_BOLD_PURPLE='\033[1;35m' 23 | export COLOR_BOLD_CYAN='\033[1;36m' 24 | export COLOR_BOLD_WHITE='\033[1;37m' 25 | 26 | # Utility functions 27 | color_echo() { 28 | local color="$1" 29 | shift 30 | echo -e "${color}$*${COLOR_RESET}" 31 | } 32 | 33 | color_error() { 34 | color_echo "$COLOR_RED" "$@" >&2 35 | } 36 | 37 | color_warning() { 38 | color_echo "$COLOR_YELLOW" "$@" 39 | } 40 | 41 | color_success() { 42 | color_echo "$COLOR_GREEN" "$@" 43 | } 44 | 45 | color_info() { 46 | color_echo "$COLOR_CYAN" "$@" 47 | } 48 | -------------------------------------------------------------------------------- /template/mise.ci.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | precommit = "latest" 3 | 4 | [env] 5 | DATABASE_URL = "postgres://postgres:postgres@localhost/postgres" 6 | DJANGO_SETTINGS_MODULE = "[[project_name]].config.settings.test" 7 | SECRET_KEY = "!!!! Change me !!!!" 8 | -------------------------------------------------------------------------------- /template/package.json.jinja: -------------------------------------------------------------------------------- 1 | { 2 | "name": "[[project_name]]", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "tsc --noEmit && vite build", 7 | "serve": "vite preview", 8 | "test": "vitest", 9 | "coverage": "vitest run --coverage" 10 | }, 11 | "devDependencies": { 12 | "@eslint/js": "^9.17.0", 13 | "@tailwindcss/forms": "^0.5.9", 14 | "@types/alpinejs": "^3.13.11", 15 | "@types/alpinejs__focus": "^3.13.4", 16 | "@types/alpinejs__mask": "^3.13.4", 17 | "@types/js-cookie": "^3.0.6", 18 | "autoprefixer": "^10.4.20", 19 | "eslint": "^9.17.0", 20 | "fast-glob": "^3.3.2", 21 | "happy-dom": "^15.11.7", 22 | "postcss": "^8.4.49", 23 | "postcss-nested": "^7.0.2", 24 | "sass": "^1.83.0", 25 | "stylelint": "^16.12.0", 26 | "stylelint-config-standard-scss": "^14.0.0", 27 | "tailwindcss": "^3.4.17", 28 | "typescript": "^5.7.2", 29 | "typescript-eslint": "^8.18.1", 30 | "vite": "^6.0.5", 31 | "vitest": "^2.1.8" 32 | }, 33 | "dependencies": { 34 | "@alpinejs/focus": "^3.14.7", 35 | "@alpinejs/mask": "^3.14.7", 36 | "@alpinejs/ui": "^3.14.7", 37 | "alpinejs": "^3.14.7", 38 | "flatpickr": "^4.6.13", 39 | "htmx.org": "^2.0.4", 40 | "js-cookie": "^3.0.5", 41 | "tom-select": "^2.4.1" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /template/postcss.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable global-require */ 2 | module.exports = { 3 | plugins: [ 4 | require("tailwindcss/nesting"), 5 | require("tailwindcss"), 6 | require("autoprefixer"), 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /template/render.yaml.jinja: -------------------------------------------------------------------------------- 1 | previewsEnabled: true 2 | previewsExpireAfterDays: 5 3 | services: 4 | - type: web 5 | name: [[project_name]] 6 | env: python 7 | previewPlan: starter 8 | healthCheckPath: / 9 | buildCommand: "./build.sh" 10 | startCommand: poetry run gunicorn -c gunicorn.conf.py 11 | envVars: 12 | - key: PYTHON_VERSION 13 | value: 3.11.1 14 | - fromGroup: sentry 15 | - key: SECRET_KEY 16 | generateValue: true 17 | - key: DJANGO_SETTINGS_MODULE 18 | value: "[[project_name]].config.settings.prod" 19 | - key: DJANGO_ALLOWED_HOSTS 20 | fromService: 21 | type: web 22 | envVarKey: RENDER_EXTERNAL_HOSTNAME 23 | name: [[project_name]] 24 | - key: DJANGO_DEBUG 25 | value: false 26 | - key: DATABASE_URL 27 | fromDatabase: 28 | name: [[project_name]]-db 29 | property: connectionString 30 | - key: SENTRY_ENVIRONMENT 31 | fromService: 32 | type: web 33 | envVarKey: RENDER_SERVICE_NAME 34 | name: [[project_name]] 35 | - key: SENTRY_RELEASE 36 | fromService: 37 | type: web 38 | envVarKey: RENDER_GIT_COMMIT 39 | name: [[project_name]] 40 | - key: BUCKET_NAME 41 | value: [[project_name]] 42 | - key: AWS_ACCESS_KEY_ID 43 | fromService: 44 | type: web 45 | envVarKey: MINIO_ROOT_USER 46 | name: minio 47 | - key: AWS_SECRET_ACCESS_KEY 48 | fromService: 49 | type: web 50 | envVarKey: MINIO_ROOT_PASSWORD 51 | name: minio 52 | - key: AWS_ENDPOINT_URL_S3 53 | fromService: 54 | type: web 55 | envVarKey: RENDER_EXTERNAL_URL 56 | name: minio 57 | - key: REDIS_HOST 58 | fromService: 59 | name: redis 60 | type: pserv 61 | property: host # available properties are listed below 62 | - key: REDIS_PORT 63 | fromService: 64 | name: redis 65 | type: pserv 66 | property: port 67 | 68 | - type: web 69 | name: minio 70 | healthCheckPath: /minio/health/live 71 | env: docker 72 | dockerfilePath: ./compose/prod/minio/Dockerfile 73 | dockerContext: ./compose/prod/minio/ 74 | disk: 75 | name: data 76 | mountPath: /data 77 | sizeGB: 10 78 | envVars: 79 | - key: MINIO_ROOT_USER 80 | generateValue: true 81 | - key: MINIO_ROOT_PASSWORD 82 | generateValue: true 83 | - key: PORT 84 | value: 9000 85 | 86 | - type: pserv 87 | name: redis 88 | dockerfilePath: ./compose/prod/redis/Dockerfile 89 | dockerContext: ./compose/prod/redis 90 | env: docker 91 | disk: 92 | name: data 93 | mountPath: /var/lib/redis 94 | sizeGB: 10 95 | 96 | 97 | databases: 98 | - name: [[project_name]]-db 99 | previewPlan: starter 100 | databaseName: [[project_name]] # optional (Render may add a suffix) 101 | ipAllowList: [] # optional (defaults to allow all) 102 | 103 | envVarGroups: 104 | - name: sentry 105 | envVars: 106 | - key: SENTRY_DSN 107 | sync: false 108 | - key: SENTRY_PROJECT 109 | value: [[project_name]] 110 | -------------------------------------------------------------------------------- /template/scripts/create_patch.sh.jinja: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | RED='\033[0;31m' 6 | GREEN='\033[0;32m' 7 | YELLOW='\033[1;33m' 8 | NC='\033[0m' 9 | 10 | log_info() { echo -e "${GREEN}[INFO]${NC} $1"; } 11 | log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } 12 | log_error() { echo -e "${RED}[ERROR]${NC} $1"; } 13 | 14 | if ! [ -d .git ] || ! [ -d ../django-hydra ]; then 15 | log_error "Must be run from project root with template at ../django-hydra" 16 | exit 1 17 | fi 18 | 19 | if [ $# -eq 1 ]; then 20 | if ! [ "$(git cat-file -t "$1" 2>/dev/null)" = "commit" ]; then 21 | log_error "'$1' is not a valid git commit" 22 | exit 1 23 | fi 24 | PATCH_COMMIT=$1 25 | BASE_COMMIT=$(git rev-parse "$PATCH_COMMIT^1") 26 | elif [ $# -eq 2 ]; then 27 | PATCH_COMMIT=$1 28 | BASE_COMMIT=$2 29 | else 30 | PATCH_COMMIT=$(git rev-parse HEAD) 31 | BASE_COMMIT=$(git rev-parse "$PATCH_COMMIT^1") 32 | fi 33 | 34 | PROJECT_NAME="${PWD##*/}" 35 | WORK_DIR=$(mktemp -d) 36 | trap 'rm -rf "$WORK_DIR"' EXIT 37 | 38 | # Get original branch name 39 | ORIGINAL_BRANCH=$(cd ../django-hydra && git branch --show-current) 40 | 41 | # Extract commit message and patch 42 | COMMIT_MSG=$(git log -1 --format=%B "$PATCH_COMMIT") 43 | git format-patch --binary --minimal --stdout "$BASE_COMMIT..$PATCH_COMMIT" > "$WORK_DIR/changes.patch" 44 | sed -i.bak "s/$PROJECT_NAME/[[project_name]]/g" "$WORK_DIR/changes.patch" 45 | 46 | pushd "../django-hydra" >/dev/null 47 | TEMP_BRANCH="temp_patch_$(date +%s)" 48 | git checkout -b "$TEMP_BRANCH" 49 | 50 | if ! git apply -v --reject --directory="[[project_name]]" "$WORK_DIR/changes.patch" ; then 51 | log_warning "Merge conflicts detected. Resolve the conflicts then:" 52 | log_warning "1. git add changed files" 53 | log_warning "2. git commit -m '$COMMIT_MSG'" 54 | exit 1 55 | else 56 | git add . 57 | git commit -m "$COMMIT_MSG" 58 | fi 59 | 60 | git checkout "$ORIGINAL_BRANCH" 61 | if ! git merge "$TEMP_BRANCH" -m "Applied patch from instance project: $COMMIT_MSG"; then 62 | log_warning "Conflict during merge to $ORIGINAL_BRANCH. Resolve conflicts and merge manually." 63 | exit 1 64 | fi 65 | git branch -d "$TEMP_BRANCH" 66 | log_info "Successfully applied changes to template" 67 | popd >/dev/null 68 | -------------------------------------------------------------------------------- /template/scripts/export_project.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import html 3 | import os 4 | from fnmatch import fnmatch 5 | from pathlib import Path 6 | 7 | MANUAL_EXCLUDES = { 8 | "uv.lock", 9 | ".pytest_cache", 10 | ".coverage", 11 | ".ruff_cache", 12 | "dist", 13 | "build", 14 | "__pycache__", 15 | "*.pyc", 16 | "node_modules", 17 | "package-lock.json", 18 | ".venv", 19 | ".git", 20 | "static_source/assets/*", 21 | "*.svg", 22 | } 23 | 24 | 25 | def parse_gitignore(gitignore_path: Path) -> set[str]: 26 | if not gitignore_path.exists(): 27 | return set() 28 | 29 | patterns = set() 30 | with open(gitignore_path) as f: 31 | for line in f: 32 | line = line.strip() 33 | if not line or line.startswith("#"): 34 | continue 35 | 36 | # Normalize pattern 37 | if line.startswith("/"): 38 | line = line[1:] 39 | if line.endswith("/"): 40 | patterns.add(f"{line}**") 41 | line = line[:-1] 42 | 43 | patterns.add(line) 44 | # Add pattern with and without leading **/ to catch both absolute and relative paths 45 | if not line.startswith("**/"): 46 | patterns.add(f"**/{line}") 47 | 48 | return patterns 49 | 50 | 51 | def should_include(path: Path, gitignore_patterns: set[str], source_root: Path) -> bool: 52 | try: 53 | rel_path = str(path.relative_to(source_root)) 54 | except ValueError: 55 | return True 56 | 57 | # Check manual excludes first 58 | for pattern in MANUAL_EXCLUDES: 59 | if fnmatch(rel_path, pattern) or fnmatch(path.name, pattern): 60 | return False 61 | 62 | # Check gitignore patterns using full path 63 | for pattern in gitignore_patterns: 64 | if fnmatch(rel_path, pattern): 65 | return False 66 | 67 | return True 68 | 69 | 70 | def export_project(source_dir: str, output_file: str): 71 | source_path = Path(source_dir).resolve() 72 | gitignore_patterns = parse_gitignore(source_path / ".gitignore") 73 | 74 | with open(output_file, "w", encoding="utf-8") as f: 75 | f.write("\n") 76 | 77 | file_count = 0 78 | for root_dir, dirs, files in os.walk(source_path): 79 | root_path = Path(root_dir) 80 | dirs[:] = [d for d in dirs if should_include(root_path / d, gitignore_patterns, source_path)] 81 | 82 | for file in files: 83 | file_path = root_path / file 84 | if not should_include(file_path, gitignore_patterns, source_path): 85 | continue 86 | 87 | try: 88 | with open(file_path, encoding="utf-8") as src: 89 | content = src.read() 90 | 91 | relative_path = file_path.relative_to(source_path) 92 | f.write(f'\n') 93 | f.write(f"{html.escape(str(relative_path))}\n") 94 | f.write( 95 | f"{html.escape(content)}\n", 96 | ) 97 | f.write("\n") 98 | file_count += 1 99 | 100 | except UnicodeDecodeError: 101 | print(f"Skipping binary file: {file_path}") 102 | continue 103 | 104 | f.write("") 105 | print(f"Exported {file_count} files") 106 | 107 | 108 | if __name__ == "__main__": 109 | import argparse 110 | 111 | parser = argparse.ArgumentParser() 112 | parser.add_argument("source", help="Source directory") 113 | parser.add_argument("output", help="Output XML file") 114 | args = parser.parse_args() 115 | 116 | export_project(args.source, args.output) 117 | -------------------------------------------------------------------------------- /template/scripts/pull_remote_db.sh.jinja: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | export ENV_NAME="[[project_name]]" 5 | 6 | dropdb $ENV_NAME 7 | heroku pg:pull DATABASE_URL $ENV_NAME --app $ENV_NAME-development 8 | for tbl in `psql -qAt -c "select tablename from pg_tables where schemaname = 'public';" $ENV_NAME` ; do psql -c "alter table \"$tbl\" owner to $ENV_NAME" $ENV_NAME ; done 9 | for tbl in `psql -qAt -c "select sequence_name from information_schema.sequences where sequence_schema = 'public';" $ENV_NAME` ; do psql -c "alter table \"$tbl\" owner to $ENV_NAME" $ENV_NAME ; done 10 | -------------------------------------------------------------------------------- /template/tailwind.config.js.jinja: -------------------------------------------------------------------------------- 1 | const plugin = require('tailwindcss/plugin'); 2 | 3 | module.exports = { 4 | corePlugins: { 5 | preflight: false, //manually import this in app.css 6 | }, 7 | content: [ 8 | './[[project_name]]/static_source/**/*.html', 9 | './[[project_name]]/static_source/**/*.js', 10 | './[[project_name]]/static_source/**/*.scss', 11 | './[[project_name]]/static_source/**/*.sass', 12 | './[[project_name]]/templates/**/*.html', 13 | './[[project_name]]/components/**/*.html', 14 | './[[project_name]]/components/**/*.svg', 15 | './[[project_name]]/components/**/*.py', 16 | 17 | ], 18 | theme: { 19 | extend: { 20 | colors: { 21 | primary: { 22 | DEFAULT: "var(--primary)", 23 | focus: 'var(--primary-focus)', 24 | content: 'var(--primary-content)' 25 | }, 26 | secondary: { 27 | DEFAULT: 'var(--secondary)', 28 | focus: 'var(--secondary-focus)', 29 | content: 'var(--secondary-content)' 30 | }, 31 | accent: { 32 | DEFAULT: 'var(--accent)', 33 | 'focus': 'var(--accent-focus)', 34 | 'content': 'var(--accent-content)' 35 | }, 36 | neutral: { 37 | '100': '#F3F6FA', 38 | '200': '#E5E7EB', 39 | '300': '#D1D5DB', 40 | '400': '#9CA3AF', 41 | '500': '#6B7280', 42 | '600': '#4B5563', 43 | '700': '#374151', 44 | '800': '#1F2937', 45 | '900': '#111827', 46 | }, 47 | debug: { 48 | DEFAULT: 'var(--info)', 49 | 'focus': 'var(--info-focus)', 50 | 'content': 'var(--info-content)' 51 | }, 52 | info: { 53 | DEFAULT: 'var(--info)', 54 | 'focus': 'var(--info-focus)', 55 | 'content': 'var(--info-content)' 56 | }, 57 | success: { 58 | DEFAULT: 'var(--success)', 59 | 'focus': 'var(--success-focus)', 60 | 'content':'var(--success-content)', 61 | }, 62 | warning: { 63 | DEFAULT: 'var(--warning)', 64 | 'focus': 'var(--warning-focus)', 65 | 'content': 'var(--warning-content)' 66 | }, 67 | error: { 68 | DEFAULT: 'var(--error)', 69 | 'focus': 'var(--error-focus)', 70 | 'content': 'var(--error-content)' 71 | }, 72 | } 73 | }, 74 | }, 75 | plugins: [ 76 | plugin(function({ addVariant }) { 77 | // https://www.crocodile.dev/blog/css-transitions-with-tailwind-and-htmx 78 | addVariant('htmx-settling', ['&.htmx-settling', '.htmx-settling &']); 79 | addVariant('htmx-request', ['&.htmx-request', '.htmx-request &']); 80 | addVariant('htmx-swapping', ['&.htmx-swapping', '.htmx-swapping &']); 81 | addVariant('htmx-added', ['&.htmx-added', '.htmx-added &']); 82 | }), 83 | require('@tailwindcss/forms'), 84 | ], 85 | } 86 | -------------------------------------------------------------------------------- /template/tsconfig.json.jinja: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": ["vite/client"], 4 | "target": "es6", 5 | "useDefineForClassFields": true, 6 | "module": "esnext", 7 | "moduleResolution": "bundler", 8 | "strict": true, 9 | "jsx": "preserve", 10 | "sourceMap": true, 11 | "resolveJsonModule": true, 12 | "esModuleInterop": true, 13 | "skipLibCheck": true, 14 | "lib": ["esnext", "dom"] 15 | }, 16 | "include": [ 17 | "[[project_name]]/**/*.ts", 18 | "[[project_name]]/**/*.d.ts", 19 | "[[project_name]]/**/*.tsx", 20 | "[[project_name]]/**/*.vue", 21 | "[[project_name]]/**/*.js", 22 | "./*.js", 23 | "./*.ts" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /template/vite.config.mjs.jinja: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import { resolve } from 'path'; 3 | import fg from 'fast-glob'; 4 | 5 | export default defineConfig({ 6 | // Where the project's files are 7 | root: resolve('./[[project_name]]/static_source/'), 8 | // The public path where assets are served, both in development and in production. 9 | base: "/static/", 10 | resolve: { 11 | alias: { 12 | // Use '@' in urls as a shortcut for './static_source'. (Currently used in CSS files.) 13 | '@': resolve('./[[project_name]]/static_source') 14 | }, 15 | }, 16 | build: { 17 | manifest: "manifest.json", 18 | rollupOptions: { 19 | input: { 20 | /* The bundle's entry point(s). If you provide an array of entry points or an object mapping names to 21 | entry points, they will be bundled to separate output chunks. */ 22 | components: resolve(__dirname, './[[project_name]]/static_source/js/components.ts'), 23 | main: resolve(__dirname, './[[project_name]]/static_source/js/main.ts'), 24 | styles: resolve(__dirname, './[[project_name]]/static_source/css/styles.js'), 25 | admin: resolve(__dirname, './[[project_name]]/static_source/css/admin.js'), 26 | } 27 | }, 28 | outDir: './', // puts the manifest.json in PROJECT_ROOT/static_source/ for Django to collect 29 | }, 30 | plugins: [ 31 | { 32 | name: 'watch-external', // https://stackoverflow.com/questions/63373804/rollup-watch-include-directory/63548394#63548394 33 | async buildStart() { 34 | const htmls = await fg(['[[project_name]]/**/*.html']); 35 | for (let file of htmls) { 36 | this.addWatchFile(file); 37 | } 38 | } 39 | }, 40 | { 41 | name: 'reloadHtml', 42 | handleHotUpdate({ file, server }) { 43 | if (file.endsWith('.html')) { 44 | server.ws.send({ 45 | type: 'custom', 46 | event: 'template-hmr', 47 | path: '*', 48 | }); 49 | // returning an empty array prevents the hmr update from proceeding as normal 50 | return []; 51 | } 52 | }, 53 | } 54 | ], 55 | }); 56 | -------------------------------------------------------------------------------- /template/vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | 3 | export default defineConfig({ 4 | test: { 5 | environment: "happy-dom", 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | original=$(pwd) 4 | keepenv=false 5 | 6 | RED='\033[0;31m' 7 | YELLOW='\033[1;33m' 8 | CLEAR='\033[0m' 9 | 10 | 11 | tmpfolder="" 12 | appname=sampleapp 13 | appdir=../$appname 14 | 15 | unset DJANGO_SETTINGS_MODULE 16 | 17 | 18 | 19 | printf "${RED}Removing old app${CLEAR}\n" 20 | if [[ -d "../$appname" ]] 21 | then 22 | set +e 23 | rm -rf ../$appname 24 | dropdb test_sampleapp 25 | dropdb sampleapp 26 | set -e 27 | fi 28 | 29 | echo "Creating App" 30 | cookiecutter . --default-config --no-input project_name=$appname -o ../ 31 | 32 | 33 | echo "Running tests" 34 | cd ../$appname/ 35 | 36 | eval "$(direnv export bash)" 37 | ./scripts/create_new_project.sh 38 | npm run build 39 | pre-commit run --all-files 40 | playwright install 41 | poetry run pytest 42 | 43 | 44 | RV=$? 45 | rm -rf static/ 46 | cd $original 47 | exit $RV 48 | -------------------------------------------------------------------------------- /todo.txt: -------------------------------------------------------------------------------- 1 | fix silk/debug toolbar profiling 2 | https://github.com/django-commons/django-debug-toolbar/issues/1875 3 | https://github.com/jazzband/django-silk/issues/682 4 | 5 | swap cachealot for cacheops 6 | 7 | Make sure redis caching is configured bestly https://github.com/sebleier/django-redis-cache 8 | setup robots.txt and configure that to be part of django 9 | structlog 10 | Hijack 11 | 12 | 13 | 14 | **DJANGO 15 | set config up with 16 | https://github.com/rochacbruno/dynaconf 17 | 18 | Figure out html emails 19 | - https://github.com/peterbe/premailer 20 | - https://github.com/sunscrapers/django-templated-mail 21 | 22 | Get celery setup 23 | - https://github.com/pmclanahan/django-celery-email 24 | 25 | install 26 | 27 | 28 | 29 | next-boost looks nice 30 | https://github.com/rjyo/next-boost 31 | 32 | 33 | ** Documentation: 34 | Authentication 35 | - same subdomain cookie for same machine 36 | - domain cookie for different machines, same domain (must not be on https://publicsuffix.org/list/public_suffix_list.dat) 37 | - jwt for different domains. 38 | 39 | 40 | 41 | {{ foo:bar}} sets a dictionary in react code but is also a variable in jinja - look into changing jinja delimiter to something illegal in js 42 | 43 | 44 | 45 | documentation notes: 46 | Security 47 | 48 | - http only cookie 49 | 50 | 51 | 52 | mention that you need to use python3.8 to manage.py on prod 53 | 54 | 55 | Truly Static assets - where do they live 56 | - Django - easier to put a cdn in front of them. 57 | Get cdn configured with next and django 58 | https://nextjs.org/docs/api-reference/next.config.js/cdn-support-with-asset-prefix 59 | 60 | fix security issues from: 61 | https://securityheaders.com/?q=https%3A%2F%2Flightmatter-sampleapp.herokuapp.com%2F&followRedirects=on 62 | 63 | Better jest testing: 64 | https://github.com/testing-library/jest-dom 65 | 66 | 67 | cypress testing 68 | https://github.com/cypress-io/cypress-example-recipes 69 | - https://github.com/bahmutov/cypress-select-tests 70 | - get client side mock working w/ mirage, but not enabled when running tests through django 71 | - move nextjs server start into a once per class thing vs once per test in the django test case 72 | - figure out how to enforce new build of nextjs on running django tests 73 | - https://github.com/svish/cypress-hmr-restarter#readme for interactive testing 74 | - https://stackoverflow.com/questions/52231111/re-run-cypress-tests-in-gui-when-webpack-dev-server-causes-page-reload 75 | 76 | 77 | Sentry - Get consolidated error report for frontend and backend 78 | https://nextjs.org/docs/advanced-features/measuring-performance#sending-results-to-analytics 79 | https://github.com/zeit/next.js/discussions/12913 -- get error page working 80 | --------------------------------------------------------------------------------