splent-feature-contact 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.
Files changed (27) hide show
  1. splent_feature_contact-0.1.0/MANIFEST.in +8 -0
  2. splent_feature_contact-0.1.0/PKG-INFO +5 -0
  3. splent_feature_contact-0.1.0/pyproject.toml +52 -0
  4. splent_feature_contact-0.1.0/setup.cfg +4 -0
  5. splent_feature_contact-0.1.0/src/splent_feature_contact.egg-info/PKG-INFO +5 -0
  6. splent_feature_contact-0.1.0/src/splent_feature_contact.egg-info/SOURCES.txt +25 -0
  7. splent_feature_contact-0.1.0/src/splent_feature_contact.egg-info/dependency_links.txt +1 -0
  8. splent_feature_contact-0.1.0/src/splent_feature_contact.egg-info/top_level.txt +1 -0
  9. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/__init__.py +50 -0
  10. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/assets/dist/splent_feature_contact.bundle.js +20 -0
  11. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/assets/dist/splent_feature_contact.bundle.js.map +1 -0
  12. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/commands.py +21 -0
  13. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/config.py +19 -0
  14. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/forms.py +6 -0
  15. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/hooks.py +34 -0
  16. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/migrations/alembic.ini +36 -0
  17. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/migrations/env.py +9 -0
  18. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/migrations/script.py.mako +24 -0
  19. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/migrations/versions/contact0001_initial.py +35 -0
  20. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/models.py +24 -0
  21. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/repositories.py +17 -0
  22. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/routes.py +100 -0
  23. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/seeders.py +10 -0
  24. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/services.py +34 -0
  25. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/signals.py +12 -0
  26. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/templates/contact/admin/list.html +92 -0
  27. splent_feature_contact-0.1.0/src/splent_io/splent_feature_contact/templates/contact/index.html +75 -0
@@ -0,0 +1,8 @@
1
+ # Compiled frontend bundles
2
+ recursive-include src/splent_io/splent_feature_contact/assets/dist *.js *.css *.map
3
+
4
+ # Jinja templates (feature views + hook fragments)
5
+ recursive-include src/splent_io/splent_feature_contact/templates *.html
6
+
7
+ # Alembic migration config and scripts
8
+ recursive-include src/splent_io/splent_feature_contact/migrations *.py *.ini *.mako
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: splent_feature_contact
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.13
5
+ Description-Content-Type: text/markdown
@@ -0,0 +1,52 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80.3.1", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "splent_feature_contact"
7
+ version = "0.1.0"
8
+ readme = "README.md"
9
+ requires-python = ">=3.13"
10
+
11
+ dependencies = [
12
+
13
+ ]
14
+
15
+ [tool.setuptools]
16
+ package-dir = { "" = "src" }
17
+ include-package-data = true
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
21
+ exclude = ["*.tests", "*.tests.*"]
22
+
23
+ [tool.splent]
24
+ cli_version = "1.11.0"
25
+
26
+ # ── Feature Contract (auto-generated) ────────────────────────────────────────
27
+ # Do not edit manually — re-run `splent feature:contract --write` to refresh.
28
+ [tool.splent.contract]
29
+ description = "contact feature"
30
+
31
+ [tool.splent.contract.provides]
32
+ routes = ["/admin/contact", "/admin/contact/<int:message_id>/delete", "/admin/contact/<int:message_id>/read", "/contact"]
33
+ blueprints = []
34
+ models = ["ContactMessage"]
35
+ commands = ["hello"]
36
+ hooks = ["layout.authenticated_sidebar"]
37
+ services = ["ContactService"]
38
+ signals = ["contact-created", "contact-submitting"]
39
+ translations = []
40
+ docker = []
41
+
42
+ [tool.splent.contract.requires]
43
+ features = []
44
+ env_vars = []
45
+ signals = []
46
+
47
+ [tool.splent.contract.extensible]
48
+ services = ["ContactService"]
49
+ templates = ["contact/admin/list.html", "contact/index.html"]
50
+ models = ["ContactMessage"]
51
+ hooks = ["layout.authenticated_sidebar"]
52
+ routes = false
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: splent_feature_contact
3
+ Version: 0.1.0
4
+ Requires-Python: >=3.13
5
+ Description-Content-Type: text/markdown
@@ -0,0 +1,25 @@
1
+ MANIFEST.in
2
+ pyproject.toml
3
+ src/splent_feature_contact.egg-info/PKG-INFO
4
+ src/splent_feature_contact.egg-info/SOURCES.txt
5
+ src/splent_feature_contact.egg-info/dependency_links.txt
6
+ src/splent_feature_contact.egg-info/top_level.txt
7
+ src/splent_io/splent_feature_contact/__init__.py
8
+ src/splent_io/splent_feature_contact/commands.py
9
+ src/splent_io/splent_feature_contact/config.py
10
+ src/splent_io/splent_feature_contact/forms.py
11
+ src/splent_io/splent_feature_contact/hooks.py
12
+ src/splent_io/splent_feature_contact/models.py
13
+ src/splent_io/splent_feature_contact/repositories.py
14
+ src/splent_io/splent_feature_contact/routes.py
15
+ src/splent_io/splent_feature_contact/seeders.py
16
+ src/splent_io/splent_feature_contact/services.py
17
+ src/splent_io/splent_feature_contact/signals.py
18
+ src/splent_io/splent_feature_contact/assets/dist/splent_feature_contact.bundle.js
19
+ src/splent_io/splent_feature_contact/assets/dist/splent_feature_contact.bundle.js.map
20
+ src/splent_io/splent_feature_contact/migrations/alembic.ini
21
+ src/splent_io/splent_feature_contact/migrations/env.py
22
+ src/splent_io/splent_feature_contact/migrations/script.py.mako
23
+ src/splent_io/splent_feature_contact/migrations/versions/contact0001_initial.py
24
+ src/splent_io/splent_feature_contact/templates/contact/index.html
25
+ src/splent_io/splent_feature_contact/templates/contact/admin/list.html
@@ -0,0 +1,50 @@
1
+ from splent_framework.blueprints.base_blueprint import create_blueprint
2
+ from splent_framework.nav.nav_registry import register_nav_item
3
+ from splent_framework.services.service_locator import register_service
4
+
5
+ from splent_io.splent_feature_contact.services import ContactService
6
+
7
+ contact_bp = create_blueprint(__name__)
8
+
9
+
10
+ def init_feature(app):
11
+ from splent_framework.assets.asset_registry import register_asset
12
+ from splent_framework.settings.settings_schema import register_settings
13
+
14
+ register_service(app, "ContactService", ContactService)
15
+
16
+ # Public contact-form styles, shipped through the asset registry (never
17
+ # inline / CDN) from the feature's OWN assets/css/contact.css.
18
+ register_asset(
19
+ "css", "contact.assets", order=100, subfolder="css", filename="contact.css"
20
+ )
21
+
22
+ # Public main-nav entry; surfaces only when the feature is selected.
23
+ register_nav_item(key="contact", label="Contact", href="/contact", order=60)
24
+
25
+ # Declarative admin settings — the framework renders the panel and persists
26
+ # the values; routes read them via get_config("contact").
27
+ register_settings(
28
+ "contact",
29
+ "Contact",
30
+ [
31
+ {
32
+ "key": "recipient",
33
+ "type": "text",
34
+ "default": "",
35
+ "label": "Recipient email",
36
+ "help": "Where contact messages are sent.",
37
+ },
38
+ {
39
+ "key": "success_message",
40
+ "type": "text",
41
+ "default": "Thanks — we'll be in touch.",
42
+ "label": "Success message",
43
+ },
44
+ ],
45
+ icon="mail",
46
+ )
47
+
48
+
49
+ def inject_context_vars(app):
50
+ return {}
@@ -0,0 +1,20 @@
1
+ /******/ (() => { // webpackBootstrap
2
+ /*!*******************************************************************************************!*\
3
+ !*** ../splent_feature_contact/src/splent_io/splent_feature_contact/assets/js/scripts.js ***!
4
+ \*******************************************************************************************/
5
+ // Entry point for splent_feature_contact frontend assets.
6
+ // Add your JavaScript here. Webpack compiles this into assets/dist/splent_feature_contact.bundle.js
7
+ //
8
+ // To load the compiled bundle in the product layout, register it in hooks.py:
9
+ //
10
+ // from splent_framework.hooks.template_hooks import register_template_hook
11
+ // from flask import url_for
12
+ //
13
+ // def contact_scripts():
14
+ // return '<script src="' + url_for("contact.assets", subfolder="dist", filename="splent_feature_contact.bundle.js") + '"></script>'
15
+ //
16
+ // register_template_hook("layout.scripts", contact_scripts)
17
+
18
+ /******/ })()
19
+ ;
20
+ //# sourceMappingURL=splent_feature_contact.bundle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"splent_feature_contact.bundle.js","mappings":";;;;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","sources":["webpack://diversolab_app/../splent_feature_contact/src/splent_io/splent_feature_contact/assets/js/scripts.js"],"sourcesContent":["// Entry point for splent_feature_contact frontend assets.\n// Add your JavaScript here. Webpack compiles this into assets/dist/splent_feature_contact.bundle.js\n//\n// To load the compiled bundle in the product layout, register it in hooks.py:\n//\n// from splent_framework.hooks.template_hooks import register_template_hook\n// from flask import url_for\n//\n// def contact_scripts():\n// return '<script src=\"' + url_for(\"contact.assets\", subfolder=\"dist\", filename=\"splent_feature_contact.bundle.js\") + '\"></script>'\n//\n// register_template_hook(\"layout.scripts\", contact_scripts)\n"],"names":[],"sourceRoot":""}
@@ -0,0 +1,21 @@
1
+ """
2
+ CLI commands contributed by splent_feature_contact.
3
+
4
+ These commands are auto-discovered by the framework and exposed in the
5
+ SPLENT CLI under the ``feature:contact`` group.
6
+
7
+ Usage::
8
+
9
+ splent feature:contact hello
10
+ """
11
+
12
+ import click
13
+
14
+
15
+ @click.command("hello")
16
+ def hello():
17
+ """Example command — replace with your own."""
18
+ click.echo(" Hello from splent_feature_contact!")
19
+
20
+
21
+ cli_commands = [hello]
@@ -0,0 +1,19 @@
1
+ """
2
+ contact feature configuration.
3
+
4
+ Injects environment variables into Flask app.config.
5
+ Add your feature's env vars here so the framework can track them.
6
+
7
+ To regenerate from source code: splent feature:inject-config splent_feature_contact
8
+ """
9
+
10
+ import os # noqa: F401 — used when adding env vars below
11
+
12
+
13
+ def inject_config(app):
14
+ app.config.update(
15
+ {
16
+ # Add your feature's env vars here, e.g.:
17
+ # "MY_VAR": os.getenv("MY_VAR", "default_value"),
18
+ }
19
+ )
@@ -0,0 +1,6 @@
1
+ from flask_wtf import FlaskForm
2
+ from wtforms import SubmitField
3
+
4
+
5
+ class SplentFeatureContactForm(FlaskForm):
6
+ submit = SubmitField("Save splent_feature_contact")
@@ -0,0 +1,34 @@
1
+ """Template hooks for splent_feature_contact.
2
+
3
+ Adds an admin "Contact" link to the authenticated sidebar, with a badge showing
4
+ how many messages are still unread. The public "Contact" nav entry is declared
5
+ via register_nav_item() in __init__.py, not here.
6
+ """
7
+
8
+ from flask import request, url_for
9
+
10
+ from splent_framework.hooks.template_hooks import register_template_hook
11
+ from splent_framework.services.service_locator import service_proxy
12
+
13
+
14
+ def contact_admin_link():
15
+ active = (
16
+ "active"
17
+ if request.endpoint and request.endpoint.startswith("contact.admin")
18
+ else ""
19
+ )
20
+ pending = 0
21
+ try:
22
+ pending = service_proxy("ContactService").pending_count()
23
+ except Exception:
24
+ pass
25
+ badge = f' <span class="badge bg-danger">{pending}</span>' if pending else ""
26
+ return (
27
+ f'<li class="sidebar-item {active}">'
28
+ f'<a class="sidebar-link" href="{url_for("contact.admin_index")}">'
29
+ '<i class="align-middle" data-feather="mail"></i> '
30
+ f'<span class="align-middle">Contact</span>{badge}</a></li>'
31
+ )
32
+
33
+
34
+ register_template_hook("layout.authenticated_sidebar", contact_admin_link)
@@ -0,0 +1,36 @@
1
+ [alembic]
2
+ script_location = migrations
3
+
4
+ [loggers]
5
+ keys = root,sqlalchemy,alembic
6
+
7
+ [handlers]
8
+ keys = console
9
+
10
+ [formatters]
11
+ keys = generic
12
+
13
+ [logger_root]
14
+ level = WARN
15
+ handlers = console
16
+ qualname =
17
+
18
+ [logger_sqlalchemy]
19
+ level = WARN
20
+ handlers =
21
+ qualname = sqlalchemy.engine
22
+
23
+ [logger_alembic]
24
+ level = INFO
25
+ handlers =
26
+ qualname = alembic
27
+
28
+ [handler_console]
29
+ class = StreamHandler
30
+ args = (sys.stderr,)
31
+ level = NOTSET
32
+ formatter = generic
33
+
34
+ [formatter_generic]
35
+ format = %(levelname)-5.5s [%(name)s] %(message)s
36
+ datefmt = %H:%M:%S
@@ -0,0 +1,9 @@
1
+ """Alembic migration environment for splent_feature_contact."""
2
+
3
+ from splent_io.splent_feature_contact import models # noqa
4
+ from splent_framework.migrations.feature_env import run_feature_migrations
5
+
6
+ FEATURE_NAME = "splent_feature_contact"
7
+ FEATURE_TABLES = set()
8
+
9
+ run_feature_migrations(FEATURE_NAME, FEATURE_TABLES)
@@ -0,0 +1,24 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+ ${imports if imports else ""}
11
+
12
+ # revision identifiers, used by Alembic.
13
+ revision = ${repr(up_revision)}
14
+ down_revision = ${repr(down_revision)}
15
+ branch_labels = ${repr(branch_labels)}
16
+ depends_on = ${repr(depends_on)}
17
+
18
+
19
+ def upgrade():
20
+ ${upgrades if upgrades else "pass"}
21
+
22
+
23
+ def downgrade():
24
+ ${downgrades if downgrades else "pass"}
@@ -0,0 +1,35 @@
1
+ """contact_message table.
2
+
3
+ Revision ID: contact0001
4
+ Revises:
5
+ """
6
+
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "contact0001"
11
+ down_revision = None
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade():
17
+ op.create_table(
18
+ "contact_message",
19
+ sa.Column("id", sa.Integer(), nullable=False),
20
+ sa.Column("name", sa.String(length=255), nullable=False),
21
+ sa.Column("email", sa.String(length=255), nullable=False),
22
+ sa.Column("subject", sa.String(length=255), nullable=True),
23
+ sa.Column("message", sa.Text(), nullable=False),
24
+ sa.Column("created_at", sa.DateTime(), nullable=True),
25
+ sa.Column("read", sa.Boolean(), nullable=True),
26
+ sa.PrimaryKeyConstraint("id"),
27
+ )
28
+ op.create_index(
29
+ op.f("ix_contact_message_read"), "contact_message", ["read"], unique=False
30
+ )
31
+
32
+
33
+ def downgrade():
34
+ op.drop_index(op.f("ix_contact_message_read"), table_name="contact_message")
35
+ op.drop_table("contact_message")
@@ -0,0 +1,24 @@
1
+ from datetime import datetime
2
+
3
+ from splent_framework.db import db
4
+
5
+
6
+ class ContactMessage(db.Model):
7
+ """A message submitted through the public contact form.
8
+
9
+ Stored even when the notification email fails to send, so nothing is lost.
10
+ ``read`` flags whether an admin has triaged it.
11
+ """
12
+
13
+ __tablename__ = "contact_message"
14
+
15
+ id = db.Column(db.Integer, primary_key=True)
16
+ name = db.Column(db.String(255), nullable=False)
17
+ email = db.Column(db.String(255), nullable=False)
18
+ subject = db.Column(db.String(255), default="")
19
+ message = db.Column(db.Text, nullable=False)
20
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
21
+ read = db.Column(db.Boolean, default=False, index=True)
22
+
23
+ def __repr__(self):
24
+ return f"ContactMessage<{self.id} from {self.email}>"
@@ -0,0 +1,17 @@
1
+ from splent_io.splent_feature_contact.models import ContactMessage
2
+ from splent_framework.repositories.BaseRepository import BaseRepository
3
+
4
+
5
+ class ContactRepository(BaseRepository):
6
+ def __init__(self):
7
+ super().__init__(ContactMessage)
8
+
9
+ def all_newest_first(self) -> list[ContactMessage]:
10
+ return ContactMessage.query.order_by(ContactMessage.created_at.desc()).all()
11
+
12
+ def pending(self) -> list[ContactMessage]:
13
+ return (
14
+ ContactMessage.query.filter_by(read=False)
15
+ .order_by(ContactMessage.created_at.desc())
16
+ .all()
17
+ )
@@ -0,0 +1,100 @@
1
+ from flask import flash, redirect, render_template, request, url_for
2
+ from flask_login import login_required
3
+
4
+ from splent_io.splent_feature_contact import contact_bp
5
+ from splent_io.splent_feature_contact.signals import (
6
+ contact_created,
7
+ contact_submitting,
8
+ )
9
+ from splent_framework.services.service_locator import service_proxy
10
+ from splent_framework.settings.settings_schema import get_config
11
+
12
+ contact_service = service_proxy("ContactService")
13
+
14
+
15
+ # =====================================================================
16
+ # PUBLIC — the contact form
17
+ # =====================================================================
18
+ @contact_bp.route("/contact", methods=["GET", "POST"])
19
+ def index():
20
+ if request.method == "GET":
21
+ return render_template("contact/index.html")
22
+
23
+ # Let any listener (a captcha provider, a spam filter…) veto the submission.
24
+ # contact knows nothing about captchas — they connect to this signal.
25
+ results = contact_submitting.send(
26
+ None, form=request.form, remoteip=request.remote_addr
27
+ )
28
+ if any(value is False for _, value in results):
29
+ flash("Spam check failed — please try again.", "danger")
30
+ return redirect(url_for("contact.index"))
31
+
32
+ name = (request.form.get("name") or "").strip()
33
+ email = (request.form.get("email") or "").strip()
34
+ subject = (request.form.get("subject") or "").strip()
35
+ message = (request.form.get("message") or "").strip()
36
+
37
+ if not (name and email and message):
38
+ flash("Name, email and message are required.", "danger")
39
+ return redirect(url_for("contact.index"))
40
+
41
+ contact_message = contact_service.create_message(
42
+ name=name, email=email, subject=subject, message=message
43
+ )
44
+
45
+ # Notify the configured recipient. The message is already saved, so a mail
46
+ # failure must not lose it — wrap in try/except and still flash success.
47
+ mail = service_proxy("MailService")
48
+ recipient = get_config("contact").get("recipient") or ""
49
+ if not recipient:
50
+ try:
51
+ recipient = mail.from_address()
52
+ except Exception:
53
+ recipient = None
54
+
55
+ body = f"Name: {name}\nEmail: {email}\nSubject: {subject}\n\n{message}\n"
56
+ if recipient:
57
+ try:
58
+ mail.send(
59
+ to=recipient,
60
+ subject="[Contact] " + (subject or "(no subject)"),
61
+ body=body,
62
+ reply_to=email,
63
+ )
64
+ except Exception:
65
+ pass
66
+
67
+ contact_created.send(None, message=contact_message)
68
+
69
+ success = get_config("contact").get("success_message") or (
70
+ "Thanks — we'll be in touch."
71
+ )
72
+ flash(success, "success")
73
+ return redirect(url_for("contact.index"))
74
+
75
+
76
+ # =====================================================================
77
+ # ADMIN — inbox
78
+ # =====================================================================
79
+ @contact_bp.route("/admin/contact", methods=["GET"])
80
+ @login_required
81
+ def admin_index():
82
+ return render_template(
83
+ "contact/admin/list.html", messages=contact_service.all_messages()
84
+ )
85
+
86
+
87
+ @contact_bp.route("/admin/contact/<int:message_id>/read", methods=["POST"])
88
+ @login_required
89
+ def admin_read(message_id):
90
+ contact_service.mark_read(message_id)
91
+ flash("Message marked as read.", "success")
92
+ return redirect(url_for("contact.admin_index"))
93
+
94
+
95
+ @contact_bp.route("/admin/contact/<int:message_id>/delete", methods=["POST"])
96
+ @login_required
97
+ def admin_delete(message_id):
98
+ contact_service.delete_message(message_id)
99
+ flash("Message deleted.", "success")
100
+ return redirect(url_for("contact.admin_index"))
@@ -0,0 +1,10 @@
1
+ from splent_framework.seeders.BaseSeeder import BaseSeeder
2
+
3
+
4
+ class SplentFeatureContactSeeder(BaseSeeder):
5
+ def run(self):
6
+ data = [
7
+ # Create any Model object you want to make seed
8
+ ]
9
+
10
+ self.seed(data)
@@ -0,0 +1,34 @@
1
+ from splent_io.splent_feature_contact.models import ContactMessage
2
+ from splent_io.splent_feature_contact.repositories import ContactRepository
3
+ from splent_framework.services.BaseService import BaseService
4
+
5
+
6
+ class ContactService(BaseService):
7
+ def __init__(self):
8
+ super().__init__(ContactRepository())
9
+
10
+ def all_messages(self):
11
+ """Every message, newest first."""
12
+ return self.repository.all_newest_first()
13
+
14
+ def pending_count(self) -> int:
15
+ """How many messages are still unread (for the sidebar badge)."""
16
+ return ContactMessage.query.filter_by(read=False).count()
17
+
18
+ def create_message(self, name, email, subject, message):
19
+ """Persist a submitted contact message and return it."""
20
+ return self.repository.create(
21
+ name=name,
22
+ email=email,
23
+ subject=subject or "",
24
+ message=message,
25
+ read=False,
26
+ )
27
+
28
+ def mark_read(self, message_id):
29
+ """Flag a message as read."""
30
+ return self.repository.update(message_id, read=True)
31
+
32
+ def delete_message(self, message_id):
33
+ """Remove a message permanently."""
34
+ return self.repository.delete(message_id)
@@ -0,0 +1,12 @@
1
+ """Signals emitted by the contact feature.
2
+
3
+ contact stays decoupled from anti-spam: it emits ``contact-submitting`` and any
4
+ listener (a captcha provider, a spam filter…) may return False to veto the
5
+ submission — mirroring comments' ``comment-submitting``. ``contact-created``
6
+ fires after a message is stored, for things like notifications or counters.
7
+ """
8
+
9
+ from splent_framework.signals.signal_utils import define_signal
10
+
11
+ contact_submitting = define_signal("contact-submitting", "splent_feature_contact")
12
+ contact_created = define_signal("contact-created", "splent_feature_contact")
@@ -0,0 +1,92 @@
1
+ {% extends "base_template.html" %}
2
+
3
+ {% block title %}{{ _('Contact') }}{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="container section">
7
+
8
+ <header class="page-header">
9
+ <h1>{{ _('Contact') }}</h1>
10
+ <p class="text-muted">{{ _('Messages submitted through the contact form.') }}</p>
11
+ </header>
12
+
13
+ {# Flash messages #}
14
+ {% with messages = get_flashed_messages(with_categories=true) %}
15
+ {% if messages %}
16
+ <div class="admin-flashes">
17
+ {% for category, message in messages %}
18
+ <div class="alert alert-{{ category }}">{{ message }}</div>
19
+ {% endfor %}
20
+ </div>
21
+ {% endif %}
22
+ {% endwith %}
23
+
24
+ <table class="table">
25
+ <thead>
26
+ <tr>
27
+ <th>{{ _('From') }}</th>
28
+ <th>{{ _('Subject') }}</th>
29
+ <th>{{ _('Message') }}</th>
30
+ <th>{{ _('Date') }}</th>
31
+ <th>{{ _('Status') }}</th>
32
+ <th>{{ _('Actions') }}</th>
33
+ </tr>
34
+ </thead>
35
+ <tbody>
36
+ {% for m in messages %}
37
+ <tr class="{{ '' if m.read else 'table-active' }}">
38
+ <td>
39
+ <strong>{{ m.name }}</strong>
40
+ <br><span class="text-muted">{{ m.email }}</span>
41
+ </td>
42
+ <td>{{ m.subject or '—' }}</td>
43
+ <td>{{ m.message|truncate(100, true) }}</td>
44
+ <td>{{ m.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
45
+ <td>
46
+ {% if m.read %}
47
+ <span class="badge bg-secondary">{{ _('read') }}</span>
48
+ {% else %}
49
+ <span class="badge bg-warning text-dark">{{ _('unread') }}</span>
50
+ {% endif %}
51
+ </td>
52
+ <td class="contact-actions">
53
+ {% if not m.read %}
54
+ <form method="POST"
55
+ action="{{ url_for('contact.admin_read', message_id=m.id) }}"
56
+ class="d-inline">
57
+ <button type="submit" class="btn btn-sm btn-success">
58
+ {{ _('Mark read') }}
59
+ </button>
60
+ </form>
61
+ {% endif %}
62
+ <form method="POST"
63
+ action="{{ url_for('contact.admin_delete', message_id=m.id) }}"
64
+ class="d-inline"
65
+ onsubmit="return confirm('{{ _('Delete this message?') }}');">
66
+ <button type="submit"
67
+ class="btn btn-sm btn-danger"
68
+ title="{{ _('Delete') }}"
69
+ aria-label="{{ _('Delete') }}">
70
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"
71
+ viewBox="0 0 24 24" fill="none" stroke="currentColor"
72
+ stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
73
+ aria-hidden="true">
74
+ <line x1="18" y1="6" x2="6" y2="18"></line>
75
+ <line x1="6" y1="6" x2="18" y2="18"></line>
76
+ </svg>
77
+ </button>
78
+ </form>
79
+ </td>
80
+ </tr>
81
+ {% else %}
82
+ <tr>
83
+ <td colspan="6" class="text-muted text-center">
84
+ {{ _('No messages yet.') }}
85
+ </td>
86
+ </tr>
87
+ {% endfor %}
88
+ </tbody>
89
+ </table>
90
+
91
+ </div>
92
+ {% endblock %}
@@ -0,0 +1,75 @@
1
+ {% extends "public_base.html" %}
2
+
3
+ {% block title %}{{ _('Contact') }} — {{ SPLENT_APP }}{% endblock %}
4
+
5
+ {% block hero %}
6
+ {{ render_block('hero', eyebrow=_('Contact'), title=_('Get in touch'),
7
+ subtitle=_('Send us a message and we will get back to you.')) }}
8
+ {% endblock %}
9
+
10
+ {% block content %}
11
+ <div class="container section">
12
+
13
+ {# Flash messages #}
14
+ {% with messages = get_flashed_messages(with_categories=true) %}
15
+ {% if messages %}
16
+ <div class="contact-form__flashes">
17
+ {% for category, message in messages %}
18
+ <div class="alert alert-{{ category }}">{{ message }}</div>
19
+ {% endfor %}
20
+ </div>
21
+ {% endif %}
22
+ {% endwith %}
23
+
24
+ <form class="contact-form"
25
+ method="POST"
26
+ action="{{ url_for('contact.index') }}">
27
+
28
+ <div class="form-group">
29
+ <label for="contact-name">{{ _('Name') }}</label>
30
+ <input type="text"
31
+ id="contact-name"
32
+ name="name"
33
+ class="form-control"
34
+ required>
35
+ </div>
36
+
37
+ <div class="form-group">
38
+ <label for="contact-email">{{ _('Email') }}</label>
39
+ <input type="email"
40
+ id="contact-email"
41
+ name="email"
42
+ class="form-control"
43
+ required>
44
+ </div>
45
+
46
+ <div class="form-group">
47
+ <label for="contact-subject">{{ _('Subject') }}</label>
48
+ <input type="text"
49
+ id="contact-subject"
50
+ name="subject"
51
+ class="form-control">
52
+ </div>
53
+
54
+ <div class="form-group">
55
+ <label for="contact-message">{{ _('Message') }}</label>
56
+ <textarea id="contact-message"
57
+ name="message"
58
+ class="form-control"
59
+ rows="6"
60
+ required></textarea>
61
+ </div>
62
+
63
+ {# Captcha widget (cloudflare / recaptcha) — empty when none installed #}
64
+ {% if captcha_widget is defined %}
65
+ {{ captcha_script() }}
66
+ {{ captcha_widget() }}
67
+ {% endif %}
68
+
69
+ <button type="submit" class="btn btn-primary">
70
+ {{ _('Send message') }}
71
+ </button>
72
+ </form>
73
+
74
+ </div>
75
+ {% endblock %}