├── .github └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── AGENTS.md ├── CONTRIBUTING.md ├── README.md ├── demos └── full │ ├── .gitignore │ ├── app │ ├── __init__.py │ ├── settings.py │ ├── urls.py │ └── users │ │ ├── __init__.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ │ └── models.py │ ├── pyproject.toml │ ├── scripts │ └── install │ ├── tailwind.css │ └── test_urls.py ├── plain-admin ├── LICENSE ├── README.md ├── plain │ └── admin │ │ ├── README.md │ │ ├── __init__.py │ │ ├── assets │ │ ├── admin │ │ │ ├── admin.css │ │ │ ├── admin.js │ │ │ ├── list.js │ │ │ └── vendor │ │ │ │ ├── chart.js │ │ │ │ ├── jquery-3.6.1.slim.min.js │ │ │ │ ├── popper.min.js │ │ │ │ └── tippy-bundle.umd.min.js │ │ └── toolbar │ │ │ └── toolbar.js │ │ ├── cards │ │ ├── __init__.py │ │ ├── base.py │ │ ├── charts.py │ │ └── tables.py │ │ ├── config.py │ │ ├── dates.py │ │ ├── default_settings.py │ │ ├── impersonate │ │ ├── README.md │ │ ├── __init__.py │ │ ├── middleware.py │ │ ├── models.py │ │ ├── permissions.py │ │ ├── settings.py │ │ ├── urls.py │ │ └── views.py │ │ ├── middleware.py │ │ ├── querystats │ │ ├── README.md │ │ ├── __init__.py │ │ ├── core.py │ │ ├── middleware.py │ │ ├── urls.py │ │ └── views.py │ │ ├── templates.py │ │ ├── templates │ │ ├── admin │ │ │ ├── base.html │ │ │ ├── cards │ │ │ │ ├── base.html │ │ │ │ ├── card.html │ │ │ │ ├── chart.html │ │ │ │ └── table.html │ │ │ ├── delete.html │ │ │ ├── detail.html │ │ │ ├── index.html │ │ │ ├── list.html │ │ │ ├── page.html │ │ │ ├── search.html │ │ │ └── values │ │ │ │ ├── UUID.html │ │ │ │ ├── bool.html │ │ │ │ ├── datetime.html │ │ │ │ ├── default.html │ │ │ │ ├── dict.html │ │ │ │ ├── get_display.html │ │ │ │ ├── img.html │ │ │ │ ├── list.html │ │ │ │ ├── model.html │ │ │ │ └── queryset.html │ │ ├── elements │ │ │ └── admin │ │ │ │ ├── Checkbox.html │ │ │ │ ├── CheckboxField.html │ │ │ │ ├── FieldErrors.html │ │ │ │ ├── Help.html │ │ │ │ ├── Input.html │ │ │ │ ├── InputField.html │ │ │ │ ├── Label.html │ │ │ │ ├── Select.html │ │ │ │ ├── SelectField.html │ │ │ │ ├── Submit.html │ │ │ │ ├── Textarea.html │ │ │ │ └── TextareaField.html │ │ ├── querystats │ │ │ ├── querystats.html │ │ │ └── toolbar.html │ │ └── toolbar │ │ │ ├── exception.html │ │ │ ├── querystats.html │ │ │ ├── request.html │ │ │ └── toolbar.html │ │ ├── toolbar.py │ │ ├── urls.py │ │ └── views │ │ ├── __init__.py │ │ ├── base.py │ │ ├── models.py │ │ ├── objects.py │ │ ├── registry.py │ │ ├── types.py │ │ └── viewsets.py ├── pyproject.toml └── tests │ ├── app │ ├── settings.py │ ├── urls.py │ └── users │ │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ │ └── models.py │ └── test_admin.py ├── plain-api ├── LICENSE ├── README.md ├── plain │ └── api │ │ ├── README.md │ │ ├── __init__.py │ │ ├── cli.py │ │ ├── config.py │ │ ├── default_settings.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_apikey_expires_at.py │ │ ├── 0003_alter_apikey_token_alter_apikey_uuid_and_more.py │ │ ├── 0004_apikey_version_name.py │ │ ├── 0005_rename_version_name_apikey_api_version_name.py │ │ ├── 0006_rename_api_version_name_apikey_api_version.py │ │ └── __init__.py │ │ ├── models.py │ │ ├── openapi │ │ ├── __init__.py │ │ ├── decorators.py │ │ ├── generator.py │ │ └── utils.py │ │ ├── schemas.py │ │ ├── versioning.py │ │ └── views.py ├── pyproject.toml └── tests │ ├── app │ ├── settings.py │ └── urls.py │ └── test_api.py ├── plain-auth ├── LICENSE ├── README.md ├── plain │ └── auth │ │ ├── README.md │ │ ├── __init__.py │ │ ├── default_settings.py │ │ ├── middleware.py │ │ ├── sessions.py │ │ ├── utils.py │ │ └── views.py └── pyproject.toml ├── plain-cache ├── LICENSE ├── README.md ├── plain │ └── cache │ │ ├── README.md │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── chores.py │ │ ├── cli.py │ │ ├── config.py │ │ ├── core.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_rename_cacheitem_cacheditem.py │ │ ├── 0003_alter_cacheditem_key_and_more.py │ │ ├── 0004_alter_cacheditem_expires_at_and_more.py │ │ └── __init__.py │ │ └── models.py └── pyproject.toml ├── plain-code ├── .gitignore ├── LICENSE ├── README.md ├── plain │ └── code │ │ ├── README.md │ │ ├── __init__.py │ │ ├── biome.py │ │ ├── biome_defaults.json │ │ ├── cli.py │ │ ├── entrypoints.py │ │ └── ruff_defaults.toml └── pyproject.toml ├── plain-dev ├── .gitignore ├── LICENSE ├── README.md ├── plain │ └── dev │ │ ├── README.md │ │ ├── __init__.py │ │ ├── cli.py │ │ ├── contribute │ │ ├── README.md │ │ ├── __init__.py │ │ └── cli.py │ │ ├── debug.py │ │ ├── default_settings.py │ │ ├── entrypoints.py │ │ ├── gunicorn_logging.json │ │ ├── mkcert.py │ │ ├── pdb.py │ │ ├── poncho │ │ ├── __init__.py │ │ ├── color.py │ │ ├── compat.py │ │ ├── manager.py │ │ ├── printer.py │ │ └── process.py │ │ ├── precommit │ │ ├── __init__.py │ │ └── cli.py │ │ ├── requests.py │ │ ├── services.py │ │ ├── templates │ │ └── dev │ │ │ └── requests.html │ │ ├── urls.py │ │ ├── utils.py │ │ └── views.py ├── pyproject.toml └── tests │ └── settings.py ├── plain-elements ├── .gitignore ├── LICENSE ├── README.md ├── plain │ └── elements │ │ ├── README.md │ │ ├── __init__.py │ │ └── templates.py ├── pyproject.toml └── tests │ ├── app │ ├── settings.py │ └── urls.py │ └── test_elements.py ├── plain-email ├── LICENSE ├── README.md ├── plain │ └── email │ │ ├── README.md │ │ ├── __init__.py │ │ ├── backends │ │ ├── __init__.py │ │ ├── base.py │ │ ├── console.py │ │ ├── filebased.py │ │ └── smtp.py │ │ ├── default_settings.py │ │ ├── message.py │ │ └── utils.py └── pyproject.toml ├── plain-esbuild ├── .gitignore ├── LICENSE ├── README.md ├── plain │ └── esbuild │ │ ├── README.md │ │ ├── __init__.py │ │ ├── cli.py │ │ ├── core.py │ │ └── entrypoints.py └── pyproject.toml ├── plain-flags ├── LICENSE ├── README.md ├── plain │ └── flags │ │ ├── README.md │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── bridge.py │ │ ├── config.py │ │ ├── default_settings.py │ │ ├── exceptions.py │ │ ├── flags.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_alter_flagresult_unique_together_and_more.py │ │ ├── 0003_remove_flagresult_unique_flag_result_key_and_more.py │ │ └── __init__.py │ │ ├── models.py │ │ ├── py.typed │ │ ├── templates.py │ │ ├── templates │ │ └── admin │ │ │ └── plainflags │ │ │ └── flagresult_form.html │ │ └── utils.py ├── pyproject.toml └── tests │ ├── app │ ├── settings.py │ └── urls.py │ └── test_flags.py ├── plain-htmx ├── .gitignore ├── LICENSE ├── README.md ├── deps.yml ├── package-lock.json ├── package.json ├── plain │ └── htmx │ │ ├── README.md │ │ ├── __init__.py │ │ ├── assets │ │ └── htmx │ │ │ ├── plainhtmx.js │ │ │ └── vendor │ │ │ ├── idiomorph │ │ │ ├── idiomorph-ext.js │ │ │ ├── idiomorph-ext.min.js │ │ │ ├── idiomorph-htmx.js │ │ │ ├── idiomorph.amd.js │ │ │ ├── idiomorph.cjs.js │ │ │ ├── idiomorph.esm.js │ │ │ ├── idiomorph.js │ │ │ └── idiomorph.min.js │ │ │ └── src │ │ │ ├── ext │ │ │ ├── README.md │ │ │ ├── ajax-header.js │ │ │ ├── alpine-morph.js │ │ │ ├── class-tools.js │ │ │ ├── client-side-templates.js │ │ │ ├── debug.js │ │ │ ├── disable-element.js │ │ │ ├── event-header.js │ │ │ ├── head-support.js │ │ │ ├── include-vals.js │ │ │ ├── json-enc.js │ │ │ ├── loading-states.js │ │ │ ├── method-override.js │ │ │ ├── morphdom-swap.js │ │ │ ├── multi-swap.js │ │ │ ├── path-deps.js │ │ │ ├── path-params.js │ │ │ ├── preload.js │ │ │ ├── rails-method.js │ │ │ ├── remove-me.js │ │ │ ├── response-targets.js │ │ │ ├── restored.js │ │ │ ├── sse.js │ │ │ └── ws.js │ │ │ ├── htmx.amd.js │ │ │ ├── htmx.cjs.js │ │ │ ├── htmx.esm.d.ts │ │ │ ├── htmx.esm.js │ │ │ ├── htmx.js │ │ │ ├── htmx.min.js │ │ │ └── htmx.min.js.gz │ │ ├── templates.py │ │ ├── templates │ │ └── htmx │ │ │ └── js.html │ │ └── views.py ├── pyproject.toml └── tests │ ├── settings.py │ └── test_views.py ├── plain-loginlink ├── LICENSE ├── README.md ├── plain │ └── loginlink │ │ ├── README.md │ │ ├── __init__.py │ │ ├── forms.py │ │ ├── links.py │ │ ├── signing.py │ │ ├── templates │ │ ├── email │ │ │ ├── loginlink.html │ │ │ └── loginlink.subject.txt │ │ └── loginlink │ │ │ ├── failed.html │ │ │ └── sent.html │ │ ├── urls.py │ │ └── views.py └── pyproject.toml ├── plain-models ├── LICENSE ├── README.md ├── plain │ └── models │ │ ├── README.md │ │ ├── __init__.py │ │ ├── aggregates.py │ │ ├── backends │ │ ├── __init__.py │ │ ├── base │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── client.py │ │ │ ├── creation.py │ │ │ ├── features.py │ │ │ ├── introspection.py │ │ │ ├── operations.py │ │ │ ├── schema.py │ │ │ └── validation.py │ │ ├── ddl_references.py │ │ ├── mysql │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── client.py │ │ │ ├── compiler.py │ │ │ ├── creation.py │ │ │ ├── features.py │ │ │ ├── introspection.py │ │ │ ├── operations.py │ │ │ ├── schema.py │ │ │ └── validation.py │ │ ├── postgresql │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── client.py │ │ │ ├── creation.py │ │ │ ├── features.py │ │ │ ├── introspection.py │ │ │ ├── operations.py │ │ │ └── schema.py │ │ ├── sqlite3 │ │ │ ├── __init__.py │ │ │ ├── _functions.py │ │ │ ├── base.py │ │ │ ├── client.py │ │ │ ├── creation.py │ │ │ ├── features.py │ │ │ ├── introspection.py │ │ │ ├── operations.py │ │ │ └── schema.py │ │ └── utils.py │ │ ├── backups │ │ ├── __init__.py │ │ ├── cli.py │ │ ├── clients.py │ │ └── core.py │ │ ├── base.py │ │ ├── cli.py │ │ ├── config.py │ │ ├── constants.py │ │ ├── constraints.py │ │ ├── database_url.py │ │ ├── db.py │ │ ├── default_settings.py │ │ ├── deletion.py │ │ ├── entrypoints.py │ │ ├── enums.py │ │ ├── expressions.py │ │ ├── fields │ │ ├── __init__.py │ │ ├── json.py │ │ ├── mixins.py │ │ ├── related.py │ │ ├── related_descriptors.py │ │ ├── related_lookups.py │ │ └── reverse_related.py │ │ ├── forms.py │ │ ├── functions │ │ ├── __init__.py │ │ ├── comparison.py │ │ ├── datetime.py │ │ ├── math.py │ │ ├── mixins.py │ │ ├── text.py │ │ └── window.py │ │ ├── indexes.py │ │ ├── lookups.py │ │ ├── manager.py │ │ ├── migrations │ │ ├── __init__.py │ │ ├── autodetector.py │ │ ├── exceptions.py │ │ ├── executor.py │ │ ├── graph.py │ │ ├── loader.py │ │ ├── migration.py │ │ ├── operations │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── fields.py │ │ │ ├── models.py │ │ │ └── special.py │ │ ├── optimizer.py │ │ ├── questioner.py │ │ ├── recorder.py │ │ ├── serializer.py │ │ ├── state.py │ │ ├── utils.py │ │ └── writer.py │ │ ├── options.py │ │ ├── preflight.py │ │ ├── query.py │ │ ├── query_utils.py │ │ ├── registry.py │ │ ├── sql │ │ ├── __init__.py │ │ ├── compiler.py │ │ ├── constants.py │ │ ├── datastructures.py │ │ ├── query.py │ │ ├── subqueries.py │ │ └── where.py │ │ ├── test │ │ ├── __init__.py │ │ ├── pytest.py │ │ └── utils.py │ │ ├── transaction.py │ │ └── utils.py ├── pyproject.toml └── tests │ ├── app │ ├── examples │ │ ├── migrations │ │ │ ├── 0001_initial.py │ │ │ ├── 0002_test_field_removed.py │ │ │ └── __init__.py │ │ └── models.py │ ├── settings.py │ └── urls.py │ └── test_models.py ├── plain-oauth ├── .gitignore ├── LICENSE ├── README.md ├── plain │ └── oauth │ │ ├── README.md │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── config.py │ │ ├── default_settings.py │ │ ├── exceptions.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_alter_oauthconnection_options_and_more.py │ │ ├── 0003_alter_oauthconnection_access_token_and_more.py │ │ ├── 0004_alter_oauthconnection_access_token_and_more.py │ │ ├── 0005_alter_oauthconnection_unique_together_and_more.py │ │ ├── 0006_remove_oauthconnection_unique_oauth_provider_user_id_and_more.py │ │ ├── 0007_alter_oauthconnection_provider_key_and_more.py │ │ └── __init__.py │ │ ├── models.py │ │ ├── providers.py │ │ ├── templates │ │ └── oauth │ │ │ └── callback.html │ │ ├── urls.py │ │ └── views.py ├── provider_examples │ ├── __init__.py │ ├── bitbucket.py │ ├── github.py │ └── gitlab.py ├── pyproject.toml └── tests │ ├── app │ ├── settings.py │ ├── templates │ │ ├── base.html │ │ ├── index.html │ │ └── login.html │ ├── urls.py │ └── users │ │ ├── migrations │ │ ├── 0001_initial.py │ │ └── __init__.py │ │ └── models.py │ ├── provider_tests │ ├── __init__.py │ └── test_github.py │ ├── providers │ ├── __init__.py │ ├── bitbucket.py │ ├── github.py │ └── gitlab.py │ ├── test_backends.py │ ├── test_checks.py │ └── test_providers.py ├── plain-pages ├── .gitignore ├── LICENSE ├── README.md ├── plain │ └── pages │ │ ├── README.md │ │ ├── __init__.py │ │ ├── config.py │ │ ├── exceptions.py │ │ ├── markdown.py │ │ ├── pages.py │ │ ├── registry.py │ │ ├── templates │ │ └── page.html │ │ ├── urls.py │ │ └── views.py └── pyproject.toml ├── plain-pageviews ├── README.md ├── plain │ └── pageviews │ │ ├── README.md │ │ ├── admin.py │ │ ├── assets │ │ └── pageviews │ │ │ └── pageviews.js │ │ ├── chores.py │ │ ├── config.py │ │ ├── default_settings.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_alter_pageview_referrer_alter_pageview_title_and_more.py │ │ ├── 0003_alter_pageview_uuid.py │ │ ├── 0004_alter_pageview_uuid_and_more.py │ │ ├── 0005_alter_pageview_session_key_alter_pageview_timestamp_and_more.py │ │ └── __init__.py │ │ ├── models.py │ │ ├── templates.py │ │ ├── templates │ │ └── pageviews │ │ │ ├── card.html │ │ │ └── js.html │ │ ├── urls.py │ │ └── views.py └── pyproject.toml ├── plain-passwords ├── LICENSE ├── README.md ├── plain │ └── passwords │ │ ├── README.md │ │ ├── __init__.py │ │ ├── common-passwords.txt.gz │ │ ├── core.py │ │ ├── default_settings.py │ │ ├── forms.py │ │ ├── hashers.py │ │ ├── models.py │ │ ├── templates │ │ └── email │ │ │ ├── password_reset.html │ │ │ └── password_reset.subject.txt │ │ ├── utils.py │ │ ├── validators.py │ │ └── views.py └── pyproject.toml ├── plain-pytest ├── .gitignore ├── LICENSE ├── README.md ├── plain │ └── pytest │ │ ├── README.md │ │ ├── __init__.py │ │ ├── cli.py │ │ ├── entrypoints.py │ │ └── plugin.py └── pyproject.toml ├── plain-redirection ├── README.md ├── plain │ └── redirection │ │ ├── README.md │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── chores.py │ │ ├── config.py │ │ ├── default_settings.py │ │ ├── middleware.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_redirect_enabled_redirect_is_regex.py │ │ ├── 0003_alter_redirect_from_pattern.py │ │ ├── 0004_alter_notfoundlog_referer_alter_redirectlog_referer.py │ │ ├── 0005_alter_notfoundlog_referer_and_more.py │ │ ├── 0006_alter_notfoundlog_user_agent_and_more.py │ │ ├── 0007_alter_redirect_http_status_alter_redirect_order_and_more.py │ │ ├── 0008_alter_notfoundlog_url_alter_redirectlog_from_url_and_more.py │ │ ├── 0009_rename_referer_notfoundlog_referrer_and_more.py │ │ ├── 0010_alter_redirect_from_pattern_and_more.py │ │ ├── 0011_alter_redirect_order_and_more.py │ │ └── __init__.py │ │ ├── models.py │ │ └── templates │ │ └── admin │ │ └── plainredirection │ │ └── redirect_form.html └── pyproject.toml ├── plain-sessions ├── LICENSE ├── README.md ├── plain │ └── sessions │ │ ├── README.md │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── chores.py │ │ ├── config.py │ │ ├── core.py │ │ ├── default_settings.py │ │ ├── exceptions.py │ │ ├── middleware.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_alter_session_options_alter_session_expire_date_and_more.py │ │ ├── 0003_alter_session_expire_date_and_more.py │ │ ├── 0004_alter_session_managers_and_more.py │ │ └── __init__.py │ │ ├── models.py │ │ ├── preflight.py │ │ └── templates │ │ └── toolbar │ │ └── session.html ├── pyproject.toml └── tests │ ├── app │ ├── settings.py │ └── urls.py │ └── test_sessions.py ├── plain-support ├── README.md ├── plain │ └── support │ │ ├── README.md │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── assets │ │ └── support │ │ │ ├── embed.js │ │ │ └── iframe.js │ │ ├── config.py │ │ ├── default_settings.py │ │ ├── forms.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_alter_supportformentry_options.py │ │ ├── 0003_alter_supportformentry_uuid_and_more.py │ │ └── __init__.py │ │ ├── models.py │ │ ├── templates │ │ ├── email │ │ │ └── support_form_entry.html │ │ └── support │ │ │ ├── card.html │ │ │ ├── forms │ │ │ └── default.html │ │ │ ├── iframe.html │ │ │ ├── page.html │ │ │ └── success │ │ │ └── default.html │ │ ├── urls.py │ │ └── views.py └── pyproject.toml ├── plain-tailwind ├── .gitignore ├── LICENSE ├── README.md ├── plain │ └── tailwind │ │ ├── README.md │ │ ├── __init__.py │ │ ├── cli.py │ │ ├── core.py │ │ ├── default_settings.py │ │ ├── entrypoints.py │ │ ├── templates.py │ │ └── templates │ │ └── tailwind │ │ └── css.html └── pyproject.toml ├── plain-tunnel ├── .gitignore ├── LICENSE ├── README.md ├── plain │ └── tunnel │ │ ├── README.md │ │ ├── __init__.py │ │ ├── cli.py │ │ ├── client.py │ │ └── entrypoints.py ├── pyproject.toml └── server │ ├── .gitignore │ ├── package-lock.json │ ├── package.json │ ├── worker.js │ └── wrangler.toml ├── plain-vendor ├── LICENSE ├── README.md ├── plain │ └── vendor │ │ ├── README.md │ │ ├── __init__.py │ │ ├── cli.py │ │ ├── deps.py │ │ ├── entrypoints.py │ │ └── exceptions.py └── pyproject.toml ├── plain-worker ├── .gitignore ├── LICENSE ├── README.md ├── plain │ └── worker │ │ ├── README.md │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── chores.py │ │ ├── cli.py │ │ ├── config.py │ │ ├── default_settings.py │ │ ├── jobs.py │ │ ├── middleware.py │ │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_jobresult_remove_jobrequest_completed_at_and_more.py │ │ ├── 0003_jobresult_status.py │ │ ├── 0004_alter_jobresult_options.py │ │ ├── 0005_remove_jobresult_updated_at.py │ │ ├── 0006_rename_completed_at_jobresult_ended_at.py │ │ ├── 0007_alter_jobresult_status.py │ │ ├── 0008_jobrequest_source_jobresult_source.py │ │ ├── 0009_alter_jobresult_status.py │ │ ├── 0010_alter_jobresult_status.py │ │ ├── 0011_jobrequest_retries_jobrequest_retry_attempt_and_more.py │ │ ├── 0012_job_jobresult_job_uuid_alter_jobresult_status.py │ │ ├── 0013_alter_job_options_alter_jobresult_options_and_more.py │ │ ├── 0014_job_unique_key_jobrequest_unique_key_and_more.py │ │ ├── 0015_job_worker_uuid_jobresult_worker_uuid_and_more.py │ │ ├── 0016_remove_job_worker_uuid_remove_jobresult_worker_uuid.py │ │ ├── 0017_job_queue_jobrequest_queue_jobresult_queue.py │ │ ├── 0018_jobrequest_unique_job_class_unique_key.py │ │ ├── 0019_remove_jobrequest_unique_job_class_unique_key_and_more.py │ │ ├── 0020_alter_job_created_at_alter_job_job_class_and_more.py │ │ └── __init__.py │ │ ├── models.py │ │ ├── registry.py │ │ ├── scheduling.py │ │ ├── templates │ │ └── admin │ │ │ └── plainqueue │ │ │ └── jobresult_detail.html │ │ └── workers.py ├── pyproject.toml └── tests │ ├── app │ └── settings.py │ └── test_scheduling.py ├── plain ├── LICENSE ├── README.md ├── plain │ ├── README.md │ ├── __main__.py │ ├── assets │ │ ├── README.md │ │ ├── __init__.py │ │ ├── compile.py │ │ ├── finders.py │ │ ├── fingerprints.py │ │ ├── urls.py │ │ └── views.py │ ├── chores │ │ ├── README.md │ │ ├── __init__.py │ │ └── registry.py │ ├── cli │ │ ├── README.md │ │ ├── __init__.py │ │ ├── build.py │ │ ├── chores.py │ │ ├── core.py │ │ ├── docs.py │ │ ├── formatting.py │ │ ├── preflight.py │ │ ├── print.py │ │ ├── registry.py │ │ ├── scaffold.py │ │ ├── settings.py │ │ ├── shell.py │ │ ├── startup.py │ │ ├── urls.py │ │ └── utils.py │ ├── csrf │ │ ├── README.md │ │ ├── middleware.py │ │ └── views.py │ ├── debug.py │ ├── exceptions.py │ ├── forms │ │ ├── README.md │ │ ├── __init__.py │ │ ├── boundfield.py │ │ ├── exceptions.py │ │ ├── fields.py │ │ └── forms.py │ ├── http │ │ ├── README.md │ │ ├── __init__.py │ │ ├── cookie.py │ │ ├── multipartparser.py │ │ ├── request.py │ │ └── response.py │ ├── internal │ │ ├── __init__.py │ │ ├── files │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── locks.py │ │ │ ├── move.py │ │ │ ├── temp.py │ │ │ ├── uploadedfile.py │ │ │ ├── uploadhandler.py │ │ │ └── utils.py │ │ ├── handlers │ │ │ ├── __init__.py │ │ │ ├── base.py │ │ │ ├── exception.py │ │ │ └── wsgi.py │ │ └── middleware │ │ │ ├── __init__.py │ │ │ ├── headers.py │ │ │ ├── https.py │ │ │ └── slash.py │ ├── json.py │ ├── logs │ │ ├── README.md │ │ ├── __init__.py │ │ ├── configure.py │ │ ├── loggers.py │ │ └── utils.py │ ├── packages │ │ ├── README.md │ │ ├── __init__.py │ │ ├── config.py │ │ └── registry.py │ ├── paginator.py │ ├── preflight │ │ ├── README.md │ │ ├── __init__.py │ │ ├── files.py │ │ ├── messages.py │ │ ├── registry.py │ │ ├── security.py │ │ └── urls.py │ ├── runtime │ │ ├── README.md │ │ ├── __init__.py │ │ ├── global_settings.py │ │ └── user_settings.py │ ├── signals │ │ ├── README.md │ │ ├── __init__.py │ │ └── dispatch │ │ │ ├── __init__.py │ │ │ ├── dispatcher.py │ │ │ └── license.txt │ ├── signing.py │ ├── templates │ │ ├── README.md │ │ ├── __init__.py │ │ ├── core.py │ │ └── jinja │ │ │ ├── __init__.py │ │ │ ├── environments.py │ │ │ ├── extensions.py │ │ │ ├── filters.py │ │ │ └── globals.py │ ├── test │ │ ├── README.md │ │ ├── __init__.py │ │ ├── client.py │ │ ├── encoding.py │ │ └── exceptions.py │ ├── urls │ │ ├── README.md │ │ ├── __init__.py │ │ ├── converters.py │ │ ├── exceptions.py │ │ ├── patterns.py │ │ ├── resolvers.py │ │ ├── routers.py │ │ └── utils.py │ ├── utils │ │ ├── README.md │ │ ├── __init__.py │ │ ├── cache.py │ │ ├── connection.py │ │ ├── crypto.py │ │ ├── datastructures.py │ │ ├── dateparse.py │ │ ├── deconstruct.py │ │ ├── decorators.py │ │ ├── duration.py │ │ ├── encoding.py │ │ ├── functional.py │ │ ├── hashable.py │ │ ├── html.py │ │ ├── http.py │ │ ├── inspect.py │ │ ├── ipv6.py │ │ ├── itercompat.py │ │ ├── module_loading.py │ │ ├── regex_helper.py │ │ ├── safestring.py │ │ ├── text.py │ │ ├── timesince.py │ │ ├── timezone.py │ │ └── tree.py │ ├── validators.py │ ├── views │ │ ├── README.md │ │ ├── __init__.py │ │ ├── base.py │ │ ├── csrf.py │ │ ├── errors.py │ │ ├── exceptions.py │ │ ├── forms.py │ │ ├── objects.py │ │ ├── redirect.py │ │ └── templates.py │ └── wsgi.py ├── pyproject.toml └── tests │ ├── .gitignore │ ├── app │ ├── .gitignore │ ├── settings.py │ ├── test │ │ ├── __init__.py │ │ └── default_settings.py │ └── urls.py │ ├── conftest.py │ ├── test_cli.py │ ├── test_runtime.py │ └── test_wsgi.py ├── pyproject.toml ├── scripts ├── fix ├── install ├── llms-full ├── pre-commit ├── publish ├── semver-bump ├── since-release ├── test ├── to-release └── vulture └── uv.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .venv 2 | .env 3 | *.egg-info 4 | *.py[co] 5 | __pycache__ 6 | *.DS_Store 7 | .coverage 8 | 9 | # Test apps 10 | plain*/tests/.plain 11 | 12 | # Ottobot 13 | .aider* 14 | 15 | /llms-full.txt 16 | 17 | # Plain temp dirs 18 | .plain 19 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Agents 2 | 3 | This is the only AGENTS.md file in the repo -- you don't need to look for others. 4 | 5 | ## Commands 6 | 7 | - Run tests on all packages: `./scripts/test` 8 | - Lint and format code: `./scripts/fix` 9 | - Make database migrations: `cd demos/full && uv run plain makemigrations` 10 | 11 | ## READMEs 12 | 13 | Inside each top level subdirectory is a `README.md` that is a symlink to the `README.md` in the of the Python package itself. You only need to edit the `README.md` inside of the package itself. 14 | 15 | ## Verifying changes 16 | 17 | Not everything needs a test, but be liberal about using `print()` statements to verify changes and show the before and after effects of your changes. Make sure those print statements are removed before committing your changes. 18 | -------------------------------------------------------------------------------- /demos/full/.gitignore: -------------------------------------------------------------------------------- 1 | app/assets/tailwind.min.css 2 | -------------------------------------------------------------------------------- /demos/full/app/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/demos/full/app/__init__.py -------------------------------------------------------------------------------- /demos/full/app/urls.py: -------------------------------------------------------------------------------- 1 | from plain.admin.urls import AdminRouter 2 | from plain.assets.urls import AssetsRouter 3 | from plain.auth.views import LogoutView 4 | from plain.passwords.views import PasswordLoginView 5 | from plain.urls import Router, include, path 6 | from plain.views import View 7 | 8 | 9 | class LoginView(PasswordLoginView): 10 | pass 11 | 12 | 13 | class IndexView(View): 14 | def get(self): 15 | return "Index!" 16 | 17 | 18 | class AppRouter(Router): 19 | namespace = "" 20 | urls = [ 21 | include("admin/", AdminRouter), 22 | include("assets/", AssetsRouter), 23 | path("login/", LoginView, name="login"), 24 | path("logout/", LogoutView, name="logout"), 25 | path("", IndexView, name="index"), 26 | ] 27 | -------------------------------------------------------------------------------- /demos/full/app/users/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/demos/full/app/users/__init__.py -------------------------------------------------------------------------------- /demos/full/app/users/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/demos/full/app/users/migrations/__init__.py -------------------------------------------------------------------------------- /demos/full/app/users/models.py: -------------------------------------------------------------------------------- 1 | from plain import models 2 | from plain.passwords.models import PasswordField 3 | 4 | 5 | @models.register_model 6 | class User(models.Model): 7 | email = models.EmailField() 8 | password = PasswordField() 9 | is_admin = models.BooleanField(default=False) 10 | -------------------------------------------------------------------------------- /demos/full/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain-demo-full" 3 | version = "0.0.0" 4 | requires-python = ">=3.11" 5 | dependencies = [ 6 | # All of the Plain packages (from the workspace) 7 | "plain", 8 | "plain-admin", 9 | "plain-api", 10 | "plain-auth", 11 | "plain-cache", 12 | "plain-code", 13 | "plain-dev", 14 | "plain-elements", 15 | "plain-esbuild", 16 | "plain-flags", 17 | "plain-htmx", 18 | "plain-loginlink", 19 | "plain-email", 20 | "plain-models", 21 | "plain-oauth", 22 | "plain-pages", 23 | "plain-pageviews", 24 | "plain-passwords", 25 | "plain-pytest", 26 | "plain-redirection", 27 | "plain-sessions", 28 | "plain-support", 29 | "plain-tailwind", 30 | "plain-tunnel", 31 | "plain-vendor", 32 | "plain-worker", 33 | ] 34 | 35 | [tool.plain.tailwind] 36 | version = "4.1.7" 37 | 38 | [tool.plain.code.biome] 39 | version = "1.9.4" 40 | -------------------------------------------------------------------------------- /demos/full/scripts/install: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | uv sync 3 | uv run plain code install 4 | uv run plain tailwind install 5 | -------------------------------------------------------------------------------- /demos/full/tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "./.plain/tailwind.css"; 3 | -------------------------------------------------------------------------------- /demos/full/test_urls.py: -------------------------------------------------------------------------------- 1 | from app.users.models import User 2 | 3 | from plain.test import Client 4 | 5 | 6 | def test_admin_access(db): 7 | client = Client() 8 | 9 | # Login required 10 | assert client.get("/admin/").status_code == 302 11 | 12 | user = User.objects.create(email="admin@example.com", password="strongpass1") 13 | client.force_login(user) 14 | 15 | # Not admin yet 16 | assert client.get("/admin/").status_code == 404 17 | 18 | user.is_admin = True 19 | user.save() 20 | 21 | # Now admin 22 | assert client.get("/admin/").status_code in {200, 302} 23 | -------------------------------------------------------------------------------- /plain-admin/README.md: -------------------------------------------------------------------------------- 1 | plain/admin/README.md -------------------------------------------------------------------------------- /plain-admin/plain/admin/__init__.py: -------------------------------------------------------------------------------- 1 | from .middleware import AdminMiddleware 2 | 3 | __all__ = [ 4 | "AdminMiddleware", 5 | ] 6 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/cards/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import Card 2 | from .charts import ChartCard, TrendCard 3 | from .tables import TableCard 4 | 5 | __all__ = [ 6 | "Card", 7 | "ChartCard", 8 | "TrendCard", 9 | "TableCard", 10 | ] 11 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/cards/tables.py: -------------------------------------------------------------------------------- 1 | from .base import Card 2 | 3 | 4 | class TableCard(Card): 5 | template_name = "admin/cards/table.html" 6 | size = Card.Sizes.FULL 7 | 8 | headers = [] 9 | rows = [] 10 | footers = [] 11 | 12 | def get_template_context(self): 13 | context = super().get_template_context() 14 | context["headers"] = self.get_headers() 15 | context["rows"] = self.get_rows() 16 | context["footers"] = self.get_footers() 17 | return context 18 | 19 | def get_headers(self): 20 | return self.headers.copy() 21 | 22 | def get_rows(self): 23 | return self.rows.copy() 24 | 25 | def get_footers(self): 26 | return self.footers.copy() 27 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/config.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from importlib.util import find_spec 3 | 4 | from plain.packages import PackageConfig, packages_registry, register_config 5 | 6 | 7 | @register_config 8 | class Config(PackageConfig): 9 | package_label = "plainadmin" 10 | 11 | def ready(self): 12 | def _import_if_exists(module_name): 13 | if find_spec(module_name): 14 | import_module(module_name) 15 | 16 | # Trigger register calls to fire by importing the modules 17 | for package_config in packages_registry.get_package_configs(): 18 | _import_if_exists(f"{package_config.name}.admin") 19 | 20 | # Also trigger for the root app/admin.py module 21 | _import_if_exists("app.admin") 22 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/default_settings.py: -------------------------------------------------------------------------------- 1 | ADMIN_TOOLBAR_CLASS = "plain.admin.toolbar.Toolbar" 2 | ADMIN_TOOLBAR_VERSION: str = "dev" 3 | 4 | ADMIN_QUERYSTATS_IGNORE_URLS: list[str] = [ 5 | "/assets/.*", 6 | "/admin/querystats/.*", 7 | "/favicon.ico", 8 | ] 9 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/impersonate/__init__.py: -------------------------------------------------------------------------------- 1 | from .middleware import ImpersonateMiddleware 2 | 3 | __all__ = ["ImpersonateMiddleware"] 4 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/impersonate/models.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-admin/plain/admin/impersonate/models.py -------------------------------------------------------------------------------- /plain-admin/plain/admin/impersonate/permissions.py: -------------------------------------------------------------------------------- 1 | from . import settings 2 | 3 | 4 | def can_be_impersonator(user): 5 | return settings.IMPERSONATE_ALLOWED(user) 6 | 7 | 8 | def can_impersonate_user(impersonator, target_user): 9 | if not can_be_impersonator(impersonator): 10 | return False 11 | 12 | # You can't impersonate admin users 13 | if target_user.is_admin: 14 | return False 15 | 16 | return True 17 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/impersonate/settings.py: -------------------------------------------------------------------------------- 1 | from plain.runtime import settings 2 | 3 | 4 | def IMPERSONATE_ALLOWED(user): 5 | if hasattr(settings, "IMPERSONATE_ALLOWED"): 6 | return settings.IMPERSONATE_ALLOWED(user) 7 | 8 | return user.is_admin 9 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/impersonate/urls.py: -------------------------------------------------------------------------------- 1 | from plain.urls import Router, path 2 | 3 | from .views import ImpersonateStartView, ImpersonateStopView 4 | 5 | 6 | class ImpersonateRouter(Router): 7 | namespace = "impersonate" 8 | urls = [ 9 | path("stop/", ImpersonateStopView, name="stop"), 10 | path("start//", ImpersonateStartView, name="start"), 11 | ] 12 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/impersonate/views.py: -------------------------------------------------------------------------------- 1 | from plain.http import ResponseForbidden, ResponseRedirect 2 | from plain.views import View 3 | 4 | from .permissions import can_be_impersonator 5 | 6 | IMPERSONATE_KEY = "impersonate" 7 | 8 | 9 | class ImpersonateStartView(View): 10 | def get(self): 11 | # We *could* already be impersonating, so need to consider that 12 | impersonator = getattr(self.request, "impersonator", self.request.user) 13 | if impersonator and can_be_impersonator(impersonator): 14 | self.request.session[IMPERSONATE_KEY] = self.url_kwargs["pk"] 15 | return ResponseRedirect(self.request.query_params.get("next", "/")) 16 | 17 | return ResponseForbidden() 18 | 19 | 20 | class ImpersonateStopView(View): 21 | def get(self): 22 | self.request.session.pop(IMPERSONATE_KEY) 23 | return ResponseRedirect(self.request.query_params.get("next", "/")) 24 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/middleware.py: -------------------------------------------------------------------------------- 1 | from .impersonate.middleware import ImpersonateMiddleware 2 | from .querystats.middleware import QueryStatsMiddleware 3 | 4 | 5 | class AdminMiddleware: 6 | """All admin-related middleware in a single class.""" 7 | 8 | def __init__(self, get_response): 9 | self.get_response = get_response 10 | 11 | def __call__(self, request): 12 | return QueryStatsMiddleware(ImpersonateMiddleware(self.get_response))(request) 13 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/querystats/__init__.py: -------------------------------------------------------------------------------- 1 | from .middleware import QueryStatsMiddleware 2 | 3 | __all__ = ["QueryStatsMiddleware"] 4 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/querystats/urls.py: -------------------------------------------------------------------------------- 1 | from plain.urls import Router, path 2 | 3 | from . import views 4 | 5 | 6 | class QuerystatsRouter(Router): 7 | namespace = "querystats" 8 | urls = [ 9 | path("", views.QuerystatsView, name="querystats"), 10 | ] 11 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates.py: -------------------------------------------------------------------------------- 1 | from plain.runtime import settings 2 | from plain.templates import register_template_extension, register_template_filter 3 | from plain.templates.jinja.extensions import InclusionTagExtension 4 | from plain.utils.module_loading import import_string 5 | 6 | from .views.registry import registry 7 | 8 | 9 | @register_template_extension 10 | class ToolbarExtension(InclusionTagExtension): 11 | tags = {"toolbar"} 12 | template_name = "toolbar/toolbar.html" 13 | 14 | def get_context(self, context, *args, **kwargs): 15 | if isinstance(settings.ADMIN_TOOLBAR_CLASS, str): 16 | cls = import_string(settings.ADMIN_TOOLBAR_CLASS) 17 | else: 18 | cls = settings.ADMIN_TOOLBAR_CLASS 19 | context.vars["toolbar"] = cls(request=context["request"]) 20 | return context 21 | 22 | 23 | @register_template_filter 24 | def get_admin_model_detail_url(obj): 25 | return registry.get_model_detail_url(obj) 26 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/cards/card.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/cards/base.html" %} 2 | 3 | {% block content %} 4 | 5 | {% if number is not none %} 6 |
7 | {{ number }} 8 |
9 | {% endif %} 10 | 11 | {% if link %} 12 | {{ text }} 13 | {% elif text %} 14 | {{ text }} 15 | {% endif %} 16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/cards/chart.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/cards/base.html" %} 2 | 3 | {% block content %} 4 | 5 | {{ chart_data|json_script(slug) }} 6 | 25 | {% endblock %} 26 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/cards/table.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/cards/base.html" %} 2 | 3 | {% block content %} 4 |
5 | 6 | {% if headers %} 7 | 8 | 9 | {% for header in headers %} 10 | 11 | {% endfor %} 12 | 13 | 14 | {% endif %} 15 | 16 | {% for row in rows %} 17 | 18 | {% for cell in row %} 19 | 20 | {% endfor %} 21 | 22 | {% endfor %} 23 | 24 | {% if footers %} 25 | 26 | 27 | {% for footer in footers %} 28 | 29 | {% endfor %} 30 | 31 | 32 | {% endif %} 33 |
{{ header }}
{{ cell }}
{{ footer }}
34 |
35 | {% endblock %} 36 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/delete.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 |
7 | {{ csrf_input }} 8 |
9 |

