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.
- splent_feature_partners-0.2.1/MANIFEST.in +8 -0
- splent_feature_partners-0.2.1/PKG-INFO +5 -0
- splent_feature_partners-0.2.1/pyproject.toml +55 -0
- splent_feature_partners-0.2.1/setup.cfg +4 -0
- splent_feature_partners-0.2.1/src/splent_feature_partners.egg-info/PKG-INFO +5 -0
- splent_feature_partners-0.2.1/src/splent_feature_partners.egg-info/SOURCES.txt +27 -0
- splent_feature_partners-0.2.1/src/splent_feature_partners.egg-info/dependency_links.txt +1 -0
- splent_feature_partners-0.2.1/src/splent_feature_partners.egg-info/top_level.txt +1 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/__init__.py +49 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/assets/dist/splent_feature_partners.bundle.js +20 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/assets/dist/splent_feature_partners.bundle.js.map +1 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/commands.py +21 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/config.py +19 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/forms.py +6 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/hooks.py +58 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/migrations/alembic.ini +36 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/migrations/env.py +9 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/migrations/script.py.mako +24 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/migrations/versions/partners0001_initial.py +37 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/models.py +34 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/repositories.py +7 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/routes.py +96 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/seeders.py +10 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/services.py +42 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/signals.py +19 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/templates/partners/admin/form.html +52 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/templates/partners/admin/list.html +70 -0
- splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/templates/partners/index.html +10 -0
- 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,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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
splent_io
|
|
@@ -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,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,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,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 %}
|
splent_feature_partners-0.2.1/src/splent_io/splent_feature_partners/templates/partners/strip.html
ADDED
|
@@ -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 %}
|