plain.support 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,13 @@
1
+ .venv
2
+ .env
3
+ *.egg-info
4
+ *.py[co]
5
+ __pycache__
6
+ *.DS_Store
7
+ .coverage
8
+
9
+ # Build files from publish
10
+ plain*/dist/
11
+
12
+ # Test apps
13
+ plain*/tests/.plain
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: plain.support
3
+ Version: 0.1.0
4
+ Summary: Basic support tools for Plain.
5
+ Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: plain-auth<1.0.0
8
+ Requires-Dist: plain-models<1.0.0
9
+ Requires-Dist: plain<1.0.0
10
+ Description-Content-Type: text/markdown
11
+
12
+ <!-- This file is compiled from plain-support/plain/support/README.md. Do not edit this file directly. -->
13
+
14
+ # plain-support
15
+
16
+ Captcha...
17
+
18
+ ## Security considerations
19
+
20
+ Most support forms allow you to type in an email address. Be careful, because anybody can pretend to be anybody else at this point. Converations 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).
@@ -0,0 +1,9 @@
1
+ <!-- This file is compiled from plain-support/plain/support/README.md. Do not edit this file directly. -->
2
+
3
+ # plain-support
4
+
5
+ Captcha...
6
+
7
+ ## Security considerations
8
+
9
+ Most support forms allow you to type in an email address. Be careful, because anybody can pretend to be anybody else at this point. Converations 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).
@@ -0,0 +1,7 @@
1
+ # plain-support
2
+
3
+ Captcha...
4
+
5
+ ## Security considerations
6
+
7
+ Most support forms allow you to type in an email address. Be careful, because anybody can pretend to be anybody else at this point. Converations 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).
@@ -0,0 +1,87 @@
1
+ var container = document.createElement('div');
2
+ document.currentScript.parentNode.insertBefore(container, document.currentScript);
3
+
4
+ // Build the iframe url based on the script src
5
+ // (replace the .js extension with /iframe/)
6
+ var src = document.currentScript.src;
7
+ var origin = new URL(src).origin;
8
+ var iframeSrc = src.replace(/\.js$/, '/iframe/');
9
+
10
+ var iframe = document.createElement('iframe');
11
+ iframe.src = iframeSrc;
12
+ iframe.width = "100%";
13
+ iframe.height = "auto";
14
+ iframe.style.border = "none";
15
+ iframe.style.display = 'none';
16
+
17
+ // Insert or select a loading div
18
+ var loading;
19
+ var loadingData = document.currentScript.getAttribute('data-loading');
20
+ if (!loadingData) {
21
+ var svg = '<svg width="40px" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"><circle fill="none" stroke-opacity="1" stroke="#000000" stroke-width=".5" cx="100" cy="100" r="0"><animate attributeName="r" calcMode="spline" dur="1" values="1;80" keyTimes="0;1" keySplines="0 .2 .5 1" repeatCount="indefinite"></animate><animate attributeName="stroke-width" calcMode="spline" dur="1" values="0;25" keyTimes="0;1" keySplines="0 .2 .5 1" repeatCount="indefinite"></animate><animate attributeName="stroke-opacity" calcMode="spline" dur="1" values="1;0" keyTimes="0;1" keySplines="0 .2 .5 1" repeatCount="indefinite"></animate></circle></svg>';
22
+ loading = document.createElement('div');
23
+ loading.style.display = 'flex';
24
+ loading.style.justifyContent = 'center';
25
+ loading.innerHTML = svg;
26
+ } else if (loadingData.startsWith("#")) {
27
+ loading = document.querySelector(loadingData);
28
+ } else if (!loading) {
29
+ loading = document.createElement('div');
30
+ loading.textContent = loadingData;
31
+ loading.style.color = "black";
32
+ }
33
+ container.appendChild(loading);
34
+
35
+ // Insert or select an error div
36
+ var error;
37
+ var errorData = document.currentScript.getAttribute('data-error');
38
+ if (!errorData) {
39
+ error = document.createElement('div');
40
+ error.textContent = "There was an error. Please email us directly.";
41
+ error.style.color = "black";
42
+ error.style.display = 'none';
43
+ } else if (errorData.startsWith("#")) {
44
+ error = document.querySelector(errorData);
45
+ error.style.display = 'none';
46
+ } else {
47
+ error = document.createElement('div');
48
+ error.textContent = errorData;
49
+ error.style.color = "black";
50
+ error.style.display = 'none';
51
+ }
52
+ container.appendChild(error);
53
+
54
+ // Keep track of whether the iframe has told us it is loaded
55
+ var iframeLoaded = false;
56
+
57
+ // Listen for postMessage events from the iframe
58
+ window.addEventListener('message', (event) => {
59
+ if (event.origin !== origin) {
60
+ return;
61
+ }
62
+
63
+ if (event.data.type === 'setHeight') {
64
+ iframe.style.height = `${event.data.height}px`;
65
+ } else if (event.data.type === 'iframeLoaded') {
66
+ // The iframe has loaded, show it
67
+ iframe.style.display = 'block';
68
+ loading.style.display = 'none';
69
+
70
+ iframeLoaded = true;
71
+
72
+ error.style.display = 'none';
73
+ loading.style.display = 'none';
74
+ iframe.style.display = 'block';
75
+ }
76
+ });
77
+
78
+ container.appendChild(iframe);
79
+
80
+ // If the iframe hasn't send a loaded message after 5 seconds, render an error emssage
81
+ setTimeout(() => {
82
+ if (!iframeLoaded) {
83
+ error.style.display = 'block';
84
+ loading.style.display = 'none';
85
+ iframe.style.display = 'none';
86
+ }
87
+ }, 5000);
@@ -0,0 +1,28 @@
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
+ });
@@ -0,0 +1,6 @@
1
+ from plain.packages import PackageConfig
2
+
3
+
4
+ class Config(PackageConfig):
5
+ name = "plain.support"
6
+ label = "plainsupport"
@@ -0,0 +1,4 @@
1
+ SUPPORT_FORMS = {
2
+ "default": "plain.support.forms.SupportForm",
3
+ }
4
+ SUPPORT_EMAIL: str
@@ -0,0 +1,66 @@
1
+ from plain.auth import get_user_model
2
+ from plain.mail import TemplateEmail
3
+ from plain.models.forms import ModelForm
4
+ from plain.runtime import settings
5
+
6
+ from .models import SupportFormEntry
7
+
8
+
9
+ class SupportForm(ModelForm):
10
+ """
11
+ The form is the customization point for users.
12
+ So any behavior modifications should be possible here.
13
+ """
14
+
15
+ class Meta:
16
+ model = SupportFormEntry
17
+ fields = ["name", "email", "message"]
18
+
19
+ def __init__(self, user, form_slug, *args, **kwargs):
20
+ super().__init__(*args, **kwargs)
21
+ self.user = user # User provided directly by authed request
22
+ self.form_slug = form_slug
23
+ if self.user:
24
+ self.fields["email"].initial = user.email
25
+
26
+ def find_user(self):
27
+ # If the user isn't logged in (typical in an iframe, depending on session cookie settings),
28
+ # we can still try to look them up by email
29
+ # to associate the entry with them.
30
+ #
31
+ # Note that since they aren't logged in, this doesn't necessarily
32
+ # confirm that this wasn't an impersonation attempt.
33
+ # Subsequent conversations over email will confirm that they have access to the email.
34
+ email = self.cleaned_data.get("email")
35
+ if not email:
36
+ return None
37
+ UserModel = get_user_model()
38
+ try:
39
+ return UserModel.objects.get(email=email)
40
+ except UserModel.DoesNotExist:
41
+ return
42
+
43
+ def save(self, commit=True):
44
+ instance = super().save(commit=False)
45
+ instance.user = self.user or self.find_user()
46
+ instance.form_slug = self.form_slug
47
+ if commit:
48
+ instance.save()
49
+ return instance
50
+
51
+ def notify(self, instance):
52
+ """
53
+ Notify the support team of a new support form entry.
54
+
55
+ Sends an immediate email by default.
56
+ """
57
+ email = TemplateEmail(
58
+ template="support_form_entry",
59
+ subject=f"Support request from {instance.name}",
60
+ to=[settings.SUPPORT_EMAIL],
61
+ reply_to=[instance.email],
62
+ context={
63
+ "support_form_entry": instance,
64
+ },
65
+ )
66
+ email.send()
@@ -0,0 +1,43 @@
1
+ # Generated by Plain 0.15.0 on 2025-01-07 18:45
2
+
3
+ import uuid
4
+
5
+ import plain.models.deletion
6
+ from plain import models
7
+ from plain.models import migrations
8
+
9
+
10
+ class Migration(migrations.Migration):
11
+ initial = True
12
+
13
+ dependencies = [
14
+ ("users", "0001_reset"),
15
+ ]
16
+
17
+ operations = [
18
+ migrations.CreateModel(
19
+ name="SupportFormEntry",
20
+ fields=[
21
+ ("id", models.BigAutoField(auto_created=True, primary_key=True)),
22
+ (
23
+ "uuid",
24
+ models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
25
+ ),
26
+ ("name", models.CharField(max_length=255)),
27
+ ("email", models.EmailField(max_length=254)),
28
+ ("message", models.TextField()),
29
+ ("created_at", models.DateTimeField(auto_now_add=True)),
30
+ ("form_slug", models.CharField(max_length=255)),
31
+ (
32
+ "user",
33
+ models.ForeignKey(
34
+ blank=True,
35
+ null=True,
36
+ on_delete=plain.models.deletion.SET_NULL,
37
+ related_name="support_form_entries",
38
+ to="users.user",
39
+ ),
40
+ ),
41
+ ],
42
+ ),
43
+ ]
@@ -0,0 +1,16 @@
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
+ ]
@@ -0,0 +1,25 @@
1
+ import uuid
2
+
3
+ from plain import models
4
+ from plain.runtime import settings
5
+
6
+
7
+ class SupportFormEntry(models.Model):
8
+ uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
9
+ user = models.ForeignKey(
10
+ settings.AUTH_USER_MODEL,
11
+ on_delete=models.SET_NULL,
12
+ related_name="support_form_entries",
13
+ null=True,
14
+ blank=True,
15
+ )
16
+ name = models.CharField(max_length=255)
17
+ email = models.EmailField()
18
+ message = models.TextField()
19
+ created_at = models.DateTimeField(auto_now_add=True)
20
+ form_slug = models.CharField(max_length=255)
21
+ # referrer? source? session?
22
+ # extra_data
23
+
24
+ class Meta:
25
+ ordering = ["-created_at"]
@@ -0,0 +1,20 @@
1
+ from plain.staff.views import (
2
+ StaffModelDetailView,
3
+ StaffModelListView,
4
+ StaffModelViewset,
5
+ register_viewset,
6
+ )
7
+
8
+ from .models import SupportFormEntry
9
+
10
+
11
+ @register_viewset
12
+ class PageviewStaff(StaffModelViewset):
13
+ class ListView(StaffModelListView):
14
+ model = SupportFormEntry
15
+ nav_section = "Support"
16
+ title = "Form entries"
17
+ fields = ["user", "email", "name", "form_slug", "created_at"]
18
+
19
+ class DetailView(StaffModelDetailView):
20
+ model = SupportFormEntry
@@ -0,0 +1 @@
1
+ <div style="white-space: pre-line;">{{ support_form_entry.message }}</div>
@@ -0,0 +1,37 @@
1
+ <div class="flex justify-center p-2">
2
+ <form class="max-w-2xl w-full" method="post" action="{{ form_action }}">
3
+ {{ csrf_input }}
4
+ <div class="flex space-x-4">
5
+ <div class="flex-1 space-y-1">
6
+ <label class="font-medium" for="{{ form.name.html_id }}">Name</label>
7
+ <input
8
+ id="{{ form.name.html_id }}"
9
+ class="block w-full rounded-sm text-black border-gray-300 border"
10
+ required
11
+ type="text"
12
+ name="{{ form.name.html_name }}"
13
+ value="{{ form.name.value() or '' }}">
14
+ </div>
15
+ <div class="flex-1 space-y-1">
16
+ <label class="font-medium" for="{{ form.email.html_id }}">Email</label>
17
+ <input
18
+ id="{{ form.email.html_id }}"
19
+ class="block w-full rounded-sm text-black border-gray-300 border"
20
+ required
21
+ type="email"
22
+ name="{{ form.email.html_name }}"
23
+ value="{{ form.email.value() or '' }}">
24
+ </div>
25
+ </div>
26
+ <div class="mt-4 space-y-1">
27
+ <label class="font-medium" for="{{ form.message.html_id }}">Message</label>
28
+ <textarea
29
+ id="{{ form.message.html_id }}"
30
+ class="block w-full text-black rounded-sm border-gray-300 border"
31
+ rows="10"
32
+ required
33
+ name="{{ form.message.html_name }}">{{ form.message.value() or '' }}</textarea>
34
+ </div>
35
+ <button class="mt-8 text-lg text-white block text-center bg-black hover:bg-black/80 w-full py-2 px-3 rounded-sm" type="submit">Submit</button>
36
+ </form>
37
+ </div>
@@ -0,0 +1,13 @@
1
+ <html>
2
+ <head>
3
+ {% tailwind_css %}
4
+ <script src="{{ asset('support/iframe.js') }}"></script>
5
+ </head>
6
+ <body>
7
+ {% if success %}
8
+ {% include success_template_name %}
9
+ {% else %}
10
+ {% include form_template_name %}
11
+ {% endif %}
12
+ </body>
13
+ </html>
@@ -0,0 +1,11 @@
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 %}
@@ -0,0 +1 @@
1
+ <div class="text-center">Your message has been sent.</div>
@@ -0,0 +1,12 @@
1
+ from plain.urls import path
2
+
3
+ from . import views
4
+
5
+ default_namespace = "support"
6
+
7
+
8
+ urlpatterns = [
9
+ path("form/<slug:form_slug>.js", views.SupportFormJSView),
10
+ path("form/<slug:form_slug>/iframe/", views.SupportIFrameView, name="iframe"),
11
+ path("form/<slug:form_slug>/", views.SupportFormView, name="form"),
12
+ ]
@@ -0,0 +1,59 @@
1
+ from plain.assets.urls import get_asset_url
2
+ from plain.http import ResponseRedirect
3
+ from plain.runtime import settings
4
+ from plain.utils.module_loading import import_string
5
+ from plain.views import FormView, View
6
+ from plain.views.csrf import CsrfExemptViewMixin
7
+
8
+
9
+ class SupportFormView(FormView):
10
+ template_name = "support/page.html"
11
+
12
+ def get_form(self):
13
+ form_slug = self.url_kwargs["form_slug"]
14
+ form_class = import_string(settings.SUPPORT_FORMS[form_slug])
15
+ return form_class(**self.get_form_kwargs())
16
+
17
+ def get_template_context(self):
18
+ context = super().get_template_context()
19
+ form_slug = self.url_kwargs["form_slug"]
20
+ context["form_action"] = self.request.build_absolute_uri()
21
+ context["form_template_name"] = f"support/forms/{form_slug}.html"
22
+ context["success_template_name"] = f"support/success/{form_slug}.html"
23
+ context["success"] = self.request.GET.get("success") == "true"
24
+ return context
25
+
26
+ def get_form_kwargs(self):
27
+ kwargs = super().get_form_kwargs()
28
+ kwargs["user"] = self.request.user
29
+ kwargs["form_slug"] = self.url_kwargs["form_slug"]
30
+ return kwargs
31
+
32
+ def form_valid(self, form):
33
+ entry = form.save()
34
+ form.notify(entry)
35
+ return super().form_valid(form)
36
+
37
+ def get_success_url(self, form):
38
+ # Redirect to the same view and template so we
39
+ # don't have to create two additional views for iframe and non-iframe.
40
+ return "?success=true"
41
+
42
+
43
+ class SupportIFrameView(CsrfExemptViewMixin, SupportFormView):
44
+ template_name = "support/iframe.html"
45
+
46
+ def get_response(self):
47
+ response = super().get_response()
48
+
49
+ # X-Frame-Options are typically in DEFAULT_RESPONSE_HEADERS,
50
+ # which will know to drop the header completely if an empty string.
51
+ # We can't del/pop it because DEFAULT_RESPONSE_HEADERS may add it back.
52
+ response.headers["X-Frame-Options"] = ""
53
+
54
+ return response
55
+
56
+
57
+ class SupportFormJSView(View):
58
+ def get(self):
59
+ return ResponseRedirect(get_asset_url("support/embed.js"))
@@ -0,0 +1,19 @@
1
+ [project]
2
+ name = "plain.support"
3
+ version = "0.1.0"
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"