Are you sure you want to delete this?

10 | 13 |
14 |
15 |
16 | 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/detail.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% block content %} 4 | 5 | 11 | 12 |
13 | {% for field in fields %} 14 |
{{ field }}
15 |
16 | {% with value=get_field_value(object, field) %} 17 | 18 |
{% include get_field_value_template(object, field, value) with context %}
19 | {% endwith %} 20 |
21 | {% endfor %} 22 |
23 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/index.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% block content %} 4 | 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/page.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% block content %}{% endblock %} 4 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/values/UUID.html: -------------------------------------------------------------------------------- 1 | {{ value }} 2 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/values/bool.html: -------------------------------------------------------------------------------- 1 | {%- if value -%} 2 | 3 | 4 | 5 | {%- else -%} 6 | 7 | 8 | 9 | {%- endif %} 10 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/values/datetime.html: -------------------------------------------------------------------------------- 1 | {{ value|localtime|strftime("%Y-%m-%d %-I:%M:%S %p") }} 2 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/values/default.html: -------------------------------------------------------------------------------- 1 | {% if value is none %} 2 | None 3 | {% else %} 4 | {{ value }} 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/values/dict.html: -------------------------------------------------------------------------------- 1 |
{{ value|tojson(indent=2) }}
2 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/values/get_display.html: -------------------------------------------------------------------------------- 1 | {{ object['get_' + field + '_display']() }} 2 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/values/img.html: -------------------------------------------------------------------------------- 1 | {{ value.alt }} 5 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/values/list.html: -------------------------------------------------------------------------------- 1 |
{{ value|tojson(indent=2) }}
2 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/values/model.html: -------------------------------------------------------------------------------- 1 | {% if value %} 2 | 3 | {% with url = value|get_admin_model_detail_url %} 4 | {% if url %} 5 | {{ value }} 6 | {% elif value.get_absolute_url is defined %} 7 | {{ value }} 8 | {% else %} 9 |
{{ value }}
10 | {% endif %} 11 | {% endwith %} 12 | 13 | {% else %} 14 | None 15 | {% endif %} 16 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/admin/values/queryset.html: -------------------------------------------------------------------------------- 1 | {% for item in value %} 2 |
3 | {% with value=item %} 4 | {% include "admin/values/model.html" %} 5 | {% endwith %} 6 |
7 | {% endfor %} 8 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/elements/admin/Checkbox.html: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/elements/admin/CheckboxField.html: -------------------------------------------------------------------------------- 1 | {% use_elements %} 2 | 3 |
4 |
5 | 6 | {{ label }} 7 | {% if help is defined %}{% endif %} 8 |
9 | 10 |
11 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/elements/admin/FieldErrors.html: -------------------------------------------------------------------------------- 1 | {% for error in field.errors %} 2 |
3 | {{ error }} 4 |
5 | {% endfor %} 6 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/elements/admin/Help.html: -------------------------------------------------------------------------------- 1 |

{{ help }}

