splent-feature-partners 0.2.1__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 (29) hide show
  1. splent_feature_partners-0.2.1/MANIFEST.in +8 -0
  2. splent_feature_partners-0.2.1/PKG-INFO +5 -0
  3. splent_feature_partners-0.2.1/pyproject.toml +55 -0
  4. splent_feature_partners-0.2.1/setup.cfg +4 -0
  5. splent_feature_partners-0.2.1/src/splent_feature_partners.egg-info/PKG-INFO +5 -0
  6. splent_feature_partners-0.2.1/src/splent_feature_partners.egg-info/SOURCES.txt +27 -0
  7. splent_feature_partners-0.2.1/src/splent_feature_partners.egg-info/dependency_links.txt +1 -0
  8. splent_feature_partners-0.2.1/src/splent_feature_partners.egg-info/top_level.txt +1 -0
  9. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/__init__.py +49 -0
  10. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/assets/dist/splent_feature_partners.bundle.js +20 -0
  11. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/assets/dist/splent_feature_partners.bundle.js.map +1 -0
  12. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/commands.py +21 -0
  13. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/config.py +19 -0
  14. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/forms.py +6 -0
  15. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/hooks.py +58 -0
  16. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/migrations/alembic.ini +36 -0
  17. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/migrations/env.py +9 -0
  18. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/migrations/script.py.mako +24 -0
  19. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/migrations/versions/partners0001_initial.py +37 -0
  20. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/models.py +34 -0
  21. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/repositories.py +7 -0
  22. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/routes.py +96 -0
  23. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/seeders.py +10 -0
  24. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/services.py +42 -0
  25. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/signals.py +19 -0
  26. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/templates/partners/admin/form.html +52 -0
  27. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/templates/partners/admin/list.html +70 -0
  28. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/templates/partners/index.html +10 -0
  29. splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/templates/partners/strip.html +26 -0
@@ -0,0 +1,8 @@
1
+ # Compiled frontend bundles
2
+ recursive-include src/splent_io/splent_feature_partners/assets/dist *.js *.css *.map
3
+
4
+ # Jinja templates (feature views + hook fragments)
5
+ recursive-include src/splent_io/splent_feature_partners/templates *.html
6
+
7
+ # Alembic migration config and scripts
8
+ recursive-include src/splent_io/splent_feature_partners/migrations *.py *.ini *.mako
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: splent_feature_partners
3
+ Version: 0.2.1
4
+ Requires-Python: >=3.13
5
+ Description-Content-Type: text/markdown
@@ -0,0 +1,55 @@
1
+ [build-system]
2
+ requires = ["setuptools>=80.3.1", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "splent_feature_partners"
7
+ version = "0.2.1"
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
+ # Preserved across refreshes: description, category, tags, env, and
29
+ # requires.features_manual (dependencies invisible to static analysis).
30
+ [tool.splent.contract]
31
+ description = "Partner logos and links (home section + admin CRUD)"
32
+ archetype = "full"
33
+
34
+ [tool.splent.contract.provides]
35
+ routes = ["/admin/partners", "/admin/partners/<int:id>/delete", "/admin/partners/<int:id>/edit", "/admin/partners/new", "/partners"]
36
+ blueprints = []
37
+ models = ["Partner"]
38
+ commands = ["hello"]
39
+ hooks = ["home.section", "layout.authenticated_sidebar"]
40
+ services = ["PartnersService"]
41
+ signals = []
42
+ translations = []
43
+ docker = []
44
+
45
+ [tool.splent.contract.requires]
46
+ features = ["media"]
47
+ env_vars = []
48
+ signals = []
49
+
50
+ [tool.splent.contract.extensible]
51
+ services = ["PartnersService"]
52
+ templates = ["partners/admin/form.html", "partners/admin/list.html", "partners/index.html", "partners/strip.html"]
53
+ models = ["Partner"]
54
+ hooks = ["home.section", "layout.authenticated_sidebar"]
55
+ 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_partners
3
+ Version: 0.2.1
4
+ Requires-Python: >=3.13
5
+ Description-Content-Type: text/markdown
@@ -0,0 +1,27 @@
1
+ MANIFEST.in
2
+ pyproject.toml
3
+ src/splent_feature_partners.egg-info/PKG-INFO
4
+ src/splent_feature_partners.egg-info/SOURCES.txt
5
+ src/splent_feature_partners.egg-info/dependency_links.txt
6
+ src/splent_feature_partners.egg-info/top_level.txt
7
+ src/splent_io/splent_feature_partners/__init__.py
8
+ src/splent_io/splent_feature_partners/commands.py
9
+ src/splent_io/splent_feature_partners/config.py
10
+ src/splent_io/splent_feature_partners/forms.py
11
+ src/splent_io/splent_feature_partners/hooks.py
12
+ src/splent_io/splent_feature_partners/models.py
13
+ src/splent_io/splent_feature_partners/repositories.py
14
+ src/splent_io/splent_feature_partners/routes.py
15
+ src/splent_io/splent_feature_partners/seeders.py
16
+ src/splent_io/splent_feature_partners/services.py
17
+ src/splent_io/splent_feature_partners/signals.py
18
+ src/splent_io/splent_feature_partners/assets/dist/splent_feature_partners.bundle.js
19
+ src/splent_io/splent_feature_partners/assets/dist/splent_feature_partners.bundle.js.map
20
+ src/splent_io/splent_feature_partners/migrations/alembic.ini
21
+ src/splent_io/splent_feature_partners/migrations/env.py
22
+ src/splent_io/splent_feature_partners/migrations/script.py.mako
23
+ src/splent_io/splent_feature_partners/migrations/versions/partners0001_initial.py
24
+ src/splent_io/splent_feature_partners/templates/partners/index.html
25
+ src/splent_io/splent_feature_partners/templates/partners/strip.html
26
+ src/splent_io/splent_feature_partners/templates/partners/admin/form.html
27
+ src/splent_io/splent_feature_partners/templates/partners/admin/list.html
@@ -0,0 +1,49 @@
1
+ from splent_framework.blueprints.base_blueprint import create_blueprint
2
+ from splent_framework.services.service_locator import register_service
3
+
4
+ from splent_io.splent_feature_partners.services import PartnersService
5
+
6
+ partners_bp = create_blueprint(__name__)
7
+
8
+
9
+ def init_feature(app):
10
+ from splent_framework.assets.asset_registry import register_asset
11
+ from splent_framework.settings.settings_schema import register_settings
12
+
13
+ register_service(app, "PartnersService", PartnersService)
14
+ register_asset(
15
+ "css", "partners.assets", order=100, subfolder="css", filename="partners.css"
16
+ )
17
+ # Admin-configurable behaviour (framework renders the panel from this schema).
18
+ register_settings(
19
+ "partners",
20
+ "Partners",
21
+ [
22
+ {
23
+ "key": "title",
24
+ "type": "text",
25
+ "default": "Collaborating organisations",
26
+ "label": "Section title",
27
+ },
28
+ {
29
+ "key": "scope",
30
+ "type": "select",
31
+ "default": "home",
32
+ "label": "Show on",
33
+ "options": [("home", "Home page only"), ("all", "All pages")],
34
+ "help": "Where the partners strip appears.",
35
+ },
36
+ {
37
+ "key": "grayscale",
38
+ "type": "bool",
39
+ "default": "1",
40
+ "label": "Greyscale logos",
41
+ "help": "Show logos in grey, full colour on hover.",
42
+ },
43
+ ],
44
+ icon="briefcase",
45
+ )
46
+
47
+
48
+ def inject_context_vars(app):
49
+ return {}
@@ -0,0 +1,20 @@
1
+ /******/ (() => { // webpackBootstrap
2
+ /*!*********************************************************************************************!*\
3
+ !*** ../splent_feature_partners/src/splent_io/splent_feature_partners/assets/js/scripts.js ***!
4
+ \*********************************************************************************************/
5
+ // Entry point for splent_feature_partners frontend assets.
6
+ // Add your JavaScript here. Webpack compiles this into assets/dist/splent_feature_partners.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 partners_scripts():
14
+ // return '<script src="' + url_for("partners.assets", subfolder="dist", filename="splent_feature_partners.bundle.js") + '"></script>'
15
+ //
16
+ // register_template_hook("layout.scripts", partners_scripts)
17
+
18
+ /******/ })()
19
+ ;
20
+ //# sourceMappingURL=splent_feature_partners.bundle.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"splent_feature_partners.bundle.js","mappings":";;;;AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","sources":["webpack://diversolab_app/../splent_feature_partners/src/splent_io/splent_feature_partners/assets/js/scripts.js"],"sourcesContent":["// Entry point for splent_feature_partners frontend assets.\n// Add your JavaScript here. Webpack compiles this into assets/dist/splent_feature_partners.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 partners_scripts():\n// return '<script src=\"' + url_for(\"partners.assets\", subfolder=\"dist\", filename=\"splent_feature_partners.bundle.js\") + '\"></script>'\n//\n// register_template_hook(\"layout.scripts\", partners_scripts)\n"],"names":[],"sourceRoot":""}
@@ -0,0 +1,21 @@
1
+ """
2
+ CLI commands contributed by splent_feature_partners.
3
+
4
+ These commands are auto-discovered by the framework and exposed in the
5
+ SPLENT CLI under the ``feature:partners`` group.
6
+
7
+ Usage::
8
+
9
+ splent feature:partners 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_partners!")
19
+
20
+
21
+ cli_commands = [hello]
@@ -0,0 +1,19 @@
1
+ """
2
+ partners 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_partners
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 SplentFeaturePartnersForm(FlaskForm):
6
+ submit = SubmitField("Save splent_feature_partners")
@@ -0,0 +1,58 @@
1
+ """Template hooks for splent_feature_partners.
2
+
3
+ * ``home.section`` — renders the public partners strip on the homepage (or on
4
+ every public page when the admin sets scope="all").
5
+ * ``layout.authenticated_sidebar`` — admin sidebar link to the partners manager.
6
+
7
+ The strip's CSS is NOT injected here: it is declared through the asset registry
8
+ (in __init__.init_feature via register_asset) so the shell loads it in a
9
+ deduplicated, deterministic order. The markup is config-driven (title / scope /
10
+ grayscale) via the admin-editable settings read by get_config("partners").
11
+ """
12
+
13
+ from flask import render_template, request, url_for
14
+ from markupsafe import Markup
15
+
16
+ from splent_framework.hooks.template_hooks import register_template_hook
17
+ from splent_framework.services.service_locator import service_proxy
18
+ from splent_framework.settings.settings_schema import get_config
19
+
20
+
21
+ def partners_section():
22
+ """Render the partners strip into the home.section slot.
23
+
24
+ Scope-aware: by default (scope="home") it renders only on the homepage;
25
+ "all" renders it on every public page. Returns "" when there are no active
26
+ partners so the slot collapses cleanly rather than showing an empty heading.
27
+ """
28
+ cfg = get_config("partners")
29
+ if cfg.get("scope", "home") == "home" and request.endpoint != "public.index":
30
+ return ""
31
+ try:
32
+ partners = service_proxy("PartnersService").active_partners()
33
+ except Exception:
34
+ return ""
35
+ if not partners:
36
+ return ""
37
+ return Markup(render_template("partners/strip.html", partners=partners, cfg=cfg))
38
+
39
+
40
+ def partners_admin_link():
41
+ """Sidebar entry for the partners manager (the WP-plugin pattern)."""
42
+ active = (
43
+ "active"
44
+ if request.endpoint and request.endpoint.startswith("partners.admin")
45
+ else ""
46
+ )
47
+ return (
48
+ f'<li class="sidebar-item {active}">'
49
+ f'<a class="sidebar-link" href="{url_for("partners.admin_index")}">'
50
+ '<i class="align-middle" data-feather="briefcase"></i> '
51
+ '<span class="align-middle">Partners</span>'
52
+ "</a>"
53
+ "</li>"
54
+ )
55
+
56
+
57
+ register_template_hook("home.section", partners_section, order=30)
58
+ register_template_hook("layout.authenticated_sidebar", partners_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_partners."""
2
+
3
+ from splent_io.splent_feature_partners import models # noqa
4
+ from splent_framework.migrations.feature_env import run_feature_migrations
5
+
6
+ FEATURE_NAME = "splent_feature_partners"
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,37 @@
1
+ """partner table (collaborating organisations).
2
+
3
+ Revision ID: partners0001
4
+ Revises:
5
+ """
6
+
7
+ import sqlalchemy as sa
8
+ from alembic import op
9
+
10
+ revision = "partners0001"
11
+ down_revision = None
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade():
17
+ op.create_table(
18
+ "partner",
19
+ sa.Column("id", sa.Integer(), nullable=False),
20
+ sa.Column("media_id", sa.Integer(), nullable=True),
21
+ sa.Column("name", sa.String(length=255), nullable=True),
22
+ sa.Column("link", sa.String(length=512), nullable=True),
23
+ sa.Column("order", sa.Integer(), nullable=True),
24
+ sa.Column("active", sa.Boolean(), nullable=True),
25
+ sa.ForeignKeyConstraint(["media_id"], ["media_item.id"], ondelete="SET NULL"),
26
+ sa.PrimaryKeyConstraint("id"),
27
+ )
28
+ op.create_index(op.f("ix_partner_media_id"), "partner", ["media_id"])
29
+ op.create_index(op.f("ix_partner_order"), "partner", ["order"])
30
+ op.create_index(op.f("ix_partner_active"), "partner", ["active"])
31
+
32
+
33
+ def downgrade():
34
+ op.drop_index(op.f("ix_partner_active"), table_name="partner")
35
+ op.drop_index(op.f("ix_partner_order"), table_name="partner")
36
+ op.drop_index(op.f("ix_partner_media_id"), table_name="partner")
37
+ op.drop_table("partner")
@@ -0,0 +1,34 @@
1
+ from splent_framework.db import db
2
+
3
+
4
+ class Partner(db.Model):
5
+ """A collaborating organisation shown in the homepage partners strip.
6
+
7
+ The logo comes from the media library (``media_id`` -> MediaItem); the
8
+ feature only REFERENCES it, so logos are uploaded/managed in /admin/media.
9
+ ON DELETE SET NULL keeps the partner if its logo is removed (the render
10
+ skips logoless ones).
11
+ """
12
+
13
+ __tablename__ = "partner"
14
+
15
+ id = db.Column(db.Integer, primary_key=True)
16
+ media_id = db.Column(
17
+ db.Integer,
18
+ db.ForeignKey("media_item.id", ondelete="SET NULL"),
19
+ nullable=True,
20
+ index=True,
21
+ )
22
+ name = db.Column(db.String(255), default="")
23
+ link = db.Column(db.String(512), default="")
24
+ order = db.Column(db.Integer, default=0, index=True)
25
+ active = db.Column(db.Boolean, default=True, index=True)
26
+
27
+ media = db.relationship("MediaItem", lazy="joined")
28
+
29
+ @property
30
+ def logo_url(self):
31
+ return self.media.url if self.media else ""
32
+
33
+ def __repr__(self):
34
+ return f"Partner<{self.id}>"
@@ -0,0 +1,7 @@
1
+ from splent_io.splent_feature_partners.models import Partner
2
+ from splent_framework.repositories.BaseRepository import BaseRepository
3
+
4
+
5
+ class PartnersRepository(BaseRepository):
6
+ def __init__(self):
7
+ super().__init__(Partner)
@@ -0,0 +1,96 @@
1
+ from flask import (
2
+ flash,
3
+ redirect,
4
+ render_template,
5
+ request,
6
+ url_for,
7
+ )
8
+ from flask_login import login_required
9
+
10
+ from splent_io.splent_feature_partners import partners_bp
11
+ from splent_framework.services.service_locator import service_proxy
12
+
13
+ partners_service = service_proxy("PartnersService")
14
+
15
+
16
+ # =====================================================================
17
+ # PUBLIC
18
+ # =====================================================================
19
+ @partners_bp.route("/partners", methods=["GET"])
20
+ def index():
21
+ return render_template("partners/index.html")
22
+
23
+
24
+ # =====================================================================
25
+ # ADMIN — domain-specific management (the "plugin" screen)
26
+ # =====================================================================
27
+ def _images():
28
+ """Image-only media items offered in the picker."""
29
+ return [m for m in service_proxy("MediaService").list_recent() if m.is_image]
30
+
31
+
32
+ def _form_to_data(form):
33
+ media_id = (form.get("media_id") or "").strip()
34
+ return {
35
+ "media_id": int(media_id) if media_id else None,
36
+ "name": (form.get("name") or "").strip(),
37
+ "link": (form.get("link") or "").strip(),
38
+ "order": int(form.get("order") or 0),
39
+ "active": bool(form.get("active")),
40
+ }
41
+
42
+
43
+ @partners_bp.route("/admin/partners", methods=["GET"])
44
+ @login_required
45
+ def admin_index():
46
+ return render_template(
47
+ "partners/admin/list.html",
48
+ partners=partners_service.all_partners(),
49
+ )
50
+
51
+
52
+ @partners_bp.route("/admin/partners/new", methods=["GET", "POST"])
53
+ @login_required
54
+ def admin_new():
55
+ if request.method == "POST":
56
+ data = _form_to_data(request.form)
57
+ if not data["media_id"]:
58
+ flash("A logo is required.", "danger")
59
+ return redirect(url_for("partners.admin_new"))
60
+ partner = partners_service.create(**data)
61
+ flash(f"Added {partner.name or 'partner'}.", "success")
62
+ return redirect(url_for("partners.admin_index"))
63
+ return render_template("partners/admin/form.html", partner=None, media=_images())
64
+
65
+
66
+ @partners_bp.route("/admin/partners/<int:id>/edit", methods=["GET", "POST"])
67
+ @login_required
68
+ def admin_edit(id):
69
+ partner = partners_service.get(id)
70
+ if partner is None:
71
+ from flask import abort
72
+
73
+ abort(404)
74
+ if request.method == "POST":
75
+ data = _form_to_data(request.form)
76
+ if not data["media_id"]:
77
+ flash("A logo is required.", "danger")
78
+ return redirect(url_for("partners.admin_edit", id=id))
79
+ partners_service.update(partner, **data)
80
+ flash(f"Updated {partner.name or 'partner'}.", "success")
81
+ return redirect(url_for("partners.admin_index"))
82
+ return render_template("partners/admin/form.html", partner=partner, media=_images())
83
+
84
+
85
+ @partners_bp.route("/admin/partners/<int:id>/delete", methods=["POST"])
86
+ @login_required
87
+ def admin_delete(id):
88
+ partner = partners_service.get(id)
89
+ if partner is None:
90
+ from flask import abort
91
+
92
+ abort(404)
93
+ name = partner.name or "partner"
94
+ partners_service.delete(partner)
95
+ flash(f"Removed {name}.", "success")
96
+ return redirect(url_for("partners.admin_index"))
@@ -0,0 +1,10 @@
1
+ from splent_framework.seeders.BaseSeeder import BaseSeeder
2
+
3
+
4
+ class SplentFeaturePartnersSeeder(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,42 @@
1
+ from splent_io.splent_feature_partners.models import Partner
2
+ from splent_io.splent_feature_partners.repositories import PartnersRepository
3
+ from splent_framework.db import db
4
+ from splent_framework.services.BaseService import BaseService
5
+
6
+
7
+ class PartnersService(BaseService):
8
+ """Collaborating organisations shown on the homepage, each with a logo."""
9
+
10
+ def __init__(self):
11
+ super().__init__(PartnersRepository())
12
+
13
+ def active_partners(self):
14
+ """Active partners that still have a logo, in display order."""
15
+ partners = (
16
+ Partner.query.filter_by(active=True)
17
+ .order_by(Partner.order.asc(), Partner.id.asc())
18
+ .all()
19
+ )
20
+ return [p for p in partners if p.media is not None]
21
+
22
+ def all_partners(self):
23
+ return Partner.query.order_by(Partner.order.asc(), Partner.id.asc()).all()
24
+
25
+ def get(self, partner_id):
26
+ return Partner.query.get(partner_id)
27
+
28
+ def create(self, **data):
29
+ partner = Partner(**data)
30
+ db.session.add(partner)
31
+ db.session.commit()
32
+ return partner
33
+
34
+ def update(self, partner, **data):
35
+ for key, value in data.items():
36
+ setattr(partner, key, value)
37
+ db.session.commit()
38
+ return partner
39
+
40
+ def delete(self, partner):
41
+ db.session.delete(partner)
42
+ db.session.commit()
@@ -0,0 +1,19 @@
1
+ """
2
+ Signals for splent_feature_partners.
3
+
4
+ Use ``define_signal`` to declare signals this feature emits.
5
+ Use ``connect_signal`` to listen to signals from other features.
6
+
7
+ Examples::
8
+
9
+ # Define a signal (this feature emits it):
10
+ from splent_framework.signals.signal_utils import define_signal
11
+ item_created = define_signal("item-created", "splent_feature_partners")
12
+
13
+ # Connect to a signal from another feature:
14
+ from splent_framework.signals.signal_utils import connect_signal
15
+
16
+ @connect_signal("user-registered", "splent_feature_partners")
17
+ def on_user_registered(sender, user, **kwargs):
18
+ pass
19
+ """
@@ -0,0 +1,52 @@
1
+ {% extends "base_template.html" %}
2
+ {% from "media/_picker.html" import media_picker %}
3
+
4
+ {% block title %}{{ 'Edit partner' if partner else 'Add partner' }}{% endblock %}
5
+
6
+ {% block content %}
7
+ <div class="d-flex flex-column align-items-start gap-2 mb-4">
8
+ <h1 class="h3 mb-0">{{ 'Edit partner' if partner else 'Add partner' }}</h1>
9
+ <a href="{{ url_for('partners.admin_index') }}" class="btn btn-outline-secondary">← Back to partners</a>
10
+ </div>
11
+
12
+ {% with messages = get_flashed_messages(with_categories=true) %}
13
+ {% for category, message in messages %}
14
+ <div class="alert alert-{{ 'danger' if category == 'danger' else 'success' }} py-2">{{ message }}</div>
15
+ {% endfor %}
16
+ {% endwith %}
17
+
18
+ <form method="POST" class="card">
19
+ <div class="card-body">
20
+ <div class="row g-3">
21
+ <div class="col-md-6">
22
+ <label class="form-label">Name</label>
23
+ <input name="name" class="form-control" value="{{ partner.name if partner else '' }}" placeholder="Organisation name">
24
+ </div>
25
+ <div class="col-md-6">
26
+ <label class="form-label">Link URL</label>
27
+ <input name="link" type="url" class="form-control" value="{{ partner.link if partner else '' }}" placeholder="https://example.org">
28
+ <div class="form-text">Optional. Wraps the logo in a link when set.</div>
29
+ </div>
30
+ <div class="col-md-3">
31
+ <label class="form-label">Order</label>
32
+ <input name="order" type="number" class="form-control" value="{{ partner.order if partner else 0 }}">
33
+ </div>
34
+ <div class="col-md-3 d-flex align-items-end">
35
+ <div class="form-check">
36
+ <input class="form-check-input" type="checkbox" name="active" id="active" {{ 'checked' if (partner.active if partner else True) else '' }}>
37
+ <label class="form-check-label" for="active">Active</label>
38
+ </div>
39
+ </div>
40
+ <div class="col-12">
41
+ <label class="form-label">Logo *</label>
42
+ {{ media_picker("media_id", partner.media_id if partner else None, media, value_attr="id") }}
43
+ <div class="form-text">Pick a logo from the media library. Upload new logos in /admin/media.</div>
44
+ </div>
45
+ </div>
46
+ </div>
47
+ <div class="card-footer text-end">
48
+ <a href="{{ url_for('partners.admin_index') }}" class="btn btn-link text-muted">Cancel</a>
49
+ <button class="btn btn-primary" type="submit">{{ 'Save changes' if partner else 'Add partner' }}</button>
50
+ </div>
51
+ </form>
52
+ {% endblock %}
@@ -0,0 +1,70 @@
1
+ {% extends "base_template.html" %}
2
+
3
+ {% block title %}Partners{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="d-flex flex-column align-items-start gap-2 mb-4">
7
+ <div>
8
+ <h1 class="h3 mb-0">Partners</h1>
9
+ <p class="text-muted mb-0">Collaborating organisations shown on the homepage strip.</p>
10
+ </div>
11
+ <a href="{{ url_for('partners.admin_new') }}" class="btn btn-primary">+ Add partner</a>
12
+ </div>
13
+
14
+ {% with messages = get_flashed_messages(with_categories=true) %}
15
+ {% for category, message in messages %}
16
+ <div class="alert alert-{{ 'danger' if category == 'danger' else 'success' }} py-2">{{ message }}</div>
17
+ {% endfor %}
18
+ {% endwith %}
19
+
20
+ <div class="card">
21
+ <div class="table-responsive">
22
+ <table class="table table-hover align-middle mb-0">
23
+ <thead>
24
+ <tr>
25
+ <th style="width:64px">Logo</th>
26
+ <th>Name</th>
27
+ <th style="width:90px" class="text-center">Active</th>
28
+ <th style="width:80px" class="text-center">Order</th>
29
+ <th style="width:150px" class="text-end">Actions</th>
30
+ </tr>
31
+ </thead>
32
+ <tbody>
33
+ {% for p in partners %}
34
+ <tr>
35
+ <td>
36
+ {% if p.logo_url %}
37
+ <img src="{{ p.logo_url }}" alt="" style="width:44px;height:44px;border-radius:6px;object-fit:contain;background:#f8f9fa">
38
+ {% else %}
39
+ <span class="d-inline-flex align-items-center justify-content-center bg-light text-muted" style="width:44px;height:44px;border-radius:6px">{{ (p.name[:1] | upper) or '?' }}</span>
40
+ {% endif %}
41
+ </td>
42
+ <td>
43
+ <strong>{{ p.name or '(unnamed)' }}</strong>
44
+ {% if p.link %}<div class="text-muted small"><a href="{{ p.link }}" target="_blank" rel="noopener">{{ p.link }}</a></div>{% endif %}
45
+ </td>
46
+ <td class="text-center">
47
+ {% if p.active %}
48
+ <span class="badge bg-success">Active</span>
49
+ {% else %}
50
+ <span class="badge bg-secondary">Hidden</span>
51
+ {% endif %}
52
+ </td>
53
+ <td class="text-center">{{ p.order }}</td>
54
+ <td class="text-end">
55
+ <a href="{{ url_for('partners.admin_edit', id=p.id) }}" class="btn btn-sm btn-outline-primary">Edit</a>
56
+ <form method="POST" action="{{ url_for('partners.admin_delete', id=p.id) }}" class="d-inline" onsubmit="return confirm('Remove {{ p.name or 'this partner' }}?')">
57
+ <button class="btn btn-sm btn-outline-danger" type="submit" title="Remove"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg></button>
58
+ </form>
59
+ </td>
60
+ </tr>
61
+ {% else %}
62
+ <tr>
63
+ <td colspan="5" class="text-center text-muted py-4">No partners yet. Add your first collaborating organisation.</td>
64
+ </tr>
65
+ {% endfor %}
66
+ </tbody>
67
+ </table>
68
+ </div>
69
+ </div>
70
+ {% endblock %}
@@ -0,0 +1,10 @@
1
+ {% extends "base_template.html" %}
2
+
3
+ {% block title %}Partners{% endblock %}
4
+
5
+ {% block content %}
6
+
7
+ {% endblock %}
8
+
9
+ {% block scripts %}
10
+ {% endblock %}
@@ -0,0 +1,26 @@
1
+ {# Public partners strip. Rendered by the home.section hook with:
2
+ partners : active partners (each has .logo_url, .name, .link)
3
+ cfg : get_config("partners") -> title / scope / grayscale
4
+ Renders nothing when there are no active partners. #}
5
+ {% if partners %}
6
+ <section class="partners-strip{{ ' partners-strip--grayscale' if cfg.grayscale in (true, '1', 1, 'true') else '' }}">
7
+ <div class="container">
8
+ {% if cfg.title %}
9
+ <h2 class="partners-strip__title">{{ cfg.title }}</h2>
10
+ {% endif %}
11
+ <div class="partners-strip__row">
12
+ {% for p in partners %}
13
+ {% if p.link %}
14
+ <a class="partners-strip__item" href="{{ p.link }}" target="_blank" rel="noopener" title="{{ p.name }}">
15
+ <img class="partners-strip__logo" src="{{ p.logo_url }}" alt="{{ p.name }}" loading="lazy">
16
+ </a>
17
+ {% else %}
18
+ <span class="partners-strip__item" title="{{ p.name }}">
19
+ <img class="partners-strip__logo" src="{{ p.logo_url }}" alt="{{ p.name }}" loading="lazy">
20
+ </span>
21
+ {% endif %}
22
+ {% endfor %}
23
+ </div>
24
+ </div>
25
+ </section>
26
+ {% endif %}