2 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/elements/admin/Input.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/elements/admin/InputField.html: -------------------------------------------------------------------------------- 1 | {% use_elements %} 2 | 3 |
4 | {{ label }} 5 | 6 | {% if help is defined %}{% endif %} 7 | 8 |
9 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/elements/admin/Label.html: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/elements/admin/Select.html: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/elements/admin/SelectField.html: -------------------------------------------------------------------------------- 1 | {% use_elements %} 2 | 3 |
4 | {{ label }} 5 | 6 | {% if help is defined %}{% endif %} 7 | 8 |
9 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/elements/admin/Submit.html: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/elements/admin/Textarea.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/templates/elements/admin/TextareaField.html: -------------------------------------------------------------------------------- 1 | {% use_elements %} 2 | 3 |
4 | {{ label }} 5 | 6 | {% if help is defined %}{% endif %} 7 | 8 |
9 | -------------------------------------------------------------------------------- /plain-admin/plain/admin/views/types.py: -------------------------------------------------------------------------------- 1 | class Img: 2 | def __init__(self, src, *, alt="", width=None, height=None): 3 | self.src = src 4 | self.alt = alt 5 | self.width = width 6 | self.height = height 7 | -------------------------------------------------------------------------------- /plain-admin/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.admin" 3 | version = "0.31.7" 4 | description = "Admin dashboard and tools for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | license = "BSD-3-Clause" 7 | readme = "README.md" 8 | requires-python = ">=3.11" 9 | dependencies = [ 10 | "plain<1.0.0", 11 | "plain.auth<1.0.0", 12 | "plain.htmx<1.0.0", 13 | "plain.tailwind<1.0.0", 14 | "sqlparse>=0.2.2", 15 | ] 16 | 17 | [tool.uv] 18 | dev-dependencies = [ 19 | "plain.pytest<1.0.0", 20 | ] 21 | 22 | [tool.hatch.build.targets.wheel] 23 | packages = ["plain"] 24 | 25 | [build-system] 26 | requires = ["hatchling"] 27 | build-backend = "hatchling.build" 28 | -------------------------------------------------------------------------------- /plain-admin/tests/app/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "test" 2 | URLS_ROUTER = "app.urls.AppRouter" 3 | INSTALLED_PACKAGES = [ 4 | "plain.auth", 5 | "plain.sessions", 6 | "plain.models", 7 | "plain.htmx", 8 | "plain.tailwind", 9 | "plain.admin", 10 | "app.users", 11 | ] 12 | DATABASES = { 13 | "default": { 14 | "ENGINE": "plain.models.backends.sqlite3", 15 | "NAME": ":memory:", 16 | } 17 | } 18 | MIDDLEWARE = [ 19 | "plain.sessions.middleware.SessionMiddleware", 20 | "plain.auth.middleware.AuthenticationMiddleware", 21 | "plain.admin.AdminMiddleware", 22 | ] 23 | AUTH_LOGIN_URL = "login" 24 | AUTH_USER_MODEL = "users.User" 25 | -------------------------------------------------------------------------------- /plain-admin/tests/app/urls.py: -------------------------------------------------------------------------------- 1 | from plain.admin.urls import AdminRouter 2 | from plain.assets.urls import AssetsRouter 3 | from plain.urls import Router, include, path 4 | from plain.views import View 5 | 6 | 7 | class LoginView(View): 8 | def get(self): 9 | return "Login!" 10 | 11 | 12 | class LogoutView(View): 13 | def get(self): 14 | return "Logout!" 15 | 16 | 17 | class AppRouter(Router): 18 | namespace = "" 19 | urls = [ 20 | include("admin/", AdminRouter), 21 | include("assets/", AssetsRouter), 22 | path("login/", LoginView, name="login"), 23 | path("logout/", LogoutView, name="logout"), 24 | ] 25 | -------------------------------------------------------------------------------- /plain-admin/tests/app/users/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.21.5 on 2025-02-17 04:12 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="User", 15 | fields=[ 16 | ("id", models.BigAutoField(auto_created=True, primary_key=True)), 17 | ("username", models.CharField(max_length=255)), 18 | ("is_admin", models.BooleanField(default=False)), 19 | ], 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /plain-admin/tests/app/users/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-admin/tests/app/users/migrations/__init__.py -------------------------------------------------------------------------------- /plain-admin/tests/app/users/models.py: -------------------------------------------------------------------------------- 1 | from plain import models 2 | 3 | 4 | @models.register_model 5 | class User(models.Model): 6 | username = models.CharField(max_length=255) 7 | is_admin = models.BooleanField(default=False) 8 | -------------------------------------------------------------------------------- /plain-admin/tests/test_admin.py: -------------------------------------------------------------------------------- 1 | from app.users.models import User 2 | 3 | from plain.test import Client 4 | 5 | 6 | def test_admin_login_required(db): 7 | client = Client() 8 | 9 | # Login required 10 | assert client.get("/admin/").status_code == 302 11 | 12 | user = User.objects.create(username="test") 13 | client.force_login(user) 14 | 15 | # Not admin yet 16 | assert client.get("/admin/").status_code == 404 17 | 18 | user.is_admin = True 19 | user.save() 20 | 21 | # Now admin 22 | assert client.get("/admin/").status_code == 200 23 | -------------------------------------------------------------------------------- /plain-api/README.md: -------------------------------------------------------------------------------- 1 | ./plain/api/README.md -------------------------------------------------------------------------------- /plain-api/plain/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-api/plain/api/__init__.py -------------------------------------------------------------------------------- /plain-api/plain/api/config.py: -------------------------------------------------------------------------------- 1 | from plain.packages import PackageConfig, register_config 2 | 3 | 4 | @register_config 5 | class Config(PackageConfig): 6 | package_label = "plainapi" # Primarily for migrations 7 | -------------------------------------------------------------------------------- /plain-api/plain/api/default_settings.py: -------------------------------------------------------------------------------- 1 | API_OPENAPI_ROUTER: str = "" 2 | -------------------------------------------------------------------------------- /plain-api/plain/api/migrations/0002_apikey_expires_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.17.0 on 2025-01-22 21:02 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainapi", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="apikey", 15 | name="expires_at", 16 | field=models.DateTimeField(required=False, allow_null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /plain-api/plain/api/migrations/0004_apikey_version_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.37.0 on 2025-04-09 04:19 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainapi", "0003_alter_apikey_token_alter_apikey_uuid_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="apikey", 15 | name="version_name", 16 | field=models.CharField(max_length=255, required=False), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /plain-api/plain/api/migrations/0005_rename_version_name_apikey_api_version_name.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.37.0 on 2025-04-09 17:21 2 | 3 | from plain.models import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("plainapi", "0004_apikey_version_name"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameField( 13 | model_name="apikey", 14 | old_name="version_name", 15 | new_name="api_version_name", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /plain-api/plain/api/migrations/0006_rename_api_version_name_apikey_api_version.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.38.0 on 2025-04-13 15:26 2 | 3 | from plain.models import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("plainapi", "0005_rename_version_name_apikey_api_version_name"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameField( 13 | model_name="apikey", 14 | old_name="api_version_name", 15 | new_name="api_version", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /plain-api/plain/api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-api/plain/api/migrations/__init__.py -------------------------------------------------------------------------------- /plain-api/plain/api/openapi/__init__.py: -------------------------------------------------------------------------------- 1 | from .decorators import request_form, response_typed_dict, schema 2 | 3 | __all__ = [ 4 | "schema", 5 | "request_form", 6 | "response_typed_dict", 7 | ] 8 | -------------------------------------------------------------------------------- /plain-api/plain/api/schemas.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | 4 | class ErrorSchema(TypedDict): 5 | id: str 6 | message: str 7 | url: str | None 8 | -------------------------------------------------------------------------------- /plain-api/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.api" 3 | version = "0.10.0" 4 | description = "API for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "plain<1.0.0", 10 | ] 11 | 12 | [tool.uv] 13 | dev-dependencies = [ 14 | "plain.pytest<1.0.0", 15 | ] 16 | 17 | [tool.hatch.build.targets.wheel] 18 | packages = ["plain"] 19 | 20 | [build-system] 21 | requires = ["hatchling"] 22 | build-backend = "hatchling.build" 23 | -------------------------------------------------------------------------------- /plain-api/tests/app/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "test" 2 | URLS_ROUTER = "app.urls.AppRouter" 3 | INSTALLED_PACKAGES = [] 4 | -------------------------------------------------------------------------------- /plain-api/tests/test_api.py: -------------------------------------------------------------------------------- 1 | from plain.test import Client 2 | 3 | 4 | def test_api_view(): 5 | client = Client() 6 | response = client.get("/test") 7 | assert response.status_code == 200 8 | assert response.json() == {"message": "Hello, world!"} 9 | 10 | 11 | def test_versioned_api_view(): 12 | client = Client() 13 | response = client.post( 14 | "/test-versioned", 15 | headers={"API-Version": "v2"}, 16 | data={"name": "Dave"}, 17 | content_type="application/json", 18 | ) 19 | assert response.status_code == 200 20 | assert response.json() == {"message": "Hello, Dave!"} 21 | 22 | response = client.post( 23 | "/test-versioned", 24 | headers={"API-Version": "v1"}, 25 | data={"to": "Dave"}, 26 | content_type="application/json", 27 | ) 28 | assert response.status_code == 200 29 | assert response.json() == {"msg": "Hello, Dave!"} 30 | -------------------------------------------------------------------------------- /plain-auth/README.md: -------------------------------------------------------------------------------- 1 | ./plain/auth/README.md -------------------------------------------------------------------------------- /plain-auth/plain/auth/__init__.py: -------------------------------------------------------------------------------- 1 | from .sessions import get_user, get_user_model, login, logout 2 | 3 | __all__ = ["login", "logout", "get_user_model", "get_user"] 4 | -------------------------------------------------------------------------------- /plain-auth/plain/auth/default_settings.py: -------------------------------------------------------------------------------- 1 | from importlib.util import find_spec 2 | 3 | AUTH_USER_MODEL: str 4 | AUTH_LOGIN_URL: str 5 | 6 | if find_spec("plain.passwords"): 7 | # Automatically invalidate sessions on password field change, 8 | # if the plain-passwords is installed. You can change this value 9 | # if your password field is named differently, or you want 10 | # to use a different field to invalidate sessions. 11 | AUTH_USER_SESSION_HASH_FIELD: str = "password" 12 | else: 13 | AUTH_USER_SESSION_HASH_FIELD: str = "" 14 | -------------------------------------------------------------------------------- /plain-auth/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.auth" 3 | version = "0.12.1" 4 | description = "User authentication and authorization for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "plain<1.0.0", 10 | "plain.models<1.0.0", 11 | # Technically you can swap out sessions entirely with your own, 12 | # so long as the request.session exists and has a similar API. 13 | "plain.sessions<1.0.0", 14 | ] 15 | 16 | [tool.hatch.build.targets.wheel] 17 | packages = ["plain"] 18 | 19 | [build-system] 20 | requires = ["hatchling"] 21 | build-backend = "hatchling.build" 22 | -------------------------------------------------------------------------------- /plain-cache/README.md: -------------------------------------------------------------------------------- 1 | ./plain/cache/README.md -------------------------------------------------------------------------------- /plain-cache/plain/cache/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import Cached 2 | 3 | __all__ = [ 4 | "Cached", 5 | ] 6 | -------------------------------------------------------------------------------- /plain-cache/plain/cache/admin.py: -------------------------------------------------------------------------------- 1 | from plain.admin.views import ( 2 | AdminModelDetailView, 3 | AdminModelListView, 4 | AdminViewset, 5 | register_viewset, 6 | ) 7 | from plain.cache.models import CachedItem 8 | 9 | 10 | @register_viewset 11 | class CachedItemViewset(AdminViewset): 12 | class ListView(AdminModelListView): 13 | nav_section = "Cache" 14 | model = CachedItem 15 | title = "Cached items" 16 | fields = [ 17 | "key", 18 | "created_at", 19 | "expires_at", 20 | "updated_at", 21 | ] 22 | queryset_order = ["-pk"] 23 | allow_global_search = False 24 | 25 | def get_objects(self): 26 | return ( 27 | super() 28 | .get_objects() 29 | .only("key", "created_at", "expires_at", "updated_at") 30 | ) 31 | 32 | class DetailView(AdminModelDetailView): 33 | model = CachedItem 34 | title = "Cached item" 35 | -------------------------------------------------------------------------------- /plain-cache/plain/cache/chores.py: -------------------------------------------------------------------------------- 1 | from plain.chores import register_chore 2 | 3 | from .models import CachedItem 4 | 5 | 6 | @register_chore("cache") 7 | def clear_expired(): 8 | """ 9 | Delete cache items that have expired. 10 | """ 11 | result = CachedItem.objects.expired().delete() 12 | return f"{result[0]} expired cache items deleted" 13 | -------------------------------------------------------------------------------- /plain-cache/plain/cache/config.py: -------------------------------------------------------------------------------- 1 | from plain.packages import PackageConfig, register_config 2 | 3 | 4 | @register_config 5 | class Config(PackageConfig): 6 | package_label = "plaincache" 7 | -------------------------------------------------------------------------------- /plain-cache/plain/cache/migrations/0002_rename_cacheitem_cacheditem.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 5.0.dev20231127233940 on 2023-12-22 17:40 2 | 3 | from plain.models import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("plaincache", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameModel( 13 | old_name="CacheItem", 14 | new_name="CachedItem", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /plain-cache/plain/cache/migrations/0003_alter_cacheditem_key_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.31.0 on 2025-03-08 21:33 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plaincache", "0002_rename_cacheitem_cacheditem"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="cacheditem", 15 | name="key", 16 | field=models.CharField(max_length=255), 17 | ), 18 | migrations.AddConstraint( 19 | model_name="cacheditem", 20 | constraint=models.UniqueConstraint( 21 | fields=("key",), name="plaincache_cacheditem_unique_key" 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /plain-cache/plain/cache/migrations/0004_alter_cacheditem_expires_at_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.32.0 on 2025-03-10 15:34 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plaincache", "0003_alter_cacheditem_key_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="cacheditem", 15 | name="expires_at", 16 | field=models.DateTimeField(allow_null=True, required=False), 17 | ), 18 | migrations.AddIndex( 19 | model_name="cacheditem", 20 | index=models.Index( 21 | fields=["expires_at"], name="plaincache__expires_5a9119_idx" 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /plain-cache/plain/cache/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-cache/plain/cache/migrations/__init__.py -------------------------------------------------------------------------------- /plain-cache/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.cache" 3 | packages = [ 4 | { include = "plain" }, 5 | ] 6 | version = "0.14.2" 7 | description = "Database-backed cache for Plain." 8 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 9 | readme = "README.md" 10 | requires-python = ">=3.11" 11 | dependencies = [ 12 | "plain<1.0.0", 13 | ] 14 | 15 | [tool.hatch.build.targets.wheel] 16 | packages = ["plain"] 17 | 18 | [build-system] 19 | requires = ["hatchling"] 20 | build-backend = "hatchling.build" 21 | -------------------------------------------------------------------------------- /plain-code/.gitignore: -------------------------------------------------------------------------------- 1 | # Local development files 2 | /.env 3 | /.plain 4 | *.sqlite3 5 | 6 | # Publishing 7 | /dist 8 | 9 | # Python 10 | /.venv 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # OS files 16 | .DS_Store 17 | -------------------------------------------------------------------------------- /plain-code/README.md: -------------------------------------------------------------------------------- 1 | ./plain/code/README.md -------------------------------------------------------------------------------- /plain-code/plain/code/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli 2 | 3 | __all__ = ["cli"] 4 | -------------------------------------------------------------------------------- /plain-code/plain/code/biome_defaults.json: -------------------------------------------------------------------------------- 1 | { 2 | "vcs": { 3 | "enabled": true, 4 | "clientKind": "git", 5 | "useIgnoreFile": true 6 | }, 7 | "files": { 8 | "ignore": ["**/vendor/**", "**/*.min.*", "**/tests/**"] 9 | }, 10 | "formatter": { 11 | "indentStyle": "space" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /plain-code/plain/code/entrypoints.py: -------------------------------------------------------------------------------- 1 | def setup(): 2 | # This package isn't an installed app, 3 | # so we need to trigger our own import and cli registration. 4 | from .cli import cli # noqa 5 | -------------------------------------------------------------------------------- /plain-code/plain/code/ruff_defaults.toml: -------------------------------------------------------------------------------- 1 | target-version = "py311" 2 | 3 | [lint] 4 | ignore = [ 5 | "E501", # Never enforce `E501` (line length violations) 6 | "S101", # pytest use of assert 7 | "ISC001", # Implicit string concatenation 8 | ] 9 | extend-select = [ 10 | "I", # isort 11 | # # "C90", # mccabe 12 | # # "N", # pep8-naming 13 | "UP", # pyupgrade 14 | # "S", # bandit 15 | # # "B", # bugbear 16 | "C4", # flake8-comprehensions 17 | # # "DTZ", # flake8-datetimez 18 | "ISC", # flake8-implicit-str-concat 19 | # # "G", # flake8-logging-format 20 | # # "T20", # print 21 | "PT", # pytest 22 | "B006", # mutable-argument-default 23 | ] 24 | -------------------------------------------------------------------------------- /plain-code/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.code" 3 | version = "0.8.0" 4 | description = "Code formatting and linting for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "plain<1.0.0", 10 | "ruff>=0.1.0", 11 | "requests>=2.0.0", 12 | "tomlkit>=0.11.0", 13 | ] 14 | 15 | # Make this available as a standalone command 16 | # in case plain can't load or something (this can run anyways) 17 | [project.scripts] 18 | plain-code = "plain.code:cli" 19 | 20 | [project.entry-points."plain.setup"] 21 | "code-setup" = "plain.code.entrypoints:setup" 22 | 23 | [tool.hatch.build.targets.wheel] 24 | packages = ["plain"] 25 | 26 | [build-system] 27 | requires = ["hatchling"] 28 | build-backend = "hatchling.build" 29 | -------------------------------------------------------------------------------- /plain-dev/.gitignore: -------------------------------------------------------------------------------- 1 | # Local development files 2 | /.env 3 | /.plain 4 | 5 | # Publishing 6 | /dist 7 | 8 | # Python 9 | /.venv 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # OS files 15 | .DS_Store 16 | -------------------------------------------------------------------------------- /plain-dev/README.md: -------------------------------------------------------------------------------- 1 | ./plain/dev/README.md -------------------------------------------------------------------------------- /plain-dev/plain/dev/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli 2 | from .requests import RequestsMiddleware 3 | 4 | __all__ = ["cli", "RequestsMiddleware"] 5 | -------------------------------------------------------------------------------- /plain-dev/plain/dev/contribute/README.md: -------------------------------------------------------------------------------- 1 | ## FAQs 2 | 3 | ### What if the plain cli isn't working? 4 | 5 | When working on packages locally you can sometimes end up in a weird state where Plain can't load. The `plain contrib` command is also available as `plain-contrib`, which won't go through any of the setup processes for Plain, so you can always run that directly if you need to. 6 | -------------------------------------------------------------------------------- /plain-dev/plain/dev/contribute/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli 2 | 3 | __all__ = ["cli"] 4 | -------------------------------------------------------------------------------- /plain-dev/plain/dev/default_settings.py: -------------------------------------------------------------------------------- 1 | DEV_REQUESTS_IGNORE_PATHS = [ 2 | "/favicon.ico", 3 | ] 4 | 5 | DEV_REQUESTS_MAX = 50 6 | -------------------------------------------------------------------------------- /plain-dev/plain/dev/entrypoints.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from dotenv import load_dotenv 4 | 5 | from .debug import set_breakpoint_hook 6 | 7 | 8 | def setup(): 9 | # Make sure our clis are registered 10 | # since this isn't an installed app 11 | from .cli import cli # noqa 12 | from .precommit import cli # noqa 13 | from .contribute import cli # noqa 14 | 15 | # Try to set a new breakpoint() hook 16 | # so we can connect to pdb remotely. 17 | set_breakpoint_hook() 18 | 19 | # Load environment variables from .env file 20 | if plain_env := os.environ.get("PLAIN_ENV", ""): 21 | load_dotenv(f".env.{plain_env}") 22 | else: 23 | load_dotenv(".env") 24 | -------------------------------------------------------------------------------- /plain-dev/plain/dev/poncho/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from ._version import __version__ 3 | except ImportError: 4 | __version__ = "0.0.0+unknown" 5 | -------------------------------------------------------------------------------- /plain-dev/plain/dev/poncho/color.py: -------------------------------------------------------------------------------- 1 | ANSI_COLOURS = ["grey", "red", "green", "yellow", "blue", "magenta", "cyan", "white"] 2 | 3 | for i, name in enumerate(ANSI_COLOURS): 4 | globals()[name] = str(30 + i) 5 | globals()["intense_" + name] = str(30 + i) + ";1" 6 | 7 | 8 | def get_colors(): 9 | cs = [ 10 | "cyan", 11 | "yellow", 12 | "green", 13 | "magenta", 14 | "red", 15 | "blue", 16 | "intense_cyan", 17 | "intense_yellow", 18 | "intense_green", 19 | "intense_magenta", 20 | "intense_red", 21 | "intense_blue", 22 | ] 23 | cs = [globals()[c] for c in cs] 24 | 25 | i = 0 26 | while True: 27 | yield cs[i % len(cs)] 28 | i += 1 29 | -------------------------------------------------------------------------------- /plain-dev/plain/dev/precommit/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli 2 | 3 | __all__ = ["cli"] 4 | -------------------------------------------------------------------------------- /plain-dev/plain/dev/urls.py: -------------------------------------------------------------------------------- 1 | from plain.urls import Router, path 2 | 3 | from . import views 4 | 5 | 6 | class DevRequestsRouter(Router): 7 | namespace = "dev" 8 | urls = [ 9 | path("", views.RequestsView, name="requests"), 10 | ] 11 | -------------------------------------------------------------------------------- /plain-dev/plain/dev/utils.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | 4 | def has_pyproject_toml(target_path): 5 | return (Path(target_path) / "pyproject.toml").exists() 6 | -------------------------------------------------------------------------------- /plain-dev/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.dev" 3 | version = "0.30.1" 4 | description = "Local development tools for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | license = "BSD-3-Clause" 7 | readme = "README.md" 8 | requires-python = ">=3.11" 9 | dependencies = [ 10 | "plain<1.0.0", 11 | "click>=8.0.0", 12 | "python-dotenv~=1.0.0", 13 | "gunicorn>20", 14 | "requests>=2.0.0", 15 | "psycopg[binary]~=3.2.2", 16 | "rich", 17 | "inotify", 18 | ] 19 | 20 | # Make this available as a standalone command 21 | # in case plain can't load or something (this can run anyways) 22 | [project.scripts] 23 | plain-contrib = "plain.dev.contribute:cli" 24 | 25 | [project.entry-points."plain.setup"] 26 | "dev-setup" = "plain.dev.entrypoints:setup" 27 | 28 | [tool.hatch.build.targets.wheel] 29 | packages = ["plain"] 30 | 31 | [build-system] 32 | requires = ["hatchling"] 33 | build-backend = "hatchling.build" 34 | -------------------------------------------------------------------------------- /plain-dev/tests/settings.py: -------------------------------------------------------------------------------- 1 | INSTALLED_PACKAGES = [ 2 | "plain.work", 3 | ] 4 | -------------------------------------------------------------------------------- /plain-elements/.gitignore: -------------------------------------------------------------------------------- 1 | # Local development files 2 | /.env 3 | /.plain 4 | *.sqlite3 5 | 6 | # Publishing 7 | /dist 8 | 9 | # Python 10 | /.venv 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # OS files 16 | .DS_Store 17 | -------------------------------------------------------------------------------- /plain-elements/README.md: -------------------------------------------------------------------------------- 1 | ./plain/elements/README.md -------------------------------------------------------------------------------- /plain-elements/plain/elements/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-elements/plain/elements/__init__.py -------------------------------------------------------------------------------- /plain-elements/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.elements" 3 | version = "0.7.1" 4 | description = "HTML-style includes for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "plain<1.0.0", 10 | ] 11 | 12 | [tool.uv] 13 | dev-dependencies = [ 14 | "plain.pytest<1.0.0", 15 | ] 16 | 17 | [tool.hatch.build.targets.wheel] 18 | packages = ["plain"] 19 | 20 | [build-system] 21 | requires = ["hatchling"] 22 | build-backend = "hatchling.build" 23 | -------------------------------------------------------------------------------- /plain-elements/tests/app/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "test" 2 | URLS_ROUTER = "app.urls.AppRouter" 3 | INSTALLED_PACKAGES = ["plain.elements"] 4 | -------------------------------------------------------------------------------- /plain-elements/tests/app/urls.py: -------------------------------------------------------------------------------- 1 | from plain.urls import Router, path 2 | from plain.views import View 3 | 4 | 5 | class ExampleView(View): 6 | def get(self): 7 | return {} 8 | 9 | 10 | class AppRouter(Router): 11 | namespace = "" 12 | urls = [ 13 | path("", ExampleView), 14 | ] 15 | -------------------------------------------------------------------------------- /plain-email/README.md: -------------------------------------------------------------------------------- 1 | plain/email/README.md -------------------------------------------------------------------------------- /plain-email/plain/email/README.md: -------------------------------------------------------------------------------- 1 | # plain.email 2 | 3 | Everything you need to send email. 4 | 5 | ## Installation 6 | 7 | Add `plain.email` to your `INSTALLED_APPS`: 8 | 9 | ```python 10 | # settings.py 11 | INSTALLED_APPS = [ 12 | # ... 13 | 'plain.email', 14 | ] 15 | ``` 16 | -------------------------------------------------------------------------------- /plain-email/plain/email/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-email/plain/email/backends/__init__.py -------------------------------------------------------------------------------- /plain-email/plain/email/default_settings.py: -------------------------------------------------------------------------------- 1 | # The email backend to use. For possible shortcuts see plain.email. 2 | # The default is to use the SMTP backend. 3 | # Third-party backends can be specified by providing a Python path 4 | # to a module that defines an EmailBackend class. 5 | EMAIL_BACKEND: str 6 | 7 | EMAIL_DEFAULT_FROM: str 8 | 9 | EMAIL_DEFAULT_REPLY_TO: list[str] = None 10 | 11 | # Host for sending email. 12 | EMAIL_HOST: str = "localhost" 13 | 14 | # Port for sending email. 15 | EMAIL_PORT: int = 587 16 | 17 | # Whether to send SMTP 'Date' header in the local time zone or in UTC. 18 | EMAIL_USE_LOCALTIME: bool = False 19 | 20 | # Optional SMTP authentication information for EMAIL_HOST. 21 | EMAIL_HOST_USER: str = "" 22 | EMAIL_HOST_PASSWORD: str = "" 23 | EMAIL_USE_TLS: bool = True 24 | EMAIL_USE_SSL: bool = False 25 | EMAIL_SSL_CERTFILE: str = None 26 | EMAIL_SSL_KEYFILE: str = None 27 | EMAIL_TIMEOUT: int = None 28 | -------------------------------------------------------------------------------- /plain-email/plain/email/utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Email message and email sending related helper functions. 3 | """ 4 | 5 | import socket 6 | 7 | from plain.utils.encoding import punycode 8 | 9 | 10 | # Cache the hostname, but do it lazily: socket.getfqdn() can take a couple of 11 | # seconds, which slows down the restart of the server. 12 | class CachedDnsName: 13 | def __str__(self): 14 | return self.get_fqdn() 15 | 16 | def get_fqdn(self): 17 | if not hasattr(self, "_fqdn"): 18 | self._fqdn = punycode(socket.getfqdn()) 19 | return self._fqdn 20 | 21 | 22 | DNS_NAME = CachedDnsName() 23 | -------------------------------------------------------------------------------- /plain-email/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.email" 3 | version = "0.10.1" 4 | description = "Email sending for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | license = "BSD-3-Clause" 7 | readme = "README.md" 8 | requires-python = ">=3.11" 9 | dependencies = [ 10 | "plain<1.0.0", 11 | ] 12 | 13 | [tool.hatch.build.targets.wheel] 14 | packages = ["plain"] 15 | 16 | [build-system] 17 | requires = ["hatchling"] 18 | build-backend = "hatchling.build" 19 | -------------------------------------------------------------------------------- /plain-esbuild/.gitignore: -------------------------------------------------------------------------------- 1 | # Local development files 2 | /.env 3 | /.plain 4 | 5 | # Publishing 6 | /dist 7 | 8 | # Python 9 | /.venv 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # OS files 15 | .DS_Store 16 | -------------------------------------------------------------------------------- /plain-esbuild/README.md: -------------------------------------------------------------------------------- 1 | ./plain/esbuild/README.md -------------------------------------------------------------------------------- /plain-esbuild/plain/esbuild/README.md: -------------------------------------------------------------------------------- 1 | # plain.esbuild 2 | 3 | Build JavaScript files with esbuild. 4 | 5 | ## gitignore 6 | 7 | ``` 8 | **/assets/**/*.esbuilt.* 9 | ``` 10 | -------------------------------------------------------------------------------- /plain-esbuild/plain/esbuild/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli 2 | 3 | __all__ = ["cli"] 4 | -------------------------------------------------------------------------------- /plain-esbuild/plain/esbuild/entrypoints.py: -------------------------------------------------------------------------------- 1 | from .cli import build, dev 2 | 3 | 4 | def run_dev_build(): 5 | # This will run by itself as a command, so it can exit() 6 | dev([], standalone_mode=True) 7 | 8 | 9 | def run_build(): 10 | # Standalone mode prevents it from exit()ing 11 | build([], standalone_mode=False) 12 | -------------------------------------------------------------------------------- /plain-esbuild/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.esbuild" 3 | version = "0.5.0" 4 | description = "" 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | license = "BSD-3-Clause" 7 | readme = "README.md" 8 | requires-python = ">=3.11" 9 | dependencies = [ 10 | "plain<1.0.0", 11 | "watchfiles>=1.0.4", 12 | ] 13 | 14 | [project.entry-points."plain.dev"] 15 | "esbuild" = "plain.esbuild.entrypoints:run_dev_build" 16 | 17 | [project.entry-points."plain.build"] 18 | "esbuild" = "plain.esbuild.entrypoints:run_build" 19 | 20 | [tool.hatch.build.targets.wheel] 21 | packages = ["plain"] 22 | 23 | [build-system] 24 | requires = ["hatchling"] 25 | build-backend = "hatchling.build" 26 | -------------------------------------------------------------------------------- /plain-flags/README.md: -------------------------------------------------------------------------------- 1 | ./plain/flags/README.md -------------------------------------------------------------------------------- /plain-flags/plain/flags/__init__.py: -------------------------------------------------------------------------------- 1 | from .flags import Flag 2 | 3 | __all__ = ["Flag"] 4 | -------------------------------------------------------------------------------- /plain-flags/plain/flags/bridge.py: -------------------------------------------------------------------------------- 1 | from plain.runtime import settings 2 | 3 | from . import exceptions 4 | from .flags import Flag 5 | 6 | 7 | def get_flags_module(): 8 | flags_module = settings.FLAGS_MODULE 9 | 10 | try: 11 | return __import__(flags_module) 12 | except ImportError as e: 13 | raise exceptions.FlagImportError( 14 | f"Could not import {flags_module} module" 15 | ) from e 16 | 17 | 18 | def get_flag_class(flag_name: str) -> Flag: 19 | flags_module = get_flags_module() 20 | 21 | try: 22 | flag_class = getattr(flags_module, flag_name) 23 | except AttributeError as e: 24 | raise exceptions.FlagImportError( 25 | f"Could not find {flag_name} in {flags_module} module" 26 | ) from e 27 | 28 | return flag_class 29 | -------------------------------------------------------------------------------- /plain-flags/plain/flags/config.py: -------------------------------------------------------------------------------- 1 | from plain.packages import PackageConfig, register_config 2 | 3 | 4 | @register_config 5 | class Config(PackageConfig): 6 | package_label = "plainflags" # Primarily for migrations 7 | -------------------------------------------------------------------------------- /plain-flags/plain/flags/default_settings.py: -------------------------------------------------------------------------------- 1 | FLAGS_MODULE: str = "app.flags" 2 | -------------------------------------------------------------------------------- /plain-flags/plain/flags/exceptions.py: -------------------------------------------------------------------------------- 1 | class FlagError(Exception): 2 | pass 3 | 4 | 5 | class FlagDisabled(FlagError): 6 | pass 7 | 8 | 9 | class FlagImportError(FlagError): 10 | pass 11 | -------------------------------------------------------------------------------- /plain-flags/plain/flags/migrations/0002_alter_flagresult_unique_together_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.3.0 on 2024-08-16 16:46 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainflags", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddConstraint( 14 | model_name="flagresult", 15 | constraint=models.UniqueConstraint( 16 | fields=("flag", "key"), name="unique_flag_result_key" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /plain-flags/plain/flags/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-flags/plain/flags/migrations/__init__.py -------------------------------------------------------------------------------- /plain-flags/plain/flags/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-flags/plain/flags/py.typed -------------------------------------------------------------------------------- /plain-flags/plain/flags/templates.py: -------------------------------------------------------------------------------- 1 | from plain.templates import register_template_global 2 | 3 | from .bridge import get_flags_module 4 | 5 | register_template_global(get_flags_module(), name="flags") 6 | -------------------------------------------------------------------------------- /plain-flags/plain/flags/templates/admin/plainflags/flagresult_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% use_elements %} 4 | 5 | {% block content %} 6 | 7 |
8 | {{ csrf_input }} 9 | 10 | 11 |
12 | 15 |
16 | 17 | 18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /plain-flags/plain/flags/utils.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | from plain import models 4 | 5 | 6 | def coerce_key(key: Any) -> str: 7 | """ 8 | Converts a flag key to a string for storage in the DB 9 | (special handling of model instances) 10 | """ 11 | if isinstance(key, str): 12 | return key 13 | 14 | if isinstance(key, models.Model): 15 | return f"{key._meta.package_label}.{key._meta.model_name}:{key.pk}" 16 | 17 | return str(key) 18 | -------------------------------------------------------------------------------- /plain-flags/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.flags" 3 | version = "0.17.1" 4 | description = "Feature flags for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "plain<1.0.0", 10 | ] 11 | 12 | [tool.uv] 13 | dev-dependencies = [ 14 | "plain.models<1.0.0", 15 | "plain.pytest<1.0.0", 16 | ] 17 | 18 | [tool.hatch.build.targets.wheel] 19 | packages = ["plain"] 20 | 21 | [build-system] 22 | requires = ["hatchling"] 23 | build-backend = "hatchling.build" 24 | -------------------------------------------------------------------------------- /plain-flags/tests/app/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "test" 2 | URLS_ROUTER = "app.urls.AppRouter" 3 | INSTALLED_PACKAGES = [ 4 | "plain.models", 5 | "plain.flags", 6 | ] 7 | DATABASES = { 8 | "default": { 9 | "ENGINE": "plain.models.backends.sqlite3", 10 | "NAME": ":memory:", 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /plain-flags/tests/app/urls.py: -------------------------------------------------------------------------------- 1 | from plain.urls import Router 2 | 3 | 4 | class AppRouter(Router): 5 | namespace = "" 6 | urls = [] 7 | -------------------------------------------------------------------------------- /plain-flags/tests/test_flags.py: -------------------------------------------------------------------------------- 1 | from plain.flags import Flag 2 | 3 | 4 | def test_flag(db): 5 | class TestFlag(Flag): 6 | def get_key(self): 7 | return "test" 8 | 9 | def get_value(self): 10 | return True 11 | 12 | flag = TestFlag() 13 | assert flag.value is True 14 | -------------------------------------------------------------------------------- /plain-htmx/.gitignore: -------------------------------------------------------------------------------- 1 | # Local development files 2 | /.env 3 | /.plain 4 | 5 | # Publishing 6 | /dist 7 | 8 | # Python 9 | /.venv 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # OS files 15 | .DS_Store 16 | 17 | # Node files 18 | node_modules/ 19 | -------------------------------------------------------------------------------- /plain-htmx/README.md: -------------------------------------------------------------------------------- 1 | ./plain/htmx/README.md -------------------------------------------------------------------------------- /plain-htmx/deps.yml: -------------------------------------------------------------------------------- 1 | version: 3 2 | dependencies: 3 | - type: js 4 | path: . 5 | settings: 6 | before_commit: npm run copy-static 7 | -------------------------------------------------------------------------------- /plain-htmx/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plain-htmx", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "dependencies": { 8 | "htmx.org": "^2.0.0", 9 | "idiomorph": "^0.4.0" 10 | } 11 | }, 12 | "node_modules/htmx.org": { 13 | "version": "2.0.4", 14 | "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-2.0.4.tgz", 15 | "integrity": "sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ==", 16 | "license": "0BSD" 17 | }, 18 | "node_modules/idiomorph": { 19 | "version": "0.4.0", 20 | "resolved": "https://registry.npmjs.org/idiomorph/-/idiomorph-0.4.0.tgz", 21 | "integrity": "sha512-VdXFpZOTXhLatJmhCWJR5oQKLXT01O6sFCJqT0/EqG71C4tYZdPJ5etvttwWsT2WKRYWz160XkNr1DUqXNMZHg==", 22 | "license": "BSD-2-Clause" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /plain-htmx/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "copy-assets": "rm -r plain/htmx/assets/htmx/vendor && mkdir plain/htmx/assets/htmx/vendor && cp -r node_modules/htmx.org/dist plain/htmx/assets/htmx/vendor/src && cp -r node_modules/idiomorph/dist plain/htmx/assets/htmx/vendor/idiomorph" 4 | }, 5 | "dependencies": { 6 | "htmx.org": "^2.0.0", 7 | "idiomorph": "^0.4.0" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /plain-htmx/plain/htmx/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-htmx/plain/htmx/__init__.py -------------------------------------------------------------------------------- /plain-htmx/plain/htmx/assets/htmx/vendor/idiomorph/idiomorph-htmx.js: -------------------------------------------------------------------------------- 1 | (function () { 2 | function createMorphConfig(swapStyle) { 3 | if (swapStyle === "morph" || swapStyle === "morph:outerHTML") { 4 | return { morphStyle: "outerHTML" }; 5 | } else if (swapStyle === "morph:innerHTML") { 6 | return { morphStyle: "innerHTML" }; 7 | } else if (swapStyle.startsWith("morph:")) { 8 | return Function("return (" + swapStyle.slice(6) + ")")(); 9 | } 10 | } 11 | 12 | htmx.defineExtension("morph", { 13 | isInlineSwap: function (swapStyle) { 14 | let config = createMorphConfig(swapStyle); 15 | return config?.morphStyle === "outerHTML" || config?.morphStyle == null; 16 | }, 17 | handleSwap: function (swapStyle, target, fragment) { 18 | let config = createMorphConfig(swapStyle); 19 | if (config) { 20 | return Idiomorph.morph(target, fragment.children, config); 21 | } 22 | }, 23 | }); 24 | })(); 25 | -------------------------------------------------------------------------------- /plain-htmx/plain/htmx/assets/htmx/vendor/src/ext/README.md: -------------------------------------------------------------------------------- 1 | # Why Are These Files Here? 2 | 3 | These are legacy extensions for htmx 1.x and are **NOT** actively maintained or guaranteed to work with htmx 2.x. 4 | They are here because we unfortunately linked to unversioned unpkg URLs in the installation guides for them 5 | in 1.x, so we need to keep them here to preserve those URLs and not break existing users functionality. 6 | 7 | If you are looking for extensions for htmx 2.x, please see the [htmx 2.0 extensions site](https://htmx.org/extensions), 8 | which has links to the new extensions repos (They have all been moved to their own NPM projects and URLs, like 9 | they should have been from the start!) 10 | -------------------------------------------------------------------------------- /plain-htmx/plain/htmx/assets/htmx/vendor/src/ext/ajax-header.js: -------------------------------------------------------------------------------- 1 | if (htmx.version && !htmx.version.startsWith("1.")) { 2 | console.warn("WARNING: You are using an htmx 1 extension with htmx " + htmx.version + 3 | ". It is recommended that you move to the version of this extension found on https://htmx.org/extensions") 4 | } 5 | htmx.defineExtension('ajax-header', { 6 | onEvent: function (name, evt) { 7 | if (name === "htmx:configRequest") { 8 | evt.detail.headers['X-Requested-With'] = 'XMLHttpRequest'; 9 | } 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /plain-htmx/plain/htmx/assets/htmx/vendor/src/ext/alpine-morph.js: -------------------------------------------------------------------------------- 1 | if (htmx.version && !htmx.version.startsWith("1.")) { 2 | console.warn("WARNING: You are using an htmx 1 extension with htmx " + htmx.version + 3 | ". It is recommended that you move to the version of this extension found on https://htmx.org/extensions") 4 | } 5 | htmx.defineExtension('alpine-morph', { 6 | isInlineSwap: function (swapStyle) { 7 | return swapStyle === 'morph'; 8 | }, 9 | handleSwap: function (swapStyle, target, fragment) { 10 | if (swapStyle === 'morph') { 11 | if (fragment.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { 12 | Alpine.morph(target, fragment.firstElementChild); 13 | return [target]; 14 | } else { 15 | Alpine.morph(target, fragment.outerHTML); 16 | return [target]; 17 | } 18 | } 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /plain-htmx/plain/htmx/assets/htmx/vendor/src/ext/debug.js: -------------------------------------------------------------------------------- 1 | if (htmx.version && !htmx.version.startsWith("1.")) { 2 | console.warn("WARNING: You are using an htmx 1 extension with htmx " + htmx.version + 3 | ". It is recommended that you move to the version of this extension found on https://htmx.org/extensions") 4 | } 5 | htmx.defineExtension('debug', { 6 | onEvent: function (name, evt) { 7 | if (console.debug) { 8 | console.debug(name, evt); 9 | } else if (console) { 10 | console.log("DEBUG:", name, evt); 11 | } else { 12 | throw "NO CONSOLE SUPPORTED" 13 | } 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /plain-htmx/plain/htmx/assets/htmx/vendor/src/ext/json-enc.js: -------------------------------------------------------------------------------- 1 | if (htmx.version && !htmx.version.startsWith("1.")) { 2 | console.warn("WARNING: You are using an htmx 1 extension with htmx " + htmx.version + 3 | ". It is recommended that you move to the version of this extension found on https://htmx.org/extensions") 4 | } 5 | htmx.defineExtension('json-enc', { 6 | onEvent: function (name, evt) { 7 | if (name === "htmx:configRequest") { 8 | evt.detail.headers['Content-Type'] = "application/json"; 9 | } 10 | }, 11 | 12 | encodeParameters : function(xhr, parameters, elt) { 13 | xhr.overrideMimeType('text/json'); 14 | return (JSON.stringify(parameters)); 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /plain-htmx/plain/htmx/assets/htmx/vendor/src/ext/method-override.js: -------------------------------------------------------------------------------- 1 | if (htmx.version && !htmx.version.startsWith("1.")) { 2 | console.warn("WARNING: You are using an htmx 1 extension with htmx " + htmx.version + 3 | ". It is recommended that you move to the version of this extension found on https://htmx.org/extensions") 4 | } 5 | htmx.defineExtension('method-override', { 6 | onEvent: function (name, evt) { 7 | if (name === "htmx:configRequest") { 8 | var method = evt.detail.verb; 9 | if (method !== "get" || method !== "post") { 10 | evt.detail.headers['X-HTTP-Method-Override'] = method.toUpperCase(); 11 | evt.detail.verb = "post"; 12 | } 13 | } 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /plain-htmx/plain/htmx/assets/htmx/vendor/src/ext/morphdom-swap.js: -------------------------------------------------------------------------------- 1 | if (htmx.version && !htmx.version.startsWith("1.")) { 2 | console.warn("WARNING: You are using an htmx 1 extension with htmx " + htmx.version + 3 | ". It is recommended that you move to the version of this extension found on https://htmx.org/extensions") 4 | } 5 | htmx.defineExtension('morphdom-swap', { 6 | isInlineSwap: function(swapStyle) { 7 | return swapStyle === 'morphdom'; 8 | }, 9 | handleSwap: function (swapStyle, target, fragment) { 10 | if (swapStyle === 'morphdom') { 11 | if (fragment.nodeType === Node.DOCUMENT_FRAGMENT_NODE) { 12 | // IE11 doesn't support DocumentFragment.firstElementChild 13 | morphdom(target, fragment.firstElementChild || fragment.firstChild); 14 | return [target]; 15 | } else { 16 | morphdom(target, fragment.outerHTML); 17 | return [target]; 18 | } 19 | } 20 | } 21 | }); 22 | -------------------------------------------------------------------------------- /plain-htmx/plain/htmx/assets/htmx/vendor/src/ext/path-params.js: -------------------------------------------------------------------------------- 1 | if (htmx.version && !htmx.version.startsWith("1.")) { 2 | console.warn("WARNING: You are using an htmx 1 extension with htmx " + htmx.version + 3 | ". It is recommended that you move to the version of this extension found on https://htmx.org/extensions") 4 | } 5 | htmx.defineExtension('path-params', { 6 | onEvent: function(name, evt) { 7 | if (name === "htmx:configRequest") { 8 | evt.detail.path = evt.detail.path.replace(/{([^}]+)}/g, function (_, param) { 9 | var val = evt.detail.parameters[param]; 10 | delete evt.detail.parameters[param]; 11 | return val === undefined ? "{" + param + "}" : encodeURIComponent(val); 12 | }) 13 | } 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /plain-htmx/plain/htmx/assets/htmx/vendor/src/ext/rails-method.js: -------------------------------------------------------------------------------- 1 | if (htmx.version && !htmx.version.startsWith("1.")) { 2 | console.warn("WARNING: You are using an htmx 1 extension with htmx " + htmx.version + 3 | ". It is recommended that you move to the version of this extension found on https://htmx.org/extensions") 4 | } 5 | htmx.defineExtension('rails-method', { 6 | onEvent: function (name, evt) { 7 | if (name === "configRequest.htmx") { 8 | var methodOverride = evt.detail.headers['X-HTTP-Method-Override']; 9 | if (methodOverride) { 10 | evt.detail.parameters['_method'] = methodOverride; 11 | } 12 | } 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /plain-htmx/plain/htmx/assets/htmx/vendor/src/ext/restored.js: -------------------------------------------------------------------------------- 1 | if (htmx.version && !htmx.version.startsWith("1.")) { 2 | console.warn("WARNING: You are using an htmx 1 extension with htmx " + htmx.version + 3 | ". It is recommended that you move to the version of this extension found on https://htmx.org/extensions") 4 | } 5 | htmx.defineExtension('restored', { 6 | onEvent : function(name, evt) { 7 | if (name === 'htmx:restored'){ 8 | var restoredElts = evt.detail.document.querySelectorAll( 9 | "[hx-trigger='restored'],[data-hx-trigger='restored']" 10 | ); 11 | // need a better way to do this, would prefer to just trigger from evt.detail.elt 12 | var foundElt = Array.from(restoredElts).find( 13 | (x) => (x.outerHTML === evt.detail.elt.outerHTML) 14 | ); 15 | var restoredEvent = evt.detail.triggerEvent(foundElt, 'restored'); 16 | } 17 | return; 18 | } 19 | }) 20 | -------------------------------------------------------------------------------- /plain-htmx/plain/htmx/assets/htmx/vendor/src/htmx.min.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-htmx/plain/htmx/assets/htmx/vendor/src/htmx.min.js.gz -------------------------------------------------------------------------------- /plain-htmx/plain/htmx/templates/htmx/js.html: -------------------------------------------------------------------------------- 1 | {%- if DEBUG -%} 2 | 3 | {%- else -%} 4 | 5 | {%- endif -%} 6 | 7 | {%- if DEBUG -%} 8 | 9 | {%- else -%} 10 | 11 | {%- endif -%} 12 | 13 | 14 | {%- for extension in extensions -%} 15 | 16 | {%- endfor -%} 17 | 18 | 19 | -------------------------------------------------------------------------------- /plain-htmx/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.htmx" 3 | version = "0.8.0" 4 | description = "HTMX integration for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | license = "BSD-3-Clause" 7 | readme = "README.md" 8 | requires-python = ">=3.11" 9 | dependencies = [ 10 | "plain<1.0.0", 11 | ] 12 | 13 | [tool.hatch.build.targets.wheel] 14 | packages = ["plain"] 15 | 16 | [build-system] 17 | requires = ["hatchling"] 18 | build-backend = "hatchling.build" 19 | -------------------------------------------------------------------------------- /plain-htmx/tests/settings.py: -------------------------------------------------------------------------------- 1 | INSTALLED_PACKAGES = [ 2 | "plain.htmx", 3 | ] 4 | -------------------------------------------------------------------------------- /plain-htmx/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from plain.htmx.views import HTMXViewMixin 2 | from plain.http import Response 3 | from plain.views import View 4 | 5 | 6 | class V(HTMXViewMixin, View): 7 | def get(self, request): 8 | return Response("Ok") 9 | 10 | 11 | def test_is_htmx_request(rf): 12 | request = rf.get("/", HTTP_HX_REQUEST="true") 13 | view = V() 14 | view.setup(request) 15 | assert view.is_htmx_request() 16 | 17 | 18 | def test_bhx_fragment(rf): 19 | request = rf.get("/", HTTP_BHX_FRAGMENT="main") 20 | view = V() 21 | view.setup(request) 22 | assert view.get_htmx_fragment_name() == "main" 23 | 24 | 25 | def test_bhx_action(rf): 26 | request = rf.get("/", HTTP_BHX_ACTION="create") 27 | view = V() 28 | view.setup(request) 29 | assert view.get_htmx_action_name() == "create" 30 | -------------------------------------------------------------------------------- /plain-loginlink/README.md: -------------------------------------------------------------------------------- 1 | ./plain/loginlink/README.md -------------------------------------------------------------------------------- /plain-loginlink/plain/loginlink/README.md: -------------------------------------------------------------------------------- 1 | # plain.loginlink 2 | 3 | Link-based authentication for Plain. 4 | -------------------------------------------------------------------------------- /plain-loginlink/plain/loginlink/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-loginlink/plain/loginlink/__init__.py -------------------------------------------------------------------------------- /plain-loginlink/plain/loginlink/templates/email/loginlink.html: -------------------------------------------------------------------------------- 1 |

Here is your link to log in: {{ url }}

2 | -------------------------------------------------------------------------------- /plain-loginlink/plain/loginlink/templates/email/loginlink.subject.txt: -------------------------------------------------------------------------------- 1 | Your link to log in 2 | -------------------------------------------------------------------------------- /plain-loginlink/plain/loginlink/templates/loginlink/failed.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 | {% if error == "expired" %} 6 |

Link Expired

7 | {% elif error == "invalid" %} 8 |

Link Invalid

9 | {% elif error == "changed" %} 10 |

Link Changed

11 | {% else %} 12 |

Link Error

13 | {% endif %} 14 | 15 | Request a new link → 16 | 17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /plain-loginlink/plain/loginlink/templates/loginlink/sent.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

Check your email

6 |

If your email address was found, then we emailed you a link to log in.

7 |

If you don't see it, make sure you have an account and check your spam folder.

8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /plain-loginlink/plain/loginlink/urls.py: -------------------------------------------------------------------------------- 1 | from plain.urls import Router, path 2 | 3 | from . import views 4 | 5 | 6 | class LoginlinkRouter(Router): 7 | namespace = "loginlink" 8 | urls = [ 9 | path("sent/", views.LoginLinkSentView.as_view(), name="sent"), 10 | path("failed/", views.LoginLinkFailedView.as_view(), name="failed"), 11 | path("token//", views.LoginLinkLoginView.as_view(), name="login"), 12 | ] 13 | -------------------------------------------------------------------------------- /plain-loginlink/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.loginlink" 3 | version = "0.10.0" 4 | description = "Emailed link-based login for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "plain<1.0.0", 10 | "plain-email<1.0.0", 11 | ] 12 | 13 | [tool.hatch.build.targets.wheel] 14 | packages = ["plain"] 15 | 16 | [build-system] 17 | requires = ["hatchling"] 18 | build-backend = "hatchling.build" 19 | -------------------------------------------------------------------------------- /plain-models/README.md: -------------------------------------------------------------------------------- 1 | ./plain/models/README.md -------------------------------------------------------------------------------- /plain-models/plain/models/backends/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-models/plain/models/backends/__init__.py -------------------------------------------------------------------------------- /plain-models/plain/models/backends/base/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-models/plain/models/backends/base/__init__.py -------------------------------------------------------------------------------- /plain-models/plain/models/backends/mysql/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-models/plain/models/backends/mysql/__init__.py -------------------------------------------------------------------------------- /plain-models/plain/models/backends/postgresql/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-models/plain/models/backends/postgresql/__init__.py -------------------------------------------------------------------------------- /plain-models/plain/models/backends/sqlite3/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-models/plain/models/backends/sqlite3/__init__.py -------------------------------------------------------------------------------- /plain-models/plain/models/backends/sqlite3/client.py: -------------------------------------------------------------------------------- 1 | from plain.models.backends.base.client import BaseDatabaseClient 2 | 3 | 4 | class DatabaseClient(BaseDatabaseClient): 5 | executable_name = "sqlite3" 6 | 7 | @classmethod 8 | def settings_to_cmd_args_env(cls, settings_dict, parameters): 9 | args = [cls.executable_name, settings_dict["NAME"], *parameters] 10 | return args, None 11 | -------------------------------------------------------------------------------- /plain-models/plain/models/backups/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-models/plain/models/backups/__init__.py -------------------------------------------------------------------------------- /plain-models/plain/models/config.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from importlib.util import find_spec 3 | 4 | from plain.packages import ( 5 | PackageConfig, 6 | packages_registry, 7 | register_config, 8 | ) 9 | 10 | from .registry import models_registry 11 | 12 | MODELS_MODULE_NAME = "models" 13 | 14 | 15 | @register_config 16 | class Config(PackageConfig): 17 | def ready(self): 18 | # Trigger register calls to fire by importing the modules 19 | for package_config in packages_registry.get_package_configs(): 20 | module_name = f"{package_config.name}.{MODELS_MODULE_NAME}" 21 | if find_spec(module_name): 22 | import_module(module_name) 23 | 24 | models_registry.ready = True 25 | -------------------------------------------------------------------------------- /plain-models/plain/models/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Constants used across the ORM in general. 3 | """ 4 | 5 | from enum import Enum 6 | 7 | # Separator used to split filter strings apart. 8 | LOOKUP_SEP = "__" 9 | 10 | 11 | class OnConflict(Enum): 12 | IGNORE = "ignore" 13 | UPDATE = "update" 14 | -------------------------------------------------------------------------------- /plain-models/plain/models/default_settings.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | 3 | from . import database_url 4 | 5 | # Make DATABASES a required setting 6 | DATABASES: dict 7 | 8 | # Automatically configure DATABASES if a DATABASE_URL was given in the environment 9 | if "DATABASE_URL" in environ: 10 | DATABASES = { 11 | "default": database_url.parse( 12 | environ["DATABASE_URL"], 13 | # Enable persistent connections by default 14 | conn_max_age=int(environ.get("DATABASE_CONN_MAX_AGE", 600)), 15 | conn_health_checks=environ.get( 16 | "DATABASE_CONN_HEALTH_CHECKS", "true" 17 | ).lower() 18 | in [ 19 | "true", 20 | "1", 21 | ], 22 | ) 23 | } 24 | 25 | # Classes used to implement DB routing behavior. 26 | DATABASE_ROUTERS = [] 27 | -------------------------------------------------------------------------------- /plain-models/plain/models/entrypoints.py: -------------------------------------------------------------------------------- 1 | def setup(): 2 | # This package isn't an installed app, 3 | # so we need to trigger our own import and cli registration. 4 | from .cli import cli # noqa 5 | from .backups import cli # noqa 6 | -------------------------------------------------------------------------------- /plain-models/plain/models/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | from .migration import Migration, settings_dependency # NOQA 2 | from .operations import * # NOQA 3 | -------------------------------------------------------------------------------- /plain-models/plain/models/migrations/operations/__init__.py: -------------------------------------------------------------------------------- 1 | from .fields import AddField, AlterField, RemoveField, RenameField 2 | from .models import ( 3 | AddConstraint, 4 | AddIndex, 5 | AlterModelManagers, 6 | AlterModelOptions, 7 | AlterModelTable, 8 | AlterModelTableComment, 9 | CreateModel, 10 | DeleteModel, 11 | RemoveConstraint, 12 | RemoveIndex, 13 | RenameIndex, 14 | RenameModel, 15 | ) 16 | from .special import RunPython, RunSQL, SeparateDatabaseAndState 17 | 18 | __all__ = [ 19 | "CreateModel", 20 | "DeleteModel", 21 | "AlterModelTable", 22 | "AlterModelTableComment", 23 | "RenameModel", 24 | "AlterModelOptions", 25 | "AddIndex", 26 | "RemoveIndex", 27 | "RenameIndex", 28 | "AddField", 29 | "RemoveField", 30 | "AlterField", 31 | "RenameField", 32 | "AddConstraint", 33 | "RemoveConstraint", 34 | "SeparateDatabaseAndState", 35 | "RunSQL", 36 | "RunPython", 37 | "AlterModelManagers", 38 | ] 39 | -------------------------------------------------------------------------------- /plain-models/plain/models/sql/__init__.py: -------------------------------------------------------------------------------- 1 | from plain.models.sql.query import * # NOQA 2 | from plain.models.sql.query import Query 3 | from plain.models.sql.subqueries import * # NOQA 4 | from plain.models.sql.where import AND, OR, XOR 5 | 6 | __all__ = ["Query", "AND", "OR", "XOR"] 7 | -------------------------------------------------------------------------------- /plain-models/plain/models/sql/constants.py: -------------------------------------------------------------------------------- 1 | """ 2 | Constants specific to the SQL storage portion of the ORM. 3 | """ 4 | 5 | # Size of each "chunk" for get_iterator calls. 6 | # Larger values are slightly faster at the expense of more storage space. 7 | GET_ITERATOR_CHUNK_SIZE = 100 8 | 9 | # Namedtuples for sql.* internal use. 10 | 11 | # How many results to expect from a cursor.execute call 12 | MULTI = "multi" 13 | SINGLE = "single" 14 | CURSOR = "cursor" 15 | NO_RESULTS = "no results" 16 | 17 | ORDER_DIR = { 18 | "ASC": ("ASC", "DESC"), 19 | "DESC": ("DESC", "ASC"), 20 | } 21 | 22 | # SQL join types. 23 | INNER = "INNER JOIN" 24 | LOUTER = "LEFT OUTER JOIN" 25 | -------------------------------------------------------------------------------- /plain-models/plain/models/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-models/plain/models/test/__init__.py -------------------------------------------------------------------------------- /plain-models/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.models" 3 | version = "0.31.1" 4 | description = "Database models for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "plain<1.0.0", 10 | "sqlparse>=0.3.1", 11 | ] 12 | 13 | [project.entry-points."plain.setup"] 14 | "models-setup" = "plain.models.entrypoints:setup" 15 | 16 | # Automatically sets this up with pytest 17 | [project.entry-points."pytest11"] 18 | "plain.models" = "plain.models.test.pytest" 19 | 20 | [tool.uv] 21 | dev-dependencies = [ 22 | "plain.pytest<1.0.0", 23 | ] 24 | 25 | [tool.hatch.build.targets.wheel] 26 | packages = ["plain"] 27 | 28 | [build-system] 29 | requires = ["hatchling"] 30 | build-backend = "hatchling.build" 31 | -------------------------------------------------------------------------------- /plain-models/tests/app/examples/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.13.1 on 2025-02-16 23:02 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="Car", 15 | fields=[ 16 | ("id", models.BigAutoField(auto_created=True, primary_key=True)), 17 | ("make", models.CharField(max_length=100)), 18 | ("model", models.CharField(max_length=100)), 19 | ], 20 | ), 21 | migrations.AddConstraint( 22 | model_name="car", 23 | constraint=models.UniqueConstraint( 24 | fields=("make", "model"), name="unique_make_model" 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /plain-models/tests/app/examples/migrations/0002_test_field_removed.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | def set_uuids(models, schema_editor): 8 | Car = models.get_model("examples", "Car") 9 | for sq in Car.objects.filter(uuid__isnull=True): 10 | sq.uuid = uuid.uuid4() 11 | sq.save(clean_and_validate=False) 12 | 13 | 14 | class Migration(migrations.Migration): 15 | dependencies = [ 16 | ("examples", "0001_initial"), 17 | ] 18 | 19 | operations = [ 20 | migrations.AddField( 21 | model_name="car", 22 | name="uuid", 23 | field=models.UUIDField(allow_null=True), 24 | ), 25 | migrations.RunPython(set_uuids), 26 | migrations.RemoveField( 27 | model_name="car", 28 | name="uuid", 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /plain-models/tests/app/examples/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-models/tests/app/examples/migrations/__init__.py -------------------------------------------------------------------------------- /plain-models/tests/app/examples/models.py: -------------------------------------------------------------------------------- 1 | from plain import models 2 | 3 | 4 | @models.register_model 5 | class Car(models.Model): 6 | make = models.CharField(max_length=100) 7 | model = models.CharField(max_length=100) 8 | 9 | class Meta: 10 | constraints = [ 11 | models.UniqueConstraint(fields=["make", "model"], name="unique_make_model"), 12 | ] 13 | 14 | 15 | class UnregisteredModel(models.Model): 16 | pass 17 | -------------------------------------------------------------------------------- /plain-models/tests/app/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "test" 2 | URLS_ROUTER = "app.urls.AppRouter" 3 | INSTALLED_PACKAGES = [ 4 | "plain.models", 5 | "app.examples", 6 | ] 7 | DATABASES = { 8 | "default": { 9 | "ENGINE": "plain.models.backends.sqlite3", 10 | "NAME": ":memory:", 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /plain-models/tests/app/urls.py: -------------------------------------------------------------------------------- 1 | from plain.admin.urls import AdminRouter 2 | from plain.assets.urls import AssetsRouter 3 | from plain.urls import Router, include, path 4 | from plain.views import View 5 | 6 | 7 | class LoginView(View): 8 | def get(self): 9 | return "Login!" 10 | 11 | 12 | class LogoutView(View): 13 | def get(self): 14 | return "Logout!" 15 | 16 | 17 | class AppRouter(Router): 18 | namespace = "" 19 | urls = [ 20 | include("admin/", AdminRouter), 21 | include("assets/", AssetsRouter), 22 | path("login/", LoginView, name="login"), 23 | path("logout/", LogoutView, name="logout"), 24 | ] 25 | -------------------------------------------------------------------------------- /plain-models/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from app.examples.models import Car 3 | 4 | from plain.exceptions import ValidationError 5 | 6 | 7 | def test_create_unique_constraint(db): 8 | Car.objects.create(make="Toyota", model="Tundra") 9 | 10 | with pytest.raises(ValidationError) as e: 11 | Car.objects.create(make="Toyota", model="Tundra") 12 | 13 | assert ( 14 | str(e) 15 | == "" 16 | ) 17 | 18 | assert Car.objects.count() == 1 19 | 20 | 21 | def test_update_or_create_unique_constraint(db): 22 | Car.objects.update_or_create(make="Toyota", model="Tundra") 23 | Car.objects.update_or_create(make="Toyota", model="Tundra") 24 | 25 | assert Car.objects.count() == 1 26 | -------------------------------------------------------------------------------- /plain-oauth/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /dist 3 | /.venv 4 | *.pyc 5 | __pycache__ 6 | db.sqlite3 7 | .env 8 | -------------------------------------------------------------------------------- /plain-oauth/README.md: -------------------------------------------------------------------------------- 1 | ./plain/oauth/README.md -------------------------------------------------------------------------------- /plain-oauth/plain/oauth/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-oauth/plain/oauth/__init__.py -------------------------------------------------------------------------------- /plain-oauth/plain/oauth/config.py: -------------------------------------------------------------------------------- 1 | from plain.packages import PackageConfig, register_config 2 | 3 | 4 | @register_config 5 | class Config(PackageConfig): 6 | package_label = "plainoauth" # Primarily for migrations 7 | -------------------------------------------------------------------------------- /plain-oauth/plain/oauth/default_settings.py: -------------------------------------------------------------------------------- 1 | OAUTH_LOGIN_PROVIDERS: dict 2 | -------------------------------------------------------------------------------- /plain-oauth/plain/oauth/exceptions.py: -------------------------------------------------------------------------------- 1 | class OAuthError(Exception): 2 | """Base class for OAuth errors""" 3 | 4 | message = "An error occurred during the OAuth process." 5 | 6 | 7 | class OAuthStateMissingError(OAuthError): 8 | message = "The state parameter is missing. Please try again." 9 | 10 | 11 | class OAuthStateMismatchError(OAuthError): 12 | message = "The state parameter did not match. Please try again." 13 | 14 | 15 | class OAuthUserAlreadyExistsError(OAuthError): 16 | message = "A user already exists with this email address. Please log in first and then connect this OAuth provider to the existing account." 17 | -------------------------------------------------------------------------------- /plain-oauth/plain/oauth/migrations/0002_alter_oauthconnection_options_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 4.0.3 on 2022-03-18 18:24 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainoauth", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name="oauthconnection", 15 | options={ 16 | "ordering": ("provider_key",), 17 | }, 18 | ), 19 | migrations.AlterField( 20 | model_name="oauthconnection", 21 | name="access_token", 22 | field=models.CharField(max_length=100), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /plain-oauth/plain/oauth/migrations/0003_alter_oauthconnection_access_token_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 4.0.6 on 2022-08-11 19:18 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainoauth", "0002_alter_oauthconnection_options_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="oauthconnection", 15 | name="access_token", 16 | field=models.CharField(max_length=300), 17 | ), 18 | migrations.AlterField( 19 | model_name="oauthconnection", 20 | name="refresh_token", 21 | field=models.CharField(required=False, max_length=300), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /plain-oauth/plain/oauth/migrations/0004_alter_oauthconnection_access_token_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 5.0.dev20230806030948 on 2023-08-08 19:32 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainoauth", "0003_alter_oauthconnection_access_token_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="oauthconnection", 15 | name="access_token", 16 | field=models.CharField(max_length=2000), 17 | ), 18 | migrations.AlterField( 19 | model_name="oauthconnection", 20 | name="refresh_token", 21 | field=models.CharField(required=False, max_length=2000), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /plain-oauth/plain/oauth/migrations/0005_alter_oauthconnection_unique_together_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.3.0 on 2024-08-16 16:46 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainoauth", "0004_alter_oauthconnection_access_token_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddConstraint( 14 | model_name="oauthconnection", 15 | constraint=models.UniqueConstraint( 16 | fields=("provider_key", "provider_user_id"), 17 | name="unique_oauth_provider_user_id", 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /plain-oauth/plain/oauth/migrations/0006_remove_oauthconnection_unique_oauth_provider_user_id_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.31.0 on 2025-03-08 21:33 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainoauth", "0005_alter_oauthconnection_unique_together_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.RemoveConstraint( 14 | model_name="oauthconnection", 15 | name="unique_oauth_provider_user_id", 16 | ), 17 | migrations.AddConstraint( 18 | model_name="oauthconnection", 19 | constraint=models.UniqueConstraint( 20 | fields=("provider_key", "provider_user_id"), 21 | name="plainoauth_oauthconnection_unique_provider_key_user_id", 22 | ), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /plain-oauth/plain/oauth/migrations/0007_alter_oauthconnection_provider_key_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.32.0 on 2025-03-10 15:37 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ( 10 | "plainoauth", 11 | "0006_remove_oauthconnection_unique_oauth_provider_user_id_and_more", 12 | ), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="oauthconnection", 18 | name="provider_key", 19 | field=models.CharField(max_length=100), 20 | ), 21 | migrations.AlterField( 22 | model_name="oauthconnection", 23 | name="provider_user_id", 24 | field=models.CharField(max_length=100), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /plain-oauth/plain/oauth/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-oauth/plain/oauth/migrations/__init__.py -------------------------------------------------------------------------------- /plain-oauth/plain/oauth/templates/oauth/callback.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

OAuth Error

6 |

{{ oauth_error.message }}

7 |
8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /plain-oauth/plain/oauth/urls.py: -------------------------------------------------------------------------------- 1 | from plain.urls import Router, include, path 2 | 3 | from . import views 4 | 5 | 6 | class OAuthRouter(Router): 7 | namespace = "oauth" 8 | urls = [ 9 | include( 10 | "/", 11 | [ 12 | # Login and Signup are both handled here, because the intent is the same 13 | path("login/", views.OAuthLoginView, name="login"), 14 | path("connect/", views.OAuthConnectView, name="connect"), 15 | path( 16 | "disconnect/", 17 | views.OAuthDisconnectView, 18 | name="disconnect", 19 | ), 20 | path("callback/", views.OAuthCallbackView, name="callback"), 21 | ], 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /plain-oauth/provider_examples/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-oauth/provider_examples/__init__.py -------------------------------------------------------------------------------- /plain-oauth/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.oauth" 3 | version = "0.20.0" 4 | description = "OAuth login and API access for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | license = "BSD-3-Clause" 7 | readme = "README.md" 8 | requires-python = ">=3.11" 9 | dependencies = [ 10 | "plain<1.0.0", 11 | "plain.auth<1.0.0", 12 | "plain.models<1.0.0", 13 | "requests>=2.0.0", 14 | ] 15 | 16 | 17 | 18 | [tool.uv] 19 | dev-dependencies = [ 20 | "plain.pytest<1.0.0", 21 | ] 22 | 23 | [tool.hatch.build.targets.wheel] 24 | packages = ["plain"] 25 | 26 | [build-system] 27 | requires = ["hatchling"] 28 | build-backend = "hatchling.build" 29 | -------------------------------------------------------------------------------- /plain-oauth/tests/app/templates/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Document 8 | 9 | 10 | {% block content %} 11 | {% endblock %} 12 | 13 | 14 | -------------------------------------------------------------------------------- /plain-oauth/tests/app/templates/login.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |

Login

5 |
6 | {{ csrf_input }} 7 | 8 |
9 |
10 | {{ csrf_input }} 11 | 12 |
13 |
14 | {{ csrf_input }} 15 | 16 |
17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /plain-oauth/tests/app/urls.py: -------------------------------------------------------------------------------- 1 | from plain.auth.views import AuthViewMixin, LogoutView 2 | from plain.oauth.providers import get_provider_keys 3 | from plain.oauth.urls import OAuthRouter 4 | from plain.urls import Router, include, path 5 | from plain.views import TemplateView 6 | 7 | 8 | class LoggedInView(AuthViewMixin, TemplateView): 9 | template_name = "index.html" 10 | 11 | def get_template_context(self): 12 | context = super().get_template_context() 13 | context["oauth_provider_keys"] = get_provider_keys() 14 | return context 15 | 16 | 17 | class LoginView(TemplateView): 18 | template_name = "login.html" 19 | 20 | 21 | class AppRouter(Router): 22 | namespace = "" 23 | urls = [ 24 | include("oauth/", OAuthRouter), 25 | path("login/", LoginView, name="login"), 26 | path("logout/", LogoutView, name="logout"), 27 | path("", LoggedInView), 28 | ] 29 | -------------------------------------------------------------------------------- /plain-oauth/tests/app/users/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.21.5 on 2025-02-17 04:24 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | initial = True 9 | 10 | dependencies = [] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name="User", 15 | fields=[ 16 | ("id", models.BigAutoField(auto_created=True, primary_key=True)), 17 | ("email", models.EmailField(max_length=254)), 18 | ("username", models.CharField(max_length=100)), 19 | ], 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /plain-oauth/tests/app/users/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-oauth/tests/app/users/migrations/__init__.py -------------------------------------------------------------------------------- /plain-oauth/tests/app/users/models.py: -------------------------------------------------------------------------------- 1 | from plain import models 2 | 3 | 4 | @models.register_model 5 | class User(models.Model): 6 | email = models.EmailField() 7 | username = models.CharField(max_length=100) 8 | 9 | class Meta: 10 | constraints = [ 11 | models.UniqueConstraint(fields=["email"], name="user_unique_email"), 12 | models.UniqueConstraint(fields=["username"], name="user_unique_username"), 13 | ] 14 | 15 | def __str__(self): 16 | return self.username 17 | -------------------------------------------------------------------------------- /plain-oauth/tests/provider_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-oauth/tests/provider_tests/__init__.py -------------------------------------------------------------------------------- /plain-oauth/tests/providers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-oauth/tests/providers/__init__.py -------------------------------------------------------------------------------- /plain-pages/.gitignore: -------------------------------------------------------------------------------- 1 | # Local development files 2 | /.env 3 | /.plain 4 | *.sqlite3 5 | 6 | # Publishing 7 | /dist 8 | 9 | # Python 10 | /.venv 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # OS files 16 | .DS_Store 17 | -------------------------------------------------------------------------------- /plain-pages/README.md: -------------------------------------------------------------------------------- 1 | ./plain/pages/README.md -------------------------------------------------------------------------------- /plain-pages/plain/pages/README.md: -------------------------------------------------------------------------------- 1 | # Pages 2 | -------------------------------------------------------------------------------- /plain-pages/plain/pages/__init__.py: -------------------------------------------------------------------------------- 1 | from .views import PageView 2 | 3 | __all__ = [ 4 | "PageView", 5 | ] 6 | -------------------------------------------------------------------------------- /plain-pages/plain/pages/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from plain.internal import internalcode 4 | from plain.packages import PackageConfig, packages_registry, register_config 5 | from plain.runtime import APP_PATH 6 | 7 | from .registry import pages_registry 8 | 9 | 10 | @internalcode 11 | @register_config 12 | class Config(PackageConfig): 13 | def ready(self): 14 | for pacakge_config in packages_registry.get_package_configs(): 15 | pages_registry.discover_pages( 16 | os.path.join(pacakge_config.path, "templates", "pages") 17 | ) 18 | 19 | pages_registry.discover_pages(os.path.join(APP_PATH, "templates", "pages")) 20 | -------------------------------------------------------------------------------- /plain-pages/plain/pages/exceptions.py: -------------------------------------------------------------------------------- 1 | class PageNotFoundError(Exception): 2 | pass 3 | 4 | 5 | class RedirectPageError(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /plain-pages/plain/pages/templates/page.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block title %}{{ page.title }}{% endblock %} 4 | 5 | {% block content %} 6 | 7 | {{ page.content|safe }} 8 | 9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /plain-pages/plain/pages/urls.py: -------------------------------------------------------------------------------- 1 | from plain.urls import Router 2 | 3 | from .registry import pages_registry 4 | 5 | 6 | class PagesRouter(Router): 7 | namespace = "pages" 8 | urls = pages_registry.get_page_urls() 9 | -------------------------------------------------------------------------------- /plain-pages/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.pages" 3 | version = "0.10.3" 4 | description = "Simplified Markdown-based content for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "plain<1.0.0", 10 | "mistune>=3.0.0", 11 | "python-frontmatter>=1.0.0", 12 | "pygments>=2.0.0", 13 | ] 14 | 15 | [tool.hatch.build.targets.wheel] 16 | packages = ["plain"] 17 | 18 | [build-system] 19 | requires = ["hatchling"] 20 | build-backend = "hatchling.build" 21 | -------------------------------------------------------------------------------- /plain-pageviews/README.md: -------------------------------------------------------------------------------- 1 | ./plain/pageviews/README.md -------------------------------------------------------------------------------- /plain-pageviews/plain/pageviews/chores.py: -------------------------------------------------------------------------------- 1 | from plain.chores import register_chore 2 | from plain.runtime import settings 3 | from plain.utils import timezone 4 | 5 | from .models import Pageview 6 | 7 | 8 | @register_chore("pageviews") 9 | def clear_old_pageviews(): 10 | """ 11 | Delete old anonymous and authenticated pageviews. 12 | """ 13 | 14 | cutoff = timezone.now() - settings.PAGEVIEWS_ANONYMOUS_RETENTION_TIMEDELTA 15 | result = Pageview.objects.filter(timestamp__lt=cutoff, user_id="").delete() 16 | output = f"{result[0]} anonymous pageviews deleted" 17 | 18 | cutoff = timezone.now() - settings.PAGEVIEWS_AUTHENTICATED_RETENTION_TIMEDELTA 19 | result = Pageview.objects.filter(timestamp__lt=cutoff).exclude(user_id="").delete() 20 | output += f", {result[0]} authenticated pageviews deleted" 21 | 22 | return output 23 | -------------------------------------------------------------------------------- /plain-pageviews/plain/pageviews/config.py: -------------------------------------------------------------------------------- 1 | from plain.packages import PackageConfig, register_config 2 | 3 | 4 | @register_config 5 | class Config(PackageConfig): 6 | package_label = "plainpageviews" 7 | -------------------------------------------------------------------------------- /plain-pageviews/plain/pageviews/default_settings.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | PAGEVIEWS_ASSOCIATE_ANONYMOUS_SESSIONS: bool = True 4 | PAGEVIEWS_ANONYMOUS_RETENTION_TIMEDELTA: timedelta = timedelta(days=90) 5 | PAGEVIEWS_AUTHENTICATED_RETENTION_TIMEDELTA: timedelta = timedelta(days=365) 6 | -------------------------------------------------------------------------------- /plain-pageviews/plain/pageviews/migrations/0002_alter_pageview_referrer_alter_pageview_title_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.15.0 on 2025-01-06 20:17 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainpageviews", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="pageview", 15 | name="referrer", 16 | field=models.URLField(required=False, max_length=1024), 17 | ), 18 | migrations.AlterField( 19 | model_name="pageview", 20 | name="title", 21 | field=models.CharField(required=False, max_length=512), 22 | ), 23 | migrations.AlterField( 24 | model_name="pageview", 25 | name="url", 26 | field=models.URLField(max_length=1024), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /plain-pageviews/plain/pageviews/migrations/0003_alter_pageview_uuid.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.15.0 on 2025-01-06 20:20 2 | 3 | import uuid 4 | 5 | from plain import models 6 | from plain.models import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ( 12 | "plainpageviews", 13 | "0002_alter_pageview_referrer_alter_pageview_title_and_more", 14 | ), 15 | ] 16 | 17 | operations = [ 18 | migrations.AlterField( 19 | model_name="pageview", 20 | name="uuid", 21 | field=models.UUIDField(default=uuid.uuid4), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /plain-pageviews/plain/pageviews/migrations/0004_alter_pageview_uuid_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.31.0 on 2025-03-08 21:33 2 | 3 | import uuid 4 | 5 | from plain import models 6 | from plain.models import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("plainpageviews", "0003_alter_pageview_uuid"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="pageview", 17 | name="uuid", 18 | field=models.UUIDField(default=uuid.uuid4), 19 | ), 20 | migrations.AddConstraint( 21 | model_name="pageview", 22 | constraint=models.UniqueConstraint( 23 | fields=("uuid",), name="plainpageviews_pageview_unique_uuid" 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /plain-pageviews/plain/pageviews/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-pageviews/plain/pageviews/migrations/__init__.py -------------------------------------------------------------------------------- /plain-pageviews/plain/pageviews/templates.py: -------------------------------------------------------------------------------- 1 | from plain.templates import register_template_extension 2 | from plain.templates.jinja.extensions import InclusionTagExtension 3 | from plain.urls import reverse 4 | 5 | 6 | @register_template_extension 7 | class PageviewsJSExtension(InclusionTagExtension): 8 | tags = {"pageviews_js"} 9 | template_name = "pageviews/js.html" 10 | 11 | def get_context(self, context, *args, **kwargs): 12 | return { 13 | "pageviews_track_url": reverse("pageviews:track"), 14 | } 15 | -------------------------------------------------------------------------------- /plain-pageviews/plain/pageviews/templates/pageviews/card.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/cards/base.html" %} 2 | 3 | {% block content %} 4 | 5 |
6 | {% for pageview in pageviews %} 7 | 21 | {% endfor %} 22 |
23 | 24 | {% endblock %} 25 | -------------------------------------------------------------------------------- /plain-pageviews/plain/pageviews/templates/pageviews/js.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /plain-pageviews/plain/pageviews/urls.py: -------------------------------------------------------------------------------- 1 | from plain.urls import Router, path 2 | 3 | from . import views 4 | 5 | 6 | class PageviewsRouter(Router): 7 | namespace = "pageviews" 8 | urls = [ 9 | path("track/", views.TrackView, name="track"), 10 | ] 11 | -------------------------------------------------------------------------------- /plain-pageviews/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.pageviews" 3 | version = "0.15.4" 4 | description = "Basic pageview analytics for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "plain<1.0.0", 10 | "plain.models<1.0.0", 11 | ] 12 | 13 | [tool.hatch.build.targets.wheel] 14 | packages = ["plain"] 15 | 16 | [build-system] 17 | requires = ["hatchling"] 18 | build-backend = "hatchling.build" 19 | -------------------------------------------------------------------------------- /plain-passwords/README.md: -------------------------------------------------------------------------------- 1 | ./plain/passwords/README.md -------------------------------------------------------------------------------- /plain-passwords/plain/passwords/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-passwords/plain/passwords/__init__.py -------------------------------------------------------------------------------- /plain-passwords/plain/passwords/common-passwords.txt.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-passwords/plain/passwords/common-passwords.txt.gz -------------------------------------------------------------------------------- /plain-passwords/plain/passwords/core.py: -------------------------------------------------------------------------------- 1 | from .hashers import check_password, hash_password 2 | 3 | 4 | def check_user_password(user, password): 5 | # Run the default password hasher once to reduce the timing 6 | # difference between an existing and a nonexistent user (#20760). 7 | hash_password(password) 8 | 9 | # Update the stored hashed password if the hashing algorithm changed 10 | def setter(raw_password): 11 | user.password = raw_password 12 | user.save(update_fields=["password"]) 13 | 14 | password_is_correct = check_password(password, user.password, setter) 15 | 16 | return password_is_correct 17 | -------------------------------------------------------------------------------- /plain-passwords/plain/passwords/default_settings.py: -------------------------------------------------------------------------------- 1 | # The first hasher in this list is the preferred algorithm. Any 2 | # password using different algorithms will be converted automatically 3 | # upon login. 4 | PASSWORD_HASHERS: list = [ 5 | "plain.passwords.hashers.PBKDF2PasswordHasher", 6 | "plain.passwords.hashers.PBKDF2SHA1PasswordHasher", 7 | "plain.passwords.hashers.Argon2PasswordHasher", 8 | "plain.passwords.hashers.BCryptSHA256PasswordHasher", 9 | "plain.passwords.hashers.ScryptPasswordHasher", 10 | ] 11 | -------------------------------------------------------------------------------- /plain-passwords/plain/passwords/templates/email/password_reset.html: -------------------------------------------------------------------------------- 1 |

A password reset has been requested for {{ email }}. If you didn't make this request, you can ignore this email.

2 | 3 |

To reset your password, please click the link below:

4 | 5 |

{{ url }}

6 | -------------------------------------------------------------------------------- /plain-passwords/plain/passwords/templates/email/password_reset.subject.txt: -------------------------------------------------------------------------------- 1 | Password reset 2 | -------------------------------------------------------------------------------- /plain-passwords/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.passwords" 3 | version = "0.9.1" 4 | description = "Password-based login for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "plain<1.0.0", 10 | ] 11 | 12 | [tool.hatch.build.targets.wheel] 13 | packages = ["plain"] 14 | 15 | [build-system] 16 | requires = ["hatchling"] 17 | build-backend = "hatchling.build" 18 | -------------------------------------------------------------------------------- /plain-pytest/.gitignore: -------------------------------------------------------------------------------- 1 | # Local development files 2 | /.env 3 | /.plain 4 | 5 | # Publishing 6 | /dist 7 | 8 | # Python 9 | /.venv 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # OS files 15 | .DS_Store 16 | -------------------------------------------------------------------------------- /plain-pytest/README.md: -------------------------------------------------------------------------------- 1 | ./plain/pytest/README.md -------------------------------------------------------------------------------- /plain-pytest/plain/pytest/README.md: -------------------------------------------------------------------------------- 1 | ## Testing - pytest 2 | 3 | Write and run tests with pytest. 4 | 5 | Django includes its own test runner and [unittest](https://docs.python.org/3/library/unittest.html#module-unittest) classes. 6 | But a lot of people (myself included) prefer [pytest](https://docs.pytest.org/en/latest/contents.html). 7 | 8 | In Plain I've removed the Django test runner and a lot of the implications that come with it. 9 | There are a few utilities that remain to make testing easier, 10 | and `plain test` is a wrapper around `pytest`. 11 | 12 | ## Usage 13 | 14 | To run your tests with pytest, use the `plain test` command: 15 | 16 | ```bash 17 | plain test 18 | ``` 19 | 20 | This will execute all your tests using pytest. 21 | -------------------------------------------------------------------------------- /plain-pytest/plain/pytest/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli 2 | 3 | __all__ = ["cli"] 4 | -------------------------------------------------------------------------------- /plain-pytest/plain/pytest/entrypoints.py: -------------------------------------------------------------------------------- 1 | def setup(): 2 | # This package isn't an installed app, 3 | # so we need to trigger our own import and cli registration. 4 | from .cli import cli # noqa 5 | -------------------------------------------------------------------------------- /plain-pytest/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.pytest" 3 | version = "0.7.0" 4 | description = "Pytest integration for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | license = "BSD-3-Clause" 7 | readme = "README.md" 8 | requires-python = ">=3.11" 9 | dependencies = [ 10 | "plain<1.0.0", 11 | "click>=8.0.0", 12 | "pytest>=7.0.0", 13 | "python-dotenv~=1.0.0", 14 | ] 15 | 16 | [project.entry-points."plain.setup"] 17 | "test-setup" = "plain.pytest.entrypoints:setup" 18 | 19 | # Automatically sets this up with pytest 20 | [project.entry-points."pytest11"] 21 | "plain" = "plain.pytest.plugin" 22 | 23 | [tool.hatch.build.targets.wheel] 24 | packages = ["plain"] 25 | 26 | [build-system] 27 | requires = ["hatchling"] 28 | build-backend = "hatchling.build" 29 | -------------------------------------------------------------------------------- /plain-redirection/README.md: -------------------------------------------------------------------------------- 1 | ./plain/redirection/README.md -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/README.md: -------------------------------------------------------------------------------- 1 | # plain-redirection 2 | -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/__init__.py: -------------------------------------------------------------------------------- 1 | from .middleware import RedirectionMiddleware 2 | 3 | __all__ = ["RedirectionMiddleware"] 4 | -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/chores.py: -------------------------------------------------------------------------------- 1 | from plain.chores import register_chore 2 | from plain.runtime import settings 3 | from plain.utils import timezone 4 | 5 | from .models import NotFoundLog, RedirectLog 6 | 7 | 8 | @register_chore("redirection") 9 | def delete_logs(): 10 | """ 11 | Delete logs older than REDIRECTION_LOG_RETENTION_TIMEDELTA. 12 | """ 13 | cutoff = timezone.now() - settings.REDIRECTION_LOG_RETENTION_TIMEDELTA 14 | 15 | result = RedirectLog.objects.filter(created_at__lt=cutoff).delete() 16 | output = f"{result[0]} redirect logs deleted" 17 | 18 | result = NotFoundLog.objects.filter(created_at__lt=cutoff).delete() 19 | output += f", {result[0]} not found logs deleted" 20 | 21 | return output 22 | -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/config.py: -------------------------------------------------------------------------------- 1 | from plain.packages import PackageConfig, register_config 2 | 3 | 4 | @register_config 5 | class Config(PackageConfig): 6 | package_label = "plainredirection" 7 | -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/default_settings.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | REDIRECTION_LOG_RETENTION_TIMEDELTA: timedelta = timedelta(days=30) 4 | -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/migrations/0002_redirect_enabled_redirect_is_regex.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.20.0 on 2025-02-05 20:12 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainredirection", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="redirect", 15 | name="enabled", 16 | field=models.BooleanField(default=True), 17 | ), 18 | migrations.AddField( 19 | model_name="redirect", 20 | name="is_regex", 21 | field=models.BooleanField(default=False), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/migrations/0003_alter_redirect_from_pattern.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.21.0 on 2025-02-05 21:58 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainredirection", "0002_redirect_enabled_redirect_is_regex"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="redirect", 15 | name="from_pattern", 16 | field=models.CharField(max_length=255), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/migrations/0004_alter_notfoundlog_referer_alter_redirectlog_referer.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.21.1 on 2025-02-06 16:36 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainredirection", "0003_alter_redirect_from_pattern"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="notfoundlog", 15 | name="referer", 16 | field=models.CharField(required=False, default=""), 17 | preserve_default=False, 18 | ), 19 | migrations.AlterField( 20 | model_name="redirectlog", 21 | name="referer", 22 | field=models.CharField(required=False, default=""), 23 | preserve_default=False, 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/migrations/0006_alter_notfoundlog_user_agent_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.21.1 on 2025-02-06 16:48 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainredirection", "0005_alter_notfoundlog_referer_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="notfoundlog", 15 | name="user_agent", 16 | field=models.CharField(required=False, max_length=512), 17 | ), 18 | migrations.AlterField( 19 | model_name="redirectlog", 20 | name="user_agent", 21 | field=models.CharField(required=False, max_length=512), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/migrations/0007_alter_redirect_http_status_alter_redirect_order_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.21.1 on 2025-02-06 17:59 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainredirection", "0006_alter_notfoundlog_user_agent_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="redirect", 15 | name="http_status", 16 | field=models.PositiveSmallIntegerField(default=301), 17 | ), 18 | migrations.AlterField( 19 | model_name="redirect", 20 | name="order", 21 | field=models.PositiveSmallIntegerField(default=0), 22 | ), 23 | migrations.AlterField( 24 | model_name="redirectlog", 25 | name="http_status", 26 | field=models.PositiveSmallIntegerField(default=301), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/migrations/0009_rename_referer_notfoundlog_referrer_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.28.0 on 2025-02-27 19:52 2 | 3 | from plain.models import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ( 9 | "plainredirection", 10 | "0008_alter_notfoundlog_url_alter_redirectlog_from_url_and_more", 11 | ), 12 | ] 13 | 14 | operations = [ 15 | migrations.RenameField( 16 | model_name="notfoundlog", 17 | old_name="referer", 18 | new_name="referrer", 19 | ), 20 | migrations.RenameField( 21 | model_name="redirectlog", 22 | old_name="referer", 23 | new_name="referrer", 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/migrations/0010_alter_redirect_from_pattern_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.31.0 on 2025-03-09 02:16 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainredirection", "0009_rename_referer_notfoundlog_referrer_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="redirect", 15 | name="from_pattern", 16 | field=models.CharField(max_length=255), 17 | ), 18 | migrations.AddConstraint( 19 | model_name="redirect", 20 | constraint=models.UniqueConstraint( 21 | fields=("from_pattern",), 22 | name="plainredirects_redirect_unique_from_pattern", 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/migrations/0011_alter_redirect_order_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.31.0 on 2025-03-10 15:43 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainredirection", "0010_alter_redirect_from_pattern_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="redirect", 15 | name="order", 16 | field=models.PositiveSmallIntegerField(default=0), 17 | ), 18 | migrations.AddIndex( 19 | model_name="redirect", 20 | index=models.Index(fields=["order"], name="plainredire_order_44dde0_idx"), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-redirection/plain/redirection/migrations/__init__.py -------------------------------------------------------------------------------- /plain-redirection/plain/redirection/templates/admin/plainredirection/redirect_form.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/base.html" %} 2 | 3 | {% use_elements %} 4 | 5 | {% block content %} 6 |
7 | {{ csrf_input }} 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 19 |
20 | 21 | {% endblock %} 22 | -------------------------------------------------------------------------------- /plain-redirection/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.redirection" 3 | version = "0.16.0" 4 | description = "" 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "plain<1.0.0", 10 | "plain.models<1.0.0", 11 | ] 12 | 13 | [tool.hatch.build.targets.wheel] 14 | packages = ["plain"] 15 | 16 | [build-system] 17 | requires = ["hatchling"] 18 | build-backend = "hatchling.build" 19 | -------------------------------------------------------------------------------- /plain-sessions/README.md: -------------------------------------------------------------------------------- 1 | ./plain/sessions/README.md -------------------------------------------------------------------------------- /plain-sessions/plain/sessions/README.md: -------------------------------------------------------------------------------- 1 | ## Sessions - db backed 2 | 3 | Manage sessions and save them in the database. 4 | 5 | - associate with users? 6 | - devices? 7 | 8 | ## Usage 9 | 10 | To use sessions in your views, access the `request.session` object: 11 | 12 | ```python 13 | # Example view using sessions 14 | class MyView(View): 15 | def get(self): 16 | # Store a value in the session 17 | self.request.session['key'] = 'value' 18 | # Retrieve a value from the session 19 | value = self.request.session.get('key') 20 | ``` 21 | -------------------------------------------------------------------------------- /plain-sessions/plain/sessions/__init__.py: -------------------------------------------------------------------------------- 1 | from . import preflight # noqa 2 | from .core import SessionStore 3 | 4 | 5 | __all__ = [ 6 | "SessionStore", 7 | ] 8 | -------------------------------------------------------------------------------- /plain-sessions/plain/sessions/admin.py: -------------------------------------------------------------------------------- 1 | from plain.admin.toolbar import ToolbarPanel, register_toolbar_panel 2 | 3 | 4 | @register_toolbar_panel 5 | class SessionToolbarPanel(ToolbarPanel): 6 | name = "Session" 7 | template_name = "toolbar/session.html" 8 | -------------------------------------------------------------------------------- /plain-sessions/plain/sessions/chores.py: -------------------------------------------------------------------------------- 1 | from plain.chores import register_chore 2 | from plain.utils import timezone 3 | 4 | from .models import Session 5 | 6 | 7 | @register_chore("sessions") 8 | def clear_expired(): 9 | """ 10 | Delete sessions that have expired. 11 | """ 12 | result = Session.objects.filter(expires_at__lt=timezone.now()).delete() 13 | return f"{result[0]} expired sessions deleted" 14 | -------------------------------------------------------------------------------- /plain-sessions/plain/sessions/config.py: -------------------------------------------------------------------------------- 1 | from plain.packages import PackageConfig, register_config 2 | 3 | 4 | @register_config 5 | class Config(PackageConfig): 6 | package_label = "plainsessions" 7 | -------------------------------------------------------------------------------- /plain-sessions/plain/sessions/default_settings.py: -------------------------------------------------------------------------------- 1 | # Cookie name. This can be whatever you want. 2 | SESSION_COOKIE_NAME = "sessionid" 3 | # Age of cookie, in seconds (default: 2 weeks). 4 | SESSION_COOKIE_AGE = 60 * 60 * 24 * 7 * 2 5 | # A string like "example.com", or None for standard domain cookie. 6 | SESSION_COOKIE_DOMAIN = None 7 | # Whether the session cookie should be secure (https:// only). 8 | SESSION_COOKIE_SECURE = True 9 | # The path of the session cookie. 10 | SESSION_COOKIE_PATH = "/" 11 | # Whether to use the HttpOnly flag. 12 | SESSION_COOKIE_HTTPONLY = True 13 | # Whether to set the flag restricting cookie leaks on cross-site requests. 14 | # This can be 'Lax', 'Strict', 'None', or False to disable the flag. 15 | SESSION_COOKIE_SAMESITE = "Lax" 16 | # Whether to save the session data on every request. 17 | SESSION_SAVE_EVERY_REQUEST = False 18 | # Whether a user's session cookie expires when the web browser is closed. 19 | SESSION_EXPIRE_AT_BROWSER_CLOSE = False 20 | -------------------------------------------------------------------------------- /plain-sessions/plain/sessions/exceptions.py: -------------------------------------------------------------------------------- 1 | from plain.exceptions import BadRequest 2 | 3 | 4 | class SessionInterrupted(BadRequest): 5 | """The session was interrupted.""" 6 | 7 | pass 8 | -------------------------------------------------------------------------------- /plain-sessions/plain/sessions/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from plain import models 2 | from plain.models import migrations 3 | 4 | 5 | class Migration(migrations.Migration): 6 | dependencies = [] 7 | 8 | operations = [ 9 | migrations.CreateModel( 10 | name="Session", 11 | fields=[ 12 | ( 13 | "session_key", 14 | models.CharField( 15 | max_length=40, 16 | primary_key=True, 17 | ), 18 | ), 19 | ("session_data", models.TextField()), 20 | ( 21 | "expire_date", 22 | models.DateTimeField(), 23 | ), 24 | ], 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /plain-sessions/plain/sessions/migrations/0003_alter_session_expire_date_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.32.0 on 2025-03-10 15:48 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ( 10 | "plainsessions", 11 | "0002_alter_session_options_alter_session_expire_date_and_more", 12 | ), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterField( 17 | model_name="session", 18 | name="expire_date", 19 | field=models.DateTimeField(), 20 | ), 21 | migrations.AddIndex( 22 | model_name="session", 23 | index=models.Index( 24 | fields=["expire_date"], name="plainsessio_expire__b3ffa6_idx" 25 | ), 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /plain-sessions/plain/sessions/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-sessions/plain/sessions/migrations/__init__.py -------------------------------------------------------------------------------- /plain-sessions/plain/sessions/templates/toolbar/session.html: -------------------------------------------------------------------------------- 1 |
2 |
Session ID
3 |
{{ request.session.session_key }}
4 | 5 | {% for k, v in request.session.items() %} 6 |
session["{{ k }}"]
7 |
8 | {% if v is iterable and not v|string %} 9 |
{{ v }}
10 | {% else %} 11 | {{ v }} 12 | {% endif %} 13 |
14 | {% endfor %} 15 |
16 | -------------------------------------------------------------------------------- /plain-sessions/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.sessions" 3 | version = "0.21.0" 4 | description = "Session management for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "plain<1.0.0", 10 | ] 11 | 12 | [tool.uv] 13 | dev-dependencies = [ 14 | "plain.models<1.0.0", 15 | "plain.pytest<1.0.0", 16 | ] 17 | 18 | [tool.hatch.build.targets.wheel] 19 | packages = ["plain"] 20 | 21 | [build-system] 22 | requires = ["hatchling"] 23 | build-backend = "hatchling.build" 24 | -------------------------------------------------------------------------------- /plain-sessions/tests/app/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "test" 2 | URLS_ROUTER = "app.urls.AppRouter" 3 | INSTALLED_PACKAGES = [ 4 | "plain.models", 5 | "plain.sessions", 6 | ] 7 | DATABASES = { 8 | "default": { 9 | "ENGINE": "plain.models.backends.sqlite3", 10 | "NAME": ":memory:", 11 | } 12 | } 13 | MIDDLEWARE = [ 14 | "plain.sessions.middleware.SessionMiddleware", 15 | ] 16 | -------------------------------------------------------------------------------- /plain-sessions/tests/app/urls.py: -------------------------------------------------------------------------------- 1 | from plain.urls import Router, path 2 | from plain.views import View 3 | 4 | 5 | class IndexView(View): 6 | def get(self): 7 | # Store something so the session is saved 8 | self.request.session["foo"] = "bar" 9 | return "test" 10 | 11 | 12 | class AppRouter(Router): 13 | namespace = "" 14 | urls = [ 15 | path("", IndexView), 16 | ] 17 | -------------------------------------------------------------------------------- /plain-sessions/tests/test_sessions.py: -------------------------------------------------------------------------------- 1 | from plain.sessions.models import Session 2 | from plain.test import Client 3 | 4 | 5 | def test_session_created(db): 6 | assert Session.objects.count() == 0 7 | 8 | response = Client().get("/") 9 | 10 | assert response.status_code == 200 11 | 12 | assert Session.objects.count() == 1 13 | -------------------------------------------------------------------------------- /plain-support/README.md: -------------------------------------------------------------------------------- 1 | ./plain/support/README.md -------------------------------------------------------------------------------- /plain-support/plain/support/README.md: -------------------------------------------------------------------------------- 1 | # plain-support 2 | 3 | Provides support forms for your application. 4 | 5 | ## Usage 6 | 7 | Include the support URLs in your `urls.py`: 8 | 9 | ```python 10 | # app/urls.py 11 | from plain.urls import include, path 12 | import plain.support.urls 13 | 14 | urlpatterns = [ 15 | path("support/", include(plain.support.urls)), 16 | # ... 17 | ] 18 | ``` 19 | 20 | ## Security considerations 21 | 22 | Most support forms allow you to type in an email address. Be careful, because anybody can pretend to be anybody else at this point. Conversations either need to continue over email (which confirms they have access to the email account), or include a verification step (emailing a code to the email address, for example). 23 | -------------------------------------------------------------------------------- /plain-support/plain/support/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-support/plain/support/__init__.py -------------------------------------------------------------------------------- /plain-support/plain/support/admin.py: -------------------------------------------------------------------------------- 1 | from plain.admin.cards import Card 2 | from plain.admin.views import ( 3 | AdminModelDetailView, 4 | AdminModelListView, 5 | AdminViewset, 6 | register_viewset, 7 | ) 8 | 9 | from .models import SupportFormEntry 10 | 11 | 12 | @register_viewset 13 | class SupportFormEntryAdmin(AdminViewset): 14 | class ListView(AdminModelListView): 15 | model = SupportFormEntry 16 | nav_section = "Support" 17 | title = "Form entries" 18 | fields = ["user", "email", "name", "form_slug", "created_at"] 19 | 20 | class DetailView(AdminModelDetailView): 21 | model = SupportFormEntry 22 | 23 | 24 | class UserSupportFormEntriesCard(Card): 25 | title = "Recent support" 26 | template_name = "support/card.html" 27 | 28 | def get_template_context(self): 29 | context = super().get_template_context() 30 | 31 | context["entries"] = SupportFormEntry.objects.filter(user=self.view.object) 32 | 33 | return context 34 | -------------------------------------------------------------------------------- /plain-support/plain/support/assets/support/iframe.js: -------------------------------------------------------------------------------- 1 | document.addEventListener("DOMContentLoaded", () => { 2 | function sendHeight(height) { 3 | window.parent.postMessage({ type: "setHeight", height: height }, "*"); 4 | } 5 | 6 | let lastHeight = 0; 7 | 8 | function calculateAndSendHeight() { 9 | const height = document.documentElement.scrollHeight; 10 | if (height !== lastHeight) { 11 | lastHeight = height; 12 | sendHeight(height); 13 | } 14 | } 15 | 16 | // Observe DOM changes 17 | const observer = new MutationObserver(calculateAndSendHeight); 18 | observer.observe(document.body, { childList: true, subtree: true }); 19 | 20 | // Recalculate height on window resize 21 | window.addEventListener("resize", calculateAndSendHeight); 22 | 23 | // Send initial height 24 | calculateAndSendHeight(); 25 | 26 | // Tell the embed.js that we loaded the iframe successfully (no other good way to do this) 27 | window.parent.postMessage({ type: "iframeLoaded" }, "*"); 28 | }); 29 | -------------------------------------------------------------------------------- /plain-support/plain/support/config.py: -------------------------------------------------------------------------------- 1 | from plain.packages import PackageConfig, register_config 2 | 3 | 4 | @register_config 5 | class Config(PackageConfig): 6 | package_label = "plainsupport" 7 | -------------------------------------------------------------------------------- /plain-support/plain/support/default_settings.py: -------------------------------------------------------------------------------- 1 | SUPPORT_FORMS = { 2 | "default": "plain.support.forms.SupportForm", 3 | } 4 | SUPPORT_EMAIL: str 5 | -------------------------------------------------------------------------------- /plain-support/plain/support/migrations/0002_alter_supportformentry_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.15.0 on 2025-01-07 21:37 2 | 3 | from plain.models import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("plainsupport", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="supportformentry", 14 | options={"ordering": ["-created_at"]}, 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /plain-support/plain/support/migrations/0003_alter_supportformentry_uuid_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 0.31.0 on 2025-03-08 21:33 2 | 3 | import uuid 4 | 5 | from plain import models 6 | from plain.models import migrations 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ("plainsupport", "0002_alter_supportformentry_options"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name="supportformentry", 17 | name="uuid", 18 | field=models.UUIDField(default=uuid.uuid4), 19 | ), 20 | migrations.AddConstraint( 21 | model_name="supportformentry", 22 | constraint=models.UniqueConstraint( 23 | fields=("uuid",), name="plainsupport_supportformentry_unique_uuid" 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /plain-support/plain/support/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-support/plain/support/migrations/__init__.py -------------------------------------------------------------------------------- /plain-support/plain/support/templates/email/support_form_entry.html: -------------------------------------------------------------------------------- 1 |
{{ support_form_entry.message }}
2 | -------------------------------------------------------------------------------- /plain-support/plain/support/templates/support/card.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/cards/base.html" %} 2 | 3 | {% block content %} 4 | 5 | 22 | 23 | {% endblock %} 24 | -------------------------------------------------------------------------------- /plain-support/plain/support/templates/support/iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | {% tailwind_css %} 4 | 5 | 6 | 7 | {% if success %} 8 | {% include success_template_name %} 9 | {% else %} 10 | {% include form_template_name %} 11 | {% endif %} 12 | 13 | 14 | -------------------------------------------------------------------------------- /plain-support/plain/support/templates/support/page.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 | 5 | {% if success %} 6 | {% include success_template_name %} 7 | {% else %} 8 | {% include form_template_name %} 9 | {% endif %} 10 | 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /plain-support/plain/support/templates/support/success/default.html: -------------------------------------------------------------------------------- 1 |
Your message has been sent.
2 | -------------------------------------------------------------------------------- /plain-support/plain/support/urls.py: -------------------------------------------------------------------------------- 1 | from plain.urls import Router, path 2 | 3 | from . import views 4 | 5 | 6 | class SupportRouter(Router): 7 | namespace = "support" 8 | urls = [ 9 | path("form/.js", views.SupportFormJSView), 10 | path("form//iframe/", views.SupportIFrameView, name="iframe"), 11 | path("form//", views.SupportFormView, name="form"), 12 | ] 13 | -------------------------------------------------------------------------------- /plain-support/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.support" 3 | version = "0.12.1" 4 | description = "Basic support tools for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "plain<1.0.0", 10 | "plain.models<1.0.0", 11 | "plain.auth<1.0.0", 12 | ] 13 | 14 | [tool.hatch.build.targets.wheel] 15 | packages = ["plain"] 16 | 17 | [build-system] 18 | requires = ["hatchling"] 19 | build-backend = "hatchling.build" 20 | -------------------------------------------------------------------------------- /plain-tailwind/.gitignore: -------------------------------------------------------------------------------- 1 | # Local development files 2 | /.env 3 | /.plain 4 | 5 | # Publishing 6 | /dist 7 | 8 | # Python 9 | /.venv 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # OS files 15 | .DS_Store 16 | -------------------------------------------------------------------------------- /plain-tailwind/README.md: -------------------------------------------------------------------------------- 1 | ./plain/tailwind/README.md -------------------------------------------------------------------------------- /plain-tailwind/plain/tailwind/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli 2 | 3 | __all__ = ["cli"] 4 | -------------------------------------------------------------------------------- /plain-tailwind/plain/tailwind/default_settings.py: -------------------------------------------------------------------------------- 1 | from plain.assets.finders import APP_ASSETS_DIR 2 | from plain.runtime import APP_PATH 3 | 4 | # The tailwind.css source file is stored at the root of the repo, 5 | # where can see all sources in the repo and manually refer to other plain sources. 6 | TAILWIND_SRC_PATH = APP_PATH.parent / "tailwind.css" 7 | 8 | # The compiled css goes in the root assets directory. 9 | # It is typically gitignored. 10 | TAILWIND_DIST_PATH = APP_ASSETS_DIR / "tailwind.min.css" 11 | -------------------------------------------------------------------------------- /plain-tailwind/plain/tailwind/entrypoints.py: -------------------------------------------------------------------------------- 1 | from plain.internal import internalcode 2 | 3 | from .cli import build 4 | 5 | 6 | @internalcode 7 | def run_dev_build(): 8 | # This will run by itself as a command, so it can exit() 9 | build(["--watch"], standalone_mode=True) 10 | 11 | 12 | @internalcode 13 | def run_build(): 14 | # Standalone mode prevents it from exit()ing 15 | build(["--minify"], standalone_mode=False) 16 | -------------------------------------------------------------------------------- /plain-tailwind/plain/tailwind/templates.py: -------------------------------------------------------------------------------- 1 | from plain.assets.finders import APP_ASSETS_DIR 2 | from plain.internal import internalcode 3 | from plain.runtime import settings 4 | from plain.templates import register_template_extension 5 | from plain.templates.jinja.extensions import InclusionTagExtension 6 | 7 | 8 | @internalcode 9 | @register_template_extension 10 | class TailwindCSSExtension(InclusionTagExtension): 11 | tags = {"tailwind_css"} 12 | template_name = "tailwind/css.html" 13 | 14 | def get_context(self, context, *args, **kwargs): 15 | tailwind_css_path = str(settings.TAILWIND_DIST_PATH.relative_to(APP_ASSETS_DIR)) 16 | return {"tailwind_css_path": tailwind_css_path} 17 | -------------------------------------------------------------------------------- /plain-tailwind/plain/tailwind/templates/tailwind/css.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /plain-tailwind/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.tailwind" 3 | version = "0.13.0" 4 | description = "Integrate Tailwind CSS with Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | license = "BSD-3-Clause" 7 | readme = "README.md" 8 | requires-python = ">=3.11" 9 | dependencies = [ 10 | "plain<1.0.0", 11 | "requests>=2.0.0", 12 | "tomlkit>=0.12.1", 13 | ] 14 | 15 | [project.entry-points."plain.dev"] 16 | "tailwind" = "plain.tailwind.entrypoints:run_dev_build" 17 | 18 | [project.entry-points."plain.build"] 19 | "tailwind" = "plain.tailwind.entrypoints:run_build" 20 | 21 | [tool.hatch.build.targets.wheel] 22 | packages = ["plain"] 23 | 24 | [build-system] 25 | requires = ["hatchling"] 26 | build-backend = "hatchling.build" 27 | -------------------------------------------------------------------------------- /plain-tunnel/.gitignore: -------------------------------------------------------------------------------- 1 | # Local development files 2 | /.env 3 | /.plain 4 | 5 | # Publishing 6 | /dist 7 | 8 | # Python 9 | /.venv 10 | __pycache__/ 11 | *.py[cod] 12 | *$py.class 13 | 14 | # OS files 15 | .DS_Store 16 | -------------------------------------------------------------------------------- /plain-tunnel/README.md: -------------------------------------------------------------------------------- 1 | ./plain/tunnel/README.md -------------------------------------------------------------------------------- /plain-tunnel/plain/tunnel/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli 2 | 3 | __all__ = ["cli"] 4 | -------------------------------------------------------------------------------- /plain-tunnel/plain/tunnel/entrypoints.py: -------------------------------------------------------------------------------- 1 | def setup(): 2 | # This package isn't an installed app, 3 | # so we need to trigger our own import and cli registration. 4 | from .cli import cli # noqa 5 | -------------------------------------------------------------------------------- /plain-tunnel/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.tunnel" 3 | version = "0.5.3" 4 | description = "" 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | license = "BSD-3-Clause" 7 | readme = "README.md" 8 | requires-python = ">=3.11" 9 | dependencies = [ 10 | "plain<1.0.0", 11 | "websockets>=14.0", 12 | ] 13 | 14 | # Make it also available as plain-tunnel, 15 | # so tools like pipx and uvx can run it independently 16 | [project.scripts] 17 | "plain-tunnel" = "plain.tunnel.cli:cli" 18 | 19 | [project.entry-points."plain.setup"] 20 | "tunnel-setup" = "plain.tunnel.entrypoints:setup" 21 | 22 | [tool.hatch.build.targets.wheel] 23 | packages = ["plain"] 24 | 25 | [build-system] 26 | requires = ["hatchling"] 27 | build-backend = "hatchling.build" 28 | -------------------------------------------------------------------------------- /plain-tunnel/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "plaintunnel", 3 | "version": "0.0.0", 4 | "devDependencies": { 5 | "@biomejs/biome": "1.9.4", 6 | "wrangler": "3.84.0" 7 | }, 8 | "private": true, 9 | "scripts": { 10 | "dev": "wrangler dev --var LOCALHOST_DEV:true", 11 | "deploy": "wrangler deploy", 12 | "fix": "npx @biomejs/biome check --fix --unsafe worker.js" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /plain-tunnel/server/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "plaintunnel" 2 | main = "worker.js" 3 | compatibility_date = "2023-10-30" # Use the current date 4 | 5 | workers_dev = false # Disable the .workers.dev subdomain 6 | route = { pattern = "*.plaintunnel.com/*", zone_name = "plaintunnel.com" } 7 | 8 | [durable_objects] 9 | bindings = [ 10 | { name = "TUNNEL_NAMESPACE", class_name = "Tunnel" } 11 | ] 12 | 13 | [[migrations]] 14 | tag = "v1" # Should be unique for each entry 15 | new_classes = ["Tunnel"] 16 | -------------------------------------------------------------------------------- /plain-vendor/README.md: -------------------------------------------------------------------------------- 1 | ./plain/vendor/README.md -------------------------------------------------------------------------------- /plain-vendor/plain/vendor/README.md: -------------------------------------------------------------------------------- 1 | # plain.vendor 2 | 3 | Download those CDN scripts and styles. 4 | 5 | ## What about source maps? 6 | 7 | It's fairly common right now to get an error during `plain build` that says it can't find the source map for one of your vendored files. 8 | Right now, the fix is add the source map itself to your vendored dependencies too. 9 | In the future `plain vendor` might discover those during the vendoring process and download them automatically with the compiled files. 10 | -------------------------------------------------------------------------------- /plain-vendor/plain/vendor/__init__.py: -------------------------------------------------------------------------------- 1 | from .cli import cli 2 | 3 | __all__ = ["cli"] 4 | -------------------------------------------------------------------------------- /plain-vendor/plain/vendor/entrypoints.py: -------------------------------------------------------------------------------- 1 | def setup(): 2 | # This package isn't an installed app, 3 | # so we need to trigger our own import and cli registration. 4 | from .cli import cli # noqa 5 | -------------------------------------------------------------------------------- /plain-vendor/plain/vendor/exceptions.py: -------------------------------------------------------------------------------- 1 | class DependencyError(Exception): 2 | pass 3 | 4 | 5 | class UnknownVersionError(DependencyError): 6 | pass 7 | 8 | 9 | class UnknownContentTypeError(DependencyError): 10 | pass 11 | 12 | 13 | class VersionMismatchError(DependencyError): 14 | pass 15 | -------------------------------------------------------------------------------- /plain-vendor/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.vendor" 3 | version = "0.8.2" 4 | description = "Vendor JS/CSS assets in Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | license = "BSD-3-Clause" 7 | readme = "README.md" 8 | requires-python = ">=3.11" 9 | dependencies = [ 10 | "plain<1.0.0", 11 | "tomlkit>=0.12.1", 12 | "requests>=2.0.0", 13 | ] 14 | 15 | # Make the CLI available without adding to INSTALLED_APPS 16 | [project.entry-points."plain.setup"] 17 | "vendor-setup" = "plain.vendor.entrypoints:setup" 18 | 19 | [tool.hatch.build.targets.wheel] 20 | packages = ["plain"] 21 | 22 | [build-system] 23 | requires = ["hatchling"] 24 | build-backend = "hatchling.build" 25 | -------------------------------------------------------------------------------- /plain-worker/.gitignore: -------------------------------------------------------------------------------- 1 | # Local development files 2 | /.env 3 | /.plain 4 | *.sqlite3 5 | 6 | # Publishing 7 | /dist 8 | 9 | # Python 10 | /.venv 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # OS files 16 | .DS_Store 17 | -------------------------------------------------------------------------------- /plain-worker/README.md: -------------------------------------------------------------------------------- 1 | ./plain/worker/README.md -------------------------------------------------------------------------------- /plain-worker/plain/worker/__init__.py: -------------------------------------------------------------------------------- 1 | from .jobs import Job 2 | from .registry import register_job 3 | 4 | __all__ = ["Job", "register_job"] 5 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/chores.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from plain.chores import register_chore 4 | from plain.runtime import settings 5 | from plain.utils import timezone 6 | 7 | from .models import JobResult 8 | 9 | 10 | @register_chore("worker") 11 | def clear_completed(): 12 | """Delete all completed job results in all queues.""" 13 | cutoff = timezone.now() - datetime.timedelta( 14 | seconds=settings.WORKER_JOBS_CLEARABLE_AFTER 15 | ) 16 | results = JobResult.objects.filter(created_at__lt=cutoff).delete() 17 | return f"{results[0]} jobs deleted" 18 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/config.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | from importlib.util import find_spec 3 | 4 | from plain.packages import PackageConfig, packages_registry, register_config 5 | 6 | from .registry import jobs_registry 7 | 8 | JOBS_MODULE_NAME = "jobs" 9 | 10 | 11 | @register_config 12 | class Config(PackageConfig): 13 | package_label = "plainworker" 14 | 15 | def ready(self): 16 | # Trigger register calls to fire by importing the modules 17 | for package_config in packages_registry.get_package_configs(): 18 | module_name = f"{package_config.name}.{JOBS_MODULE_NAME}" 19 | if find_spec(module_name): 20 | import_module(module_name) 21 | 22 | # Also need to make sure out internal jobs are registered 23 | import_module("plain.worker.scheduling") 24 | 25 | jobs_registry.ready = True 26 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/default_settings.py: -------------------------------------------------------------------------------- 1 | WORKER_JOBS_CLEARABLE_AFTER: int = 60 * 60 * 24 * 7 # One week 2 | WORKER_JOBS_LOST_AFTER: int = 60 * 60 * 24 # One day 3 | WORKER_MIDDLEWARE: list[str] = [ 4 | "plain.worker.middleware.AppLoggerMiddleware", 5 | ] 6 | WORKER_JOBS_SCHEDULE: list[tuple[str, str]] = [] 7 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/middleware.py: -------------------------------------------------------------------------------- 1 | from plain.logs import app_logger 2 | 3 | 4 | class AppLoggerMiddleware: 5 | def __init__(self, run_job): 6 | self.run_job = run_job 7 | 8 | def __call__(self, job): 9 | app_logger.kv.context["job_request_uuid"] = str(job.job_request_uuid) 10 | app_logger.kv.context["job_uuid"] = str(job.uuid) 11 | 12 | job_result = self.run_job(job) 13 | 14 | app_logger.kv.context.pop("job_request_uuid", None) 15 | app_logger.kv.context.pop("job_uuid", None) 16 | 17 | return job_result 18 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/migrations/0003_jobresult_status.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 5.0.dev20231226225312 on 2023-12-28 19:28 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainworker", "0002_jobresult_remove_jobrequest_completed_at_and_more"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="jobresult", 15 | name="status", 16 | field=models.CharField( 17 | choices=[ 18 | ("PROCESSING", "Processing"), 19 | ("SUCCESSFUL", "Successful"), 20 | ("ERRORED", "Errored"), 21 | ], 22 | default="PROCESSING", 23 | max_length=20, 24 | ), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/migrations/0004_alter_jobresult_options.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 5.0.dev20231228220106 on 2023-12-28 22:35 2 | 3 | from plain.models import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("plainworker", "0003_jobresult_status"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AlterModelOptions( 13 | name="jobresult", 14 | options={"ordering": ["-started_at"]}, 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/migrations/0005_remove_jobresult_updated_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 5.0.dev20231228223551 on 2023-12-28 22:57 2 | 3 | from plain.models import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("plainworker", "0004_alter_jobresult_options"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="jobresult", 14 | name="updated_at", 15 | ), 16 | ] 17 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/migrations/0006_rename_completed_at_jobresult_ended_at.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 5.0.dev20231229213240 on 2023-12-29 22:04 2 | 3 | from plain.models import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("plainworker", "0005_remove_jobresult_updated_at"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RenameField( 13 | model_name="jobresult", 14 | old_name="completed_at", 15 | new_name="ended_at", 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/migrations/0007_alter_jobresult_status.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 5.0.dev20231229213240 on 2023-12-30 02:35 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainworker", "0006_rename_completed_at_jobresult_ended_at"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="jobresult", 15 | name="status", 16 | field=models.CharField( 17 | required=False, 18 | choices=[ 19 | ("", "Unknown"), 20 | ("PROCESSING", "Processing"), 21 | ("SUCCESSFUL", "Successful"), 22 | ("ERRORED", "Errored"), 23 | ], 24 | default="", 25 | max_length=20, 26 | ), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/migrations/0008_jobrequest_source_jobresult_source.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 5.0.dev20240102205958 on 2024-01-02 21:12 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainworker", "0007_alter_jobresult_status"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="jobrequest", 15 | name="source", 16 | field=models.TextField(required=False), 17 | ), 18 | migrations.AddField( 19 | model_name="jobresult", 20 | name="source", 21 | field=models.TextField(required=False), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/migrations/0009_alter_jobresult_status.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 5.0.dev20240109202955 on 2024-01-09 21:36 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainworker", "0008_jobrequest_source_jobresult_source"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="jobresult", 15 | name="status", 16 | field=models.CharField( 17 | required=False, 18 | choices=[ 19 | ("", "Unknown"), 20 | ("PROCESSING", "Processing"), 21 | ("SUCCESSFUL", "Successful"), 22 | ("ERRORED", "Errored"), 23 | ("TIMED_OUT", "Timed out"), 24 | ], 25 | default="", 26 | max_length=20, 27 | ), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/migrations/0010_alter_jobresult_status.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 5.0.dev20240109225803 on 2024-01-09 23:17 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainworker", "0009_alter_jobresult_status"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="jobresult", 15 | name="status", 16 | field=models.CharField( 17 | required=False, 18 | choices=[ 19 | ("", "Unknown"), 20 | ("PROCESSING", "Processing"), 21 | ("SUCCESSFUL", "Successful"), 22 | ("ERRORED", "Errored"), 23 | ("LOST", "Lost"), 24 | ], 25 | default="", 26 | max_length=20, 27 | ), 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/migrations/0016_remove_job_worker_uuid_remove_jobresult_worker_uuid.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 5.0.dev20240121220125 on 2024-01-22 16:53 2 | 3 | from plain.models import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("plainworker", "0015_job_worker_uuid_jobresult_worker_uuid_and_more"), 9 | ] 10 | 11 | operations = [ 12 | migrations.RemoveField( 13 | model_name="job", 14 | name="worker_uuid", 15 | ), 16 | migrations.RemoveField( 17 | model_name="jobresult", 18 | name="worker_uuid", 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/migrations/0017_job_queue_jobrequest_queue_jobresult_queue.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 5.0.dev20240321171720 on 2024-03-21 18:47 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainworker", "0016_remove_job_worker_uuid_remove_jobresult_worker_uuid"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="job", 15 | name="queue", 16 | field=models.CharField(default="default", max_length=255), 17 | ), 18 | migrations.AddField( 19 | model_name="jobrequest", 20 | name="queue", 21 | field=models.CharField(default="default", max_length=255), 22 | ), 23 | migrations.AddField( 24 | model_name="jobresult", 25 | name="queue", 26 | field=models.CharField(default="default", max_length=255), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/migrations/0018_jobrequest_unique_job_class_unique_key.py: -------------------------------------------------------------------------------- 1 | # Generated by Plain 5.0.dev20240514194324 on 2024-05-14 20:03 2 | 3 | from plain import models 4 | from plain.models import migrations 5 | 6 | 7 | class Migration(migrations.Migration): 8 | dependencies = [ 9 | ("plainworker", "0017_job_queue_jobrequest_queue_jobresult_queue"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddConstraint( 14 | model_name="jobrequest", 15 | constraint=models.UniqueConstraint( 16 | condition=models.Q(("retry_attempt", 0), ("unique_key__gt", "")), 17 | fields=("job_class", "unique_key"), 18 | name="unique_job_class_unique_key", 19 | ), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /plain-worker/plain/worker/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain-worker/plain/worker/migrations/__init__.py -------------------------------------------------------------------------------- /plain-worker/plain/worker/templates/admin/plainqueue/jobresult_detail.html: -------------------------------------------------------------------------------- 1 | {% extends "admin/detail.html" %} 2 | 3 | {% block actions %} 4 |
5 | {{ csrf_input }} 6 | 7 | 8 |
9 | {% endblock %} 10 | -------------------------------------------------------------------------------- /plain-worker/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain.worker" 3 | version = "0.22.3" 4 | description = "Background job processing for Plain." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | requires-python = ">=3.11" 8 | dependencies = [ 9 | "plain<1.0.0", 10 | "plain.models<1.0.0", 11 | ] 12 | 13 | [tool.uv] 14 | dev-dependencies = [ 15 | "pytest>=8.0.0", 16 | ] 17 | 18 | [tool.hatch.build.targets.wheel] 19 | packages = ["plain"] 20 | 21 | [build-system] 22 | requires = ["hatchling"] 23 | build-backend = "hatchling.build" 24 | -------------------------------------------------------------------------------- /plain-worker/tests/app/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "test" 2 | -------------------------------------------------------------------------------- /plain/README.md: -------------------------------------------------------------------------------- 1 | ./plain/README.md -------------------------------------------------------------------------------- /plain/plain/__main__.py: -------------------------------------------------------------------------------- 1 | from .cli.core import cli 2 | 3 | # Make the CLI available as `python -m plain` 4 | if __name__ == "__main__": 5 | cli() 6 | -------------------------------------------------------------------------------- /plain/plain/assets/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain/plain/assets/__init__.py -------------------------------------------------------------------------------- /plain/plain/chores/__init__.py: -------------------------------------------------------------------------------- 1 | from .registry import register_chore 2 | 3 | __all__ = ["register_chore"] 4 | -------------------------------------------------------------------------------- /plain/plain/cli/__init__.py: -------------------------------------------------------------------------------- 1 | from .registry import register_cli 2 | 3 | __all__ = ["register_cli"] 4 | -------------------------------------------------------------------------------- /plain/plain/cli/print.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | 4 | def print_event(msg, newline=True): 5 | arrow = click.style("-->", fg=214, bold=True) 6 | message = str(msg) 7 | if not newline: 8 | message += " " 9 | click.secho(f"{arrow} {message}", nl=newline) 10 | -------------------------------------------------------------------------------- /plain/plain/cli/utils.py: -------------------------------------------------------------------------------- 1 | import click 2 | 3 | from plain.utils.crypto import get_random_string 4 | 5 | 6 | @click.group() 7 | def utils(): 8 | pass 9 | 10 | 11 | @utils.command() 12 | def generate_secret_key(): 13 | """Generate a new secret key""" 14 | new_secret_key = get_random_string(50) 15 | click.echo(new_secret_key) 16 | -------------------------------------------------------------------------------- /plain/plain/csrf/README.md: -------------------------------------------------------------------------------- 1 | # CSRF 2 | 3 | **Cross-Site Request Forgery (CSRF) protection.** 4 | 5 | Plain protects against [CSRF attacks](https://en.wikipedia.org/wiki/Cross-site_request_forgery) through a [middleware](middleware.py) that compares the generated `csrftoken` cookie with the CSRF token from the request (either `_csrftoken` in form data or the `CSRF-Token` header). 6 | 7 | ## Usage 8 | 9 | The `CsrfViewMiddleware` is [automatically installed](../internal/handlers/base.py#BUILTIN_BEFORE_MIDDLEWARE), so you don't need to add it to your `settings.MIDDLEWARE`. 10 | 11 | When you use HTML forms, you should include the CSRF token in the form data via a hidden input: 12 | 13 | ```html 14 |
15 | {{ csrf_input }} 16 | 17 |
18 | ``` 19 | -------------------------------------------------------------------------------- /plain/plain/csrf/views.py: -------------------------------------------------------------------------------- 1 | from plain.views import TemplateView 2 | 3 | 4 | class CsrfFailureView(TemplateView): 5 | template_name = "403.html" 6 | 7 | def get_response(self): 8 | response = super().get_response() 9 | response.status_code = 403 10 | return response 11 | 12 | def post(self): 13 | return self.get() 14 | 15 | def put(self): 16 | return self.get() 17 | 18 | def patch(self): 19 | return self.get() 20 | 21 | def delete(self): 22 | return self.get() 23 | 24 | def head(self): 25 | return self.get() 26 | 27 | def options(self): 28 | return self.get() 29 | 30 | def trace(self): 31 | return self.get() 32 | -------------------------------------------------------------------------------- /plain/plain/debug.py: -------------------------------------------------------------------------------- 1 | from pprint import pformat 2 | 3 | from markupsafe import Markup, escape 4 | 5 | from plain.http import Response 6 | from plain.views.exceptions import ResponseException 7 | 8 | 9 | def dd(*objs): 10 | """ 11 | Dump and die. 12 | 13 | Dump the object and raise a ResponseException with the dump as the response content. 14 | """ 15 | 16 | print(f"Dumping objects:\n{'\n'.join([pformat(obj) for obj in objs])}") 17 | 18 | dump_strs = [ 19 | Markup("
") + escape(pformat(obj)) + Markup("
") 20 | for obj in objs 21 | ] 22 | combined_dump_str = Markup("\n\n").join(dump_strs) 23 | 24 | response = Response() 25 | response.status_code = 500 26 | response.content = combined_dump_str 27 | response.content_type = "text/html" 28 | raise ResponseException(response) 29 | -------------------------------------------------------------------------------- /plain/plain/forms/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Plain validation and HTML form handling. 3 | """ 4 | 5 | from .boundfield import BoundField # NOQA 6 | from .exceptions import FormFieldMissingError, ValidationError # NOQA 7 | from .fields import * # NOQA 8 | from .forms import Form # NOQA 9 | -------------------------------------------------------------------------------- /plain/plain/forms/exceptions.py: -------------------------------------------------------------------------------- 1 | from plain.exceptions import ValidationError 2 | 3 | 4 | class FormFieldMissingError(Exception): 5 | def __init__(self, field_name): 6 | self.field_name = field_name 7 | self.message = f'The "{self.field_name}" field is missing from the form data.' 8 | 9 | 10 | __all__ = [ 11 | "ValidationError", 12 | "FormFieldMissingError", 13 | ] 14 | -------------------------------------------------------------------------------- /plain/plain/http/README.md: -------------------------------------------------------------------------------- 1 | # HTTP 2 | 3 | **HTTP request and response handling.** 4 | 5 | Typically you will interact with [request](request.py#HttpRequest) and [response](response.py#ResponseBase) objects in your views and middleware. 6 | 7 | ```python 8 | from plain.views import View 9 | from plain.http import Response 10 | 11 | class ExampleView(View): 12 | def get(self): 13 | # Accessing a request header 14 | print(self.request.headers.get("Example-Header")) 15 | 16 | # Accessing a query parameter 17 | print(self.request.query_params.get("example")) 18 | 19 | # Creating a response 20 | response = Response("Hello, world!", status_code=200) 21 | 22 | # Setting a response header 23 | response.headers["Example-Header"] = "Example Value" 24 | 25 | return response 26 | ``` 27 | -------------------------------------------------------------------------------- /plain/plain/http/cookie.py: -------------------------------------------------------------------------------- 1 | from http import cookies 2 | 3 | 4 | def parse_cookie(cookie): 5 | """ 6 | Return a dictionary parsed from a `Cookie:` header string. 7 | """ 8 | cookiedict = {} 9 | for chunk in cookie.split(";"): 10 | if "=" in chunk: 11 | key, val = chunk.split("=", 1) 12 | else: 13 | # Assume an empty name per 14 | # https://bugzilla.mozilla.org/show_bug.cgi?id=169091 15 | key, val = "", chunk 16 | key, val = key.strip(), val.strip() 17 | if key or val: 18 | # unquote using Python's algorithm. 19 | cookiedict[key] = cookies._unquote(val) 20 | return cookiedict 21 | -------------------------------------------------------------------------------- /plain/plain/internal/__init__.py: -------------------------------------------------------------------------------- 1 | def internalcode(obj): 2 | """ 3 | A decorator that simply marks a class or function as internal. 4 | 5 | Do not rely on @internalcode as a developer! 6 | """ 7 | return obj 8 | -------------------------------------------------------------------------------- /plain/plain/internal/files/__init__.py: -------------------------------------------------------------------------------- 1 | from plain.internal.files.base import File 2 | 3 | __all__ = ["File"] 4 | -------------------------------------------------------------------------------- /plain/plain/internal/handlers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain/plain/internal/handlers/__init__.py -------------------------------------------------------------------------------- /plain/plain/internal/middleware/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain/plain/internal/middleware/__init__.py -------------------------------------------------------------------------------- /plain/plain/logs/__init__.py: -------------------------------------------------------------------------------- 1 | from .configure import configure_logging 2 | from .loggers import app_logger 3 | from .utils import log_response 4 | 5 | __all__ = ["app_logger", "log_response", "configure_logging"] 6 | -------------------------------------------------------------------------------- /plain/plain/packages/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import PackageConfig 2 | from .registry import packages_registry, register_config 3 | 4 | __all__ = ["PackageConfig", "packages_registry", "register_config"] 5 | -------------------------------------------------------------------------------- /plain/plain/preflight/__init__.py: -------------------------------------------------------------------------------- 1 | from .messages import ( 2 | CRITICAL, 3 | DEBUG, 4 | ERROR, 5 | INFO, 6 | WARNING, 7 | CheckMessage, 8 | Critical, 9 | Debug, 10 | Error, 11 | Info, 12 | Warning, 13 | ) 14 | from .registry import register_check, run_checks 15 | 16 | # Import these to force registration of checks 17 | import plain.preflight.files # NOQA isort:skip 18 | import plain.preflight.security # NOQA isort:skip 19 | import plain.preflight.urls # NOQA isort:skip 20 | 21 | 22 | __all__ = [ 23 | "CheckMessage", 24 | "Debug", 25 | "Info", 26 | "Warning", 27 | "Error", 28 | "Critical", 29 | "DEBUG", 30 | "INFO", 31 | "WARNING", 32 | "ERROR", 33 | "CRITICAL", 34 | "register_check", 35 | "run_checks", 36 | ] 37 | -------------------------------------------------------------------------------- /plain/plain/preflight/files.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from plain.runtime import settings 4 | 5 | from . import Error, register_check 6 | 7 | 8 | @register_check 9 | def check_setting_file_upload_temp_dir(package_configs, **kwargs): 10 | setting = getattr(settings, "FILE_UPLOAD_TEMP_DIR", None) 11 | if setting and not Path(setting).is_dir(): 12 | return [ 13 | Error( 14 | f"The FILE_UPLOAD_TEMP_DIR setting refers to the nonexistent " 15 | f"directory '{setting}'.", 16 | id="files.E001", 17 | ), 18 | ] 19 | return [] 20 | -------------------------------------------------------------------------------- /plain/plain/signals/README.md: -------------------------------------------------------------------------------- 1 | # Signals 2 | 3 | **Run code when certain events happen.** 4 | 5 | ```python 6 | from plain.signals import request_finished 7 | 8 | 9 | def on_request_finished(sender, **kwargs): 10 | print("Request finished!") 11 | 12 | 13 | request_finished.connect(on_request_finished) 14 | ``` 15 | -------------------------------------------------------------------------------- /plain/plain/signals/__init__.py: -------------------------------------------------------------------------------- 1 | from plain.signals.dispatch import Signal 2 | 3 | request_started = Signal() 4 | request_finished = Signal() 5 | got_request_exception = Signal() 6 | -------------------------------------------------------------------------------- /plain/plain/signals/dispatch/__init__.py: -------------------------------------------------------------------------------- 1 | """Multi-consumer multi-producer dispatching mechanism 2 | 3 | Originally based on pydispatch (BSD) https://pypi.org/project/PyDispatcher/2.0.1/ 4 | See license.txt for original license. 5 | 6 | Heavily modified for Plain's purposes. 7 | """ 8 | 9 | from plain.signals.dispatch.dispatcher import Signal, receiver # NOQA 10 | -------------------------------------------------------------------------------- /plain/plain/templates/__init__.py: -------------------------------------------------------------------------------- 1 | from .core import Template, TemplateFileMissing 2 | from .jinja import ( 3 | register_template_extension, 4 | register_template_filter, 5 | register_template_global, 6 | ) 7 | 8 | __all__ = [ 9 | "Template", 10 | "TemplateFileMissing", 11 | # Technically these are jinja-specific, 12 | # but expected to be used pretty frequently so 13 | # the shorter import is handy. 14 | "register_template_extension", 15 | "register_template_filter", 16 | "register_template_global", 17 | ] 18 | -------------------------------------------------------------------------------- /plain/plain/templates/core.py: -------------------------------------------------------------------------------- 1 | import jinja2 2 | 3 | from .jinja import environment 4 | 5 | 6 | class TemplateFileMissing(Exception): 7 | def __str__(self) -> str: 8 | if self.args: 9 | return f"Template file {self.args[0]} not found" 10 | else: 11 | return "Template file not found" 12 | 13 | 14 | class Template: 15 | def __init__(self, filename: str) -> None: 16 | self.filename = filename 17 | 18 | try: 19 | self._jinja_template = environment.get_template(filename) 20 | except jinja2.TemplateNotFound: 21 | raise TemplateFileMissing(filename) 22 | 23 | def render(self, context: dict) -> str: 24 | return self._jinja_template.render(context) 25 | -------------------------------------------------------------------------------- /plain/plain/templates/jinja/globals.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | 3 | from plain.paginator import Paginator 4 | from plain.urls import reverse 5 | from plain.utils import timezone 6 | 7 | 8 | def asset(url_path): 9 | # An explicit callable we can control, but also delay the import of asset.urls->views->templates 10 | # for circular import reasons 11 | from plain.assets.urls import get_asset_url 12 | 13 | return get_asset_url(url_path) 14 | 15 | 16 | default_globals = { 17 | "asset": asset, 18 | "url": reverse, 19 | "Paginator": Paginator, 20 | "now": timezone.now, 21 | "timedelta": timedelta, 22 | "localtime": timezone.localtime, 23 | } 24 | -------------------------------------------------------------------------------- /plain/plain/test/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import Client, RequestFactory 2 | 3 | __all__ = [ 4 | "Client", 5 | "RequestFactory", 6 | ] 7 | -------------------------------------------------------------------------------- /plain/plain/test/exceptions.py: -------------------------------------------------------------------------------- 1 | class RedirectCycleError(Exception): 2 | """The test client has been asked to follow a redirect loop.""" 3 | 4 | def __init__(self, message, last_response): 5 | super().__init__(message) 6 | self.last_response = last_response 7 | self.redirect_chain = last_response.redirect_chain 8 | -------------------------------------------------------------------------------- /plain/plain/urls/__init__.py: -------------------------------------------------------------------------------- 1 | from .converters import register_converter 2 | from .exceptions import NoReverseMatch, Resolver404 3 | from .patterns import URLPattern 4 | from .resolvers import ( 5 | ResolverMatch, 6 | URLResolver, 7 | get_resolver, 8 | ) 9 | from .routers import Router, include, path 10 | from .utils import ( 11 | reverse, 12 | reverse_lazy, 13 | ) 14 | 15 | __all__ = [ 16 | "NoReverseMatch", 17 | "URLPattern", 18 | "URLResolver", 19 | "Resolver404", 20 | "ResolverMatch", 21 | "get_resolver", 22 | "include", 23 | "path", 24 | "register_converter", 25 | "reverse", 26 | "reverse_lazy", 27 | "Router", 28 | ] 29 | -------------------------------------------------------------------------------- /plain/plain/urls/exceptions.py: -------------------------------------------------------------------------------- 1 | from plain.http import Http404 2 | 3 | 4 | class Resolver404(Http404): 5 | pass 6 | 7 | 8 | class NoReverseMatch(Exception): 9 | pass 10 | -------------------------------------------------------------------------------- /plain/plain/utils/README.md: -------------------------------------------------------------------------------- 1 | # Utilities 2 | 3 | **Various utilities for text manipulation, parsing, dates, and more.** 4 | 5 | The utilities aren't going to be documented in detail here. Take a look at the source code for more information. 6 | -------------------------------------------------------------------------------- /plain/plain/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain/plain/utils/__init__.py -------------------------------------------------------------------------------- /plain/plain/utils/decorators.py: -------------------------------------------------------------------------------- 1 | "Functions that help with dynamically creating decorators for views." 2 | 3 | 4 | class classonlymethod(classmethod): 5 | def __get__(self, instance, cls=None): 6 | if instance is not None: 7 | raise AttributeError( 8 | "This method is available only on the class, not on instances." 9 | ) 10 | return super().__get__(instance, cls) 11 | -------------------------------------------------------------------------------- /plain/plain/utils/hashable.py: -------------------------------------------------------------------------------- 1 | from plain.utils.itercompat import is_iterable 2 | 3 | 4 | def make_hashable(value): 5 | """ 6 | Attempt to make value hashable or raise a TypeError if it fails. 7 | 8 | The returned value should generate the same hash for equal values. 9 | """ 10 | if isinstance(value, dict): 11 | return tuple( 12 | [ 13 | (key, make_hashable(nested_value)) 14 | for key, nested_value in sorted(value.items()) 15 | ] 16 | ) 17 | # Try hash to avoid converting a hashable iterable (e.g. string, frozenset) 18 | # to a tuple. 19 | try: 20 | hash(value) 21 | except TypeError: 22 | if is_iterable(value): 23 | return tuple(map(make_hashable, value)) 24 | # Non-hashable, non-iterable. 25 | raise 26 | return value 27 | -------------------------------------------------------------------------------- /plain/plain/utils/itercompat.py: -------------------------------------------------------------------------------- 1 | def is_iterable(x): 2 | "An implementation independent way of checking for iterables" 3 | try: 4 | iter(x) 5 | except TypeError: 6 | return False 7 | else: 8 | return True 9 | -------------------------------------------------------------------------------- /plain/plain/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import View 2 | from .forms import FormView 3 | from .objects import CreateView, DeleteView, DetailView, ListView, UpdateView 4 | from .redirect import RedirectView 5 | from .templates import TemplateView 6 | 7 | __all__ = [ 8 | "View", 9 | "TemplateView", 10 | "RedirectView", 11 | "FormView", 12 | "DetailView", 13 | "CreateView", 14 | "UpdateView", 15 | "DeleteView", 16 | "ListView", 17 | "AuthViewMixin", 18 | ] 19 | -------------------------------------------------------------------------------- /plain/plain/views/csrf.py: -------------------------------------------------------------------------------- 1 | class CsrfExemptViewMixin: 2 | def setup(self, *args, **kwargs): 3 | super().setup(*args, **kwargs) 4 | self.request.csrf_exempt = True 5 | -------------------------------------------------------------------------------- /plain/plain/views/exceptions.py: -------------------------------------------------------------------------------- 1 | class ResponseException(Exception): 2 | def __init__(self, response): 3 | self.response = response 4 | super().__init__(response) 5 | -------------------------------------------------------------------------------- /plain/plain/wsgi.py: -------------------------------------------------------------------------------- 1 | import plain.runtime 2 | from plain.internal.handlers.wsgi import WSGIHandler 3 | 4 | 5 | def _get_wsgi_application(): 6 | plain.runtime.setup() 7 | return WSGIHandler() 8 | 9 | 10 | # The default `plain.wsgi:app` WSGI application 11 | app = _get_wsgi_application() 12 | -------------------------------------------------------------------------------- /plain/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "plain" 3 | version = "0.46.0" 4 | description = "A web framework for building products with Python." 5 | authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}] 6 | readme = "README.md" 7 | dependencies = [ 8 | "jinja2>=3.1.2", 9 | "click>=8.0.0", 10 | ] 11 | requires-python = ">=3.11" 12 | 13 | [project.scripts] 14 | plain = "plain.cli.core:cli" 15 | 16 | [tool.uv] 17 | dev-dependencies = [ 18 | "pytest>=8.0.0", 19 | ] 20 | 21 | [tool.hatch.build.targets.wheel] 22 | packages = ["plain"] 23 | 24 | [build-system] 25 | requires = ["hatchling"] 26 | build-backend = "hatchling.build" 27 | -------------------------------------------------------------------------------- /plain/tests/.gitignore: -------------------------------------------------------------------------------- 1 | /.coverage 2 | /htmlcov 3 | .plain 4 | -------------------------------------------------------------------------------- /plain/tests/app/.gitignore: -------------------------------------------------------------------------------- 1 | assets_collected 2 | -------------------------------------------------------------------------------- /plain/tests/app/settings.py: -------------------------------------------------------------------------------- 1 | SECRET_KEY = "secret" 2 | DEBUG = True 3 | 4 | URLS_ROUTER = "app.urls.AppRouter" 5 | 6 | INSTALLED_PACKAGES = [ 7 | "app.test", 8 | ] 9 | 10 | EXPLICIT_SETTING = "explicitly changed" 11 | ENV_OVERRIDDEN_SETTING = "explicitly overridden" 12 | -------------------------------------------------------------------------------- /plain/tests/app/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dropseed/plain/4eac08993d124ddde9b940a549dede1ae5f6f2c5/plain/tests/app/test/__init__.py -------------------------------------------------------------------------------- /plain/tests/app/test/default_settings.py: -------------------------------------------------------------------------------- 1 | DEFAULT_SETTING: str = "unchanged default" 2 | EXPLICIT_SETTING: str = "unchanged explicit" 3 | ENV_SETTING: int = 0 4 | ENV_OVERRIDDEN_SETTING: str = "unchanged env overridden" 5 | -------------------------------------------------------------------------------- /plain/tests/app/urls.py: -------------------------------------------------------------------------------- 1 | from plain.urls import Router, path 2 | from plain.views import View 3 | 4 | 5 | class TestView(View): 6 | def get(self): 7 | return "Hello, world!" 8 | 9 | 10 | class AppRouter(Router): 11 | namespace = "" 12 | urls = [ 13 | path("", TestView), 14 | ] 15 | -------------------------------------------------------------------------------- /plain/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def pytest_configure(config): 5 | os.environ["PLAIN_ENV_SETTING"] = "1" 6 | os.environ["PLAIN_ENV_OVERRIDDEN_SETTING"] = "env value" 7 | os.environ["PLAIN_UNDEFINED_SETTING"] = "not used" 8 | -------------------------------------------------------------------------------- /plain/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from click.testing import CliRunner 2 | 3 | from plain.cli.core import cli 4 | 5 | 6 | def test_plain_cli_help(): 7 | runner = CliRunner() 8 | result = runner.invoke(cli, ["--help"], prog_name="plain") 9 | assert result.exit_code == 0 10 | assert result.output.startswith("Usage: plain") 11 | 12 | 13 | def test_plain_cli_build(): 14 | runner = CliRunner() 15 | result = runner.invoke(cli, ["build"], prog_name="plain") 16 | assert result.exit_code == 0 17 | assert "Compiled 0 assets into 0 files" in result.output 18 | -------------------------------------------------------------------------------- /plain/tests/test_runtime.py: -------------------------------------------------------------------------------- 1 | from plain.runtime import settings 2 | 3 | 4 | def test_user_settings(): 5 | # Relies on env vars in conftest.py 6 | assert settings.DEFAULT_SETTING == "unchanged default" 7 | assert settings.EXPLICIT_SETTING == "explicitly changed" 8 | assert settings.ENV_SETTING == 1 9 | assert settings.ENV_OVERRIDDEN_SETTING == "explicitly overridden" 10 | -------------------------------------------------------------------------------- /plain/tests/test_wsgi.py: -------------------------------------------------------------------------------- 1 | from io import BytesIO 2 | 3 | from plain.internal.handlers.wsgi import WSGIHandler 4 | 5 | 6 | def test_wsgi_handler(): 7 | """ 8 | Test the default plain.wsgi.app import and 9 | basic behavior with minimal environ input. 10 | """ 11 | 12 | wsgi = WSGIHandler() 13 | response = wsgi( 14 | { 15 | "REQUEST_METHOD": "GET", 16 | "wsgi.input": BytesIO(b""), 17 | "wsgi.url_scheme": "https", 18 | }, 19 | lambda *args: None, 20 | ) 21 | 22 | assert response.status_code == 200 23 | assert response.content == b"Hello, world!" 24 | -------------------------------------------------------------------------------- /scripts/fix: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | uv run --package plain-code --isolated plain-code fix . "$@" 3 | 4 | # Format all markdown files 5 | npx prettier '**/*.md' --embedded-language-formatting off --tab-width 4 --write 6 | -------------------------------------------------------------------------------- /scripts/install: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | for package in plain*; 4 | do 5 | echo "" 6 | echo "${BOLD}Installing dependencies for $package${NORMAL}" 7 | cd "$package" 8 | uv sync 9 | cd .. 10 | done 11 | 12 | for demo in demos/*; 13 | do 14 | echo "" 15 | echo "${BOLD}Installing dependencies for $demo${NORMAL}" 16 | cd "$demo" 17 | ./scripts/install 18 | cd ../.. 19 | done 20 | 21 | if [ ! -f .git/hooks/pre-commit ]; then 22 | echo "" 23 | echo "${BOLD}Installing git pre-commit hook${NORMAL}" 24 | cp scripts/pre-commit .git/hooks/pre-commit 25 | fi 26 | -------------------------------------------------------------------------------- /scripts/llms-full: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | from pathlib import Path 5 | 6 | repo_path = Path(__file__).resolve().parent.parent 7 | plain_path = Path(__file__).resolve().parent.parent / "plain" 8 | sys.path.insert(0, str(plain_path)) 9 | 10 | package_dirs = [repo_path / x / "plain" for x in os.listdir(repo_path) if os.path.isdir(repo_path / x) and x.startswith("plain")] 11 | 12 | from plain.cli.docs import LLMDocs 13 | 14 | docs = LLMDocs(package_dirs) 15 | docs.load() 16 | docs.print(relative_to=repo_path) 17 | -------------------------------------------------------------------------------- /scripts/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | BOLD=$(tput bold) 3 | NORMAL=$(tput sgr0) 4 | 5 | echo "${BOLD}Checking with plain-code${NORMAL}" 6 | uv run --package plain-code --isolated plain-code check . 7 | 8 | echo "" 9 | echo "${BOLD}Running tests${NORMAL}" 10 | ./scripts/test 11 | -------------------------------------------------------------------------------- /scripts/publish: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | for package_dir in plain*; do 4 | rm -rf dist # Clear the previous dist (which uv publish looks at) 5 | uv build --package "$package_dir" # Build just this package 6 | uv publish || true # Continue even if publish fails 7 | done 8 | -------------------------------------------------------------------------------- /scripts/test: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | workflow=".github/workflows/test.yml" 4 | packages=$(grep 'PACKAGES:' "$workflow" | sed -E 's/^.*PACKAGES: *//') 5 | 6 | bold() { 7 | echo "\033[1m$1\033[0m" 8 | } 9 | 10 | bold "Found packages to test in $workflow: $packages" 11 | 12 | for package in $packages; 13 | do 14 | echo 15 | bold "Testing $package" 16 | cd "$package/tests" 17 | uv run --package "$package" --isolated pytest "$@" 18 | cd ../.. 19 | done 20 | 21 | for demo in demos/*; do 22 | echo 23 | bold "Testing $demo" 24 | cd "$demo" 25 | uv run pytest "$@" 26 | cd .. 27 | done 28 | -------------------------------------------------------------------------------- /scripts/vulture: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | uvx vulture . --exclude .venv --ignore-decorators "@register*,@cli*" 3 | --------------------------------------------------------------------------------