From f31e05e8e7eb39b67fde32eab42ff6b577a6a4ab Mon Sep 17 00:00:00 2001 From: Greg Kaleka Date: Tue, 10 Feb 2026 15:19:42 -0500 Subject: [PATCH] Add testimonials (#2069) --- ak/views.py | 4 + config/settings.py | 13 ++ config/urls.py | 11 +- frontend/styles.css | 8 ++ marketing/models.py | 19 +-- static/css/testimonial-style.css | 16 +++ templates/homepage.html | 16 +++ templates/testimonials/testimonial.html | 32 +++++ .../testimonials/testimonials_index_page.html | 38 ++++++ testimonials/__init__.py | 0 testimonials/admin.py | 1 + testimonials/apps.py | 5 + testimonials/management/__init__.py | 0 testimonials/management/commands/__init__.py | 0 .../commands/load_initial_testimonials.py | 129 ++++++++++++++++++ testimonials/migrations/0001_initial.py | 122 +++++++++++++++++ testimonials/migrations/__init__.py | 0 testimonials/models.py | 80 +++++++++++ testimonials/tests.py | 1 + testimonials/urls.py | 12 ++ testimonials/views.py | 24 ++++ 21 files changed, 514 insertions(+), 17 deletions(-) create mode 100644 static/css/testimonial-style.css create mode 100644 templates/testimonials/testimonial.html create mode 100644 templates/testimonials/testimonials_index_page.html create mode 100644 testimonials/__init__.py create mode 100644 testimonials/admin.py create mode 100644 testimonials/apps.py create mode 100644 testimonials/management/__init__.py create mode 100644 testimonials/management/commands/__init__.py create mode 100644 testimonials/management/commands/load_initial_testimonials.py create mode 100644 testimonials/migrations/0001_initial.py create mode 100644 testimonials/migrations/__init__.py create mode 100644 testimonials/models.py create mode 100644 testimonials/tests.py create mode 100644 testimonials/urls.py create mode 100644 testimonials/views.py diff --git a/ak/views.py b/ak/views.py index 61f26f66..308ba50e 100644 --- a/ak/views.py +++ b/ak/views.py @@ -11,6 +11,7 @@ from core.calendar import extract_calendar_events, events_by_month, get_calendar from libraries.constants import LATEST_RELEASE_URL_PATH_STR from libraries.mixins import ContributorMixin from news.models import Entry +from testimonials.models import Testimonial logger = structlog.get_logger() @@ -27,6 +28,9 @@ class HomepageView(ContributorMixin, TemplateView): context = super().get_context_data(**kwargs) context["entries"] = Entry.objects.published().order_by("-publish_at")[:3] context["events"] = self.get_events() + context["testimonial"] = ( + Testimonial.objects.live().filter(pull_quote__gt="").last() + ) if context["events"]: context["num_months"] = len(context["events"]) else: diff --git a/config/settings.py b/config/settings.py index 1c2fdbcb..43ece0a6 100755 --- a/config/settings.py +++ b/config/settings.py @@ -120,6 +120,7 @@ INSTALLED_APPS += [ "reports", "core", "slack", + "testimonials", "asciidoctor_sandbox", ] @@ -644,6 +645,18 @@ WAGTAILDOCS_EXTENSIONS = [ "xlsx", "zip", ] +RICH_TEXT_FEATURES = [ + "h1", + "h2", + "h3", + "bold", + "italic", + "link", + "ol", + "ul", + "code", + "blockquote", +] WAGTAILMARKDOWN = { "autodownload_fontawesome": True, "allowed_tags": [], # optional. a list of HTML tags. e.g. ['div', 'p', 'a'] diff --git a/config/urls.py b/config/urls.py index 83f02d3c..f8c303e3 100755 --- a/config/urls.py +++ b/config/urls.py @@ -409,7 +409,8 @@ urlpatterns = ( # Wagtail stuff path("cms/", include(wagtailadmin_urls)), path("documents/", include(wagtaildocs_urls)), - path("outreach/", include(wagtail_urls)), + # Custom Django views (must come before Wagtail catch-all) + path("testimonials/", include("testimonials.urls")), ] + [ re_path( @@ -480,14 +481,18 @@ urlpatterns = ( ImageView.as_view(), name="images-page", ), - # Static content + # Static content (exclude Wagtail paths) re_path( - r"^(?!__debug__)(?P.+)/?", + r"^(?!__debug__|outreach/|testimonials/)(?P.+)/?", StaticContentTemplateView.as_view(), name="static-content-page", ), ] + djdt_urls + + [ + # Wagtail catch-all (must be last!) + path("", include(wagtail_urls)), + ] ) handler404 = "ak.views.custom_404_view" diff --git a/frontend/styles.css b/frontend/styles.css index 30e35d8c..69a5eed9 100644 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -173,3 +173,11 @@ html:has(#docsiframe) .version_alert { .version_alert a { @apply font-semibold underline dark:text-white text-charcoal; } + +.testimonials blockquote { + font-style: italic; +} + +.testimonials a { + @apply text-sky-600 dark:text-sky-300 hover:text-orange dark:hover:text-orange; +} diff --git a/marketing/models.py b/marketing/models.py index 1781cf09..0f896f6a 100644 --- a/marketing/models.py +++ b/marketing/models.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.contrib import messages from django.db import models from django.http import Http404 @@ -10,19 +11,6 @@ from wagtail.models import Page from wagtail.url_routing import RouteResult from wagtailmarkdown.blocks import MarkdownBlock -RICH_TEXT_FEATURES = [ - "h1", - "h2", - "h3", - "bold", - "italic", - "link", - "ol", - "ul", - "code", - "blockquote", -] - class CapturedEmail(models.Model): email = models.EmailField() @@ -76,7 +64,10 @@ class EmailCapturePage(Page): ) body = StreamField( [ - ("rich", RichTextBlock(features=RICH_TEXT_FEATURES, label="Rich text")), + ( + "rich", + RichTextBlock(features=settings.RICH_TEXT_FEATURES, label="Rich text"), + ), ("md", MarkdownBlock(label="Markdown")), ], use_json_field=True, diff --git a/static/css/testimonial-style.css b/static/css/testimonial-style.css new file mode 100644 index 00000000..57cc19a4 --- /dev/null +++ b/static/css/testimonial-style.css @@ -0,0 +1,16 @@ +hr {margin:2rem auto; width: 150px;} +p {padding-top: 0.5rem; padding-bottom: 0.5rem;} +a {color:#0284c7;} +a:hover, a:active {color:darkblue} +a:visited {color:#0284c7;} +h1 {font-size: 1.44rem; font-weight:700;} +h2 {font-size: 1rem; font-weight:600; text-transform: uppercase;} +h3 {font-size: 1.1rem; font-weight:700;} +h4 {font-size: 0.95rem; font-weight:700;} +h5 {font-size: 0.69rem; font-weight:700;} +ul, ol {margin: 1rem 0; padding-left: 2rem;} +ul {list-style-type: disc;} +ol {list-style-type: decimal;} +li {padding: 0.25rem 0;} +ul ul, ol ul {list-style-type: circle;} +ol ol, ul ol {list-style-type: lower-alpha;} diff --git a/templates/homepage.html b/templates/homepage.html index 7e3dd063..9dd8930b 100644 --- a/templates/homepage.html +++ b/templates/homepage.html @@ -59,6 +59,22 @@ + {% if testimonial %} +
+
+
Testimonials
+
+
+ {{ testimonial.pull_quote }} +
+ + + +
+
+
+ {% endif %} + {% if events %}
diff --git a/templates/testimonials/testimonial.html b/templates/testimonials/testimonial.html new file mode 100644 index 00000000..28c76cde --- /dev/null +++ b/templates/testimonials/testimonial.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% load static wagtailcore_tags %} + +{% block title %} + {{ page.author }} - Testimonial +{% endblock %} + +{% block css %} + +{% endblock %} + +{% block content %} +
+
+

{{ page.title }}

+

+ By + {% if page.author_url %} + {{ page.author }} + {% else %} + {{ page.author }} + {% endif %} +

+ +
+
+ {{ page.body }} +
+
+
+
+{% endblock %} diff --git a/templates/testimonials/testimonials_index_page.html b/templates/testimonials/testimonials_index_page.html new file mode 100644 index 00000000..e3371214 --- /dev/null +++ b/templates/testimonials/testimonials_index_page.html @@ -0,0 +1,38 @@ +{% extends '_base.html' %} +{% load i18n %} + +{% block title %} + Testimonials +{% endblock %} + +{% block content %} +
+
+

Testimonials

+ +
+ {% for testimonial in testimonials %} +
+

+ + {{ testimonial.author }} + +

+ + {% if testimonial.pull_quote %} +
+ "{{ testimonial.pull_quote }}" +
+ {% endif %} + + + Read more → + +
+ {% empty %} +

No testimonials yet.

+ {% endfor %} +
+
+
+{% endblock %} diff --git a/testimonials/__init__.py b/testimonials/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testimonials/admin.py b/testimonials/admin.py new file mode 100644 index 00000000..846f6b40 --- /dev/null +++ b/testimonials/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/testimonials/apps.py b/testimonials/apps.py new file mode 100644 index 00000000..443767f8 --- /dev/null +++ b/testimonials/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class TestimonialsConfig(AppConfig): + name = "testimonials" diff --git a/testimonials/management/__init__.py b/testimonials/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testimonials/management/commands/__init__.py b/testimonials/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testimonials/management/commands/load_initial_testimonials.py b/testimonials/management/commands/load_initial_testimonials.py new file mode 100644 index 00000000..02e270e6 --- /dev/null +++ b/testimonials/management/commands/load_initial_testimonials.py @@ -0,0 +1,129 @@ +from django.core.management.base import BaseCommand +from wagtail.models import Page, Site + +from testimonials.models import Testimonial, TestimonialsIndexPage + +# Initial testimonial data +INITIAL_TESTIMONIAL = { + "author": "Oleg Trott, PHD", + "author_slug": "oleg_trott", + "author_url": "https://www.olegtrott.com", + "pull_quote": [ + { + "id": "4c6ac70f-fd45-4ab5-85d7-69a790c14515", + "type": "md", + "value": """> What I really liked about Boost was that the libraries are peer-reviewed, raising expectations about quality and security. And I don't think I encountered a single bug in any of the Boost libraries I used. My thanks to the developers! + +\\- [Oleg Trott](https://www.olegtrott.com), PHD +
+Creator, [AutoDoc Vina](https://vina.scripps.edu/)""", + } + ], + "title": "The use of Boost C++ libraries in drug discovery", + "body": [ + { + "id": "92af493a-144c-406c-acce-ac10ab865911", + "type": "md", + "value": """[AutoDock Vina](https://vina.scripps.edu/) is the most popular molecular docking program, with [40,000 citations](https://scholar.google.com/citations?user=4BD7MkgAAAAJ), as of this writing. It is used widely to look for treatments for various diseases from cardiovascular and infectious ones to cancer. I created AutoDock Vina back when I was a postdoc at The Scripps Research Institute. And Boost C++ libraries were of great help. + +The mechanisms of action of various drugs are different in each case, but what they have in common is that the drug (typically a small molecule consisting of dozens of atoms) binds a huge molecule, like a protein (consisting of thousands of atoms). This binding is normally non-covalent (think "physics", not "chemistry"). It is also quite specific – the shape and other properties of the drug have to be complementary to the protein, not unlike a lock and key. This binding interferes with the normal operation of the protein in question, and this may have some desired biological effect. + +Modeling this binding process computationally is challenging, but, if done well, it can predict which small molecules would be promising as drugs. + +When I got hired by The Scripps Research Institute almost 20 years ago, they had already been developing a molecular docking program, which they called "AutoDock", for many years. AutoDock was being used widely, including in huge efforts like the IBM World Community Grid, where volunteers contributed their personal compute to do docking calculations. In one such project, AutoDock was being used to look for new anti-HIV drugs. I estimated that in that single project, millions of dollars were being spent just on electricity (a cost borne by the volunteers). So performance was important. + +Initially, my plan was to contribute to AutoDock, but after a few weeks on the job, I realized that the best path forward would be to write a new docking program instead. I thought I could re-implement the same or equivalent algorithm in a fraction of the lines of code, using modern (at the time) C++, employing STL and Boost. + +While I didn't get fired right away, I'll say this: If you set out to do something ambitious in academia, the clock starts ticking for you, because while you are busy working on your new high-effort and high-risk project, you are probably not publishing some low-effort and low-risk work that is encouraged in academia. And what if your project fails? Rather perilous for your career. + +To make matters worse, during this rewrite, my ambitions grew much further. I was no longer content with just a rewrite and started experimenting with alternative algorithms and scoring functions. (The scoring function tells us which binding is better.) Long story short, after 1.5 years, I released a new docking program and called it "VINA" (short for "VINA Is Not AutoDock"). It was superior to AutoDock: + +* It was roughly 60 times faster, when using a single thread (potentially saving many millions in electricity and compute) +* Additionally, it supported parallelism across multiple CPU cores seamlessly +* It was significantly more accurate in its binding pose predictions, on average +* It supported all major platforms directly (AutoDock required a Unix-like environment) +* The code was a few times smaller + +Later, I was asked to change the name to "AutoDock Vina". "AutoDock" became a brand, rather than the name of a particular program. Sadly, this is causing confusion to this day. Many people think that "Vina" was a new version of old software, but it was brand-new and simpler code implementing a more complex algorithm. + +Boost C++ libraries were quite useful to me in cutting down on the development time, which as I mentioned was important. In particular, I used + +* Boost.Thread – it enabled parallelism in a platform-independent way +* Boost.Serialization – for object persistence +* Boost.Math – for quaternions, which are used to represent 3D rotations conveniently +(Boost.QVM would have been more appropriate, but I don't think it was part of Boost back then) +* Boost.ProgramOptions – for parsing command line options and configuration files, as well as to +display the help message +* Boost.Filesystem – for handling files in a platform-independent way +* Boost.PointerContainer – for containers of pointers to objects +* Boost.Array – for "vectors" of statically known length +* Boost.Optional – for objects that may or may not be there +* Boost.LexicalCast – for parsing numbers, mostly +* Boost.Random – for thread-safe random number generation +* Boost.Timer – to show the users a progress bar, while they are waiting for the results + +Since then, some of these libraries made it into the C++ standard, I believe. + +What I really liked about Boost was that the libraries are peer-reviewed, raising expectations about quality and security. And I don't think I encountered a single bug in any of the Boost libraries I used. My thanks to the developers!""", + } + ], +} + + +class Command(BaseCommand): + help = "Load initial testimonials data" + + def handle(self, *args, **options): + # Check if testimonials index page already exists + if TestimonialsIndexPage.objects.exists(): + self.stdout.write( + self.style.WARNING( + "Testimonials index page already exists. Skipping data load." + ) + ) + return + + # Get the site root page (typically the homepage) + try: + site = Site.objects.get(is_default_site=True) + root_page = site.root_page + except Site.DoesNotExist: + # Fallback to the root page if no site exists + root_page = Page.objects.get(depth=1) + + # Create the testimonials index page + self.stdout.write("Creating testimonials index page...") + index_page = TestimonialsIndexPage( + title="Testimonials", + slug="testimonials", + show_in_menus=False, + ) + root_page.add_child(instance=index_page) + index_page.save_revision().publish() + self.stdout.write( + self.style.SUCCESS(f"Created testimonials index page (ID: {index_page.id})") + ) + + # Create the initial testimonial + self.stdout.write( + f"Creating testimonial for {INITIAL_TESTIMONIAL['author']}..." + ) + testimonial = Testimonial( + title=INITIAL_TESTIMONIAL["title"], + slug=INITIAL_TESTIMONIAL["author_slug"], + author=INITIAL_TESTIMONIAL["author"], + author_slug=INITIAL_TESTIMONIAL["author_slug"], + author_url=INITIAL_TESTIMONIAL["author_url"], + pull_quote=INITIAL_TESTIMONIAL["pull_quote"], + body=INITIAL_TESTIMONIAL["body"], + show_in_menus=False, + ) + index_page.add_child(instance=testimonial) + testimonial.save_revision().publish() + self.stdout.write( + self.style.SUCCESS(f"Created testimonial (ID: {testimonial.id})") + ) + + self.stdout.write( + self.style.SUCCESS("\nSuccessfully loaded initial testimonials data!") + ) diff --git a/testimonials/migrations/0001_initial.py b/testimonials/migrations/0001_initial.py new file mode 100644 index 00000000..225e98b2 --- /dev/null +++ b/testimonials/migrations/0001_initial.py @@ -0,0 +1,122 @@ +# Generated by Django 6.0.2 on 2026-02-10 20:02 + +import django.db.models.deletion +import wagtail.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ("wagtailcore", "0096_referenceindex_referenceindex_source_object_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Testimonial", + fields=[ + ( + "page_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.page", + ), + ), + ("author", models.CharField(max_length=255)), + ( + "author_slug", + models.SlugField( + help_text="Slug used for author's URL - must be unique", + unique=True, + ), + ), + ( + "author_url", + models.URLField( + blank=True, + default="", + help_text="Optional URL to link the author's name to", + ), + ), + ( + "pull_quote", + wagtail.fields.StreamField( + [("md", 0)], + blank=True, + block_lookup={ + 0: ( + "wagtailmarkdown.blocks.MarkdownBlock", + (), + {"label": "Markdown"}, + ) + }, + help_text="Optional pull quote to highlight on the homepage", + ), + ), + ( + "body", + wagtail.fields.StreamField( + [("rich", 0), ("md", 1)], + blank=True, + block_lookup={ + 0: ( + "wagtail.blocks.RichTextBlock", + (), + { + "features": [ + "h1", + "h2", + "h3", + "bold", + "italic", + "link", + "ol", + "ul", + "code", + "blockquote", + ], + "label": "Rich text", + }, + ), + 1: ( + "wagtailmarkdown.blocks.MarkdownBlock", + (), + {"label": "Markdown"}, + ), + }, + ), + ), + ], + options={ + "verbose_name": "Testimonial", + "verbose_name_plural": "Testimonials", + }, + bases=("wagtailcore.page",), + ), + migrations.CreateModel( + name="TestimonialsIndexPage", + fields=[ + ( + "page_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="wagtailcore.page", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("wagtailcore.page",), + ), + ] diff --git a/testimonials/migrations/__init__.py b/testimonials/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/testimonials/models.py b/testimonials/models.py new file mode 100644 index 00000000..0bacfa07 --- /dev/null +++ b/testimonials/models.py @@ -0,0 +1,80 @@ +from django.conf import settings +from django.db import models +from django.urls import reverse +from wagtail.admin.panels import FieldPanel +from wagtail.blocks import RichTextBlock +from wagtail.fields import StreamField +from wagtail.models import Page +from wagtailmarkdown.blocks import MarkdownBlock + + +class TestimonialsIndexPage(Page): + """Container page for all testimonials.""" + + max_count = 1 # Only allow one testimonials index page + parent_page_types = ["wagtailcore.Page"] + subpage_types = ["testimonials.Testimonial"] + + def get_url(self, request=None, current_site=None): + """Override to return the correct URL for this page.""" + return reverse("testimonials-index") + + def get_context(self, request, *args, **kwargs): + context = super().get_context(request, *args, **kwargs) + # Get all live testimonials that are children of this page + context["testimonials"] = ( + Testimonial.objects.live().child_of(self).order_by("-first_published_at") + ) + return context + + +class Testimonial(Page): + author = models.CharField(max_length=255) + author_slug = models.SlugField( + help_text="Slug used for author's URL - must be unique", unique=True + ) + author_url = models.URLField( + help_text="Optional URL to link the author's name to", blank=True, default="" + ) + pull_quote = StreamField( + [ + ("md", MarkdownBlock(label="Markdown")), + ], + use_json_field=True, + blank=True, + help_text="Optional pull quote to highlight on the homepage", + ) + body = StreamField( + [ + ( + "rich", + RichTextBlock(features=settings.RICH_TEXT_FEATURES, label="Rich text"), + ), + ("md", MarkdownBlock(label="Markdown")), + ], + use_json_field=True, + blank=True, + ) + + # Configure Wagtail admin panels + content_panels = Page.content_panels + [ + FieldPanel("title"), + FieldPanel("author"), + FieldPanel("author_slug"), + FieldPanel("author_url"), + FieldPanel("pull_quote"), + FieldPanel("body"), + ] + + # Define where this page type can be created + parent_page_types = ["testimonials.TestimonialsIndexPage"] + subpage_types = [] # Testimonials can't have child pages + + def get_url(self, request=None, current_site=None): + """Override to return the correct URL for this page.""" + # Use the page's slug (set in Wagtail admin) for the URL + return reverse("testimonial-detail", kwargs={"author_slug": self.slug}) + + class Meta: + verbose_name = "Testimonial" + verbose_name_plural = "Testimonials" diff --git a/testimonials/tests.py b/testimonials/tests.py new file mode 100644 index 00000000..a39b155a --- /dev/null +++ b/testimonials/tests.py @@ -0,0 +1 @@ +# Create your tests here. diff --git a/testimonials/urls.py b/testimonials/urls.py new file mode 100644 index 00000000..d25438eb --- /dev/null +++ b/testimonials/urls.py @@ -0,0 +1,12 @@ +from django.urls import path + +from .views import TestimonialsIndexView, TestimonialDetailView + +urlpatterns = [ + path("", TestimonialsIndexView.as_view(), name="testimonials-index"), + path( + "/", + TestimonialDetailView.as_view(), + name="testimonial-detail", + ), +] diff --git a/testimonials/views.py b/testimonials/views.py new file mode 100644 index 00000000..4f48b744 --- /dev/null +++ b/testimonials/views.py @@ -0,0 +1,24 @@ +from django.views.generic import DetailView, ListView + +from .models import Testimonial + + +class TestimonialsIndexView(ListView): + """Optional non-Wagtail view for listing testimonials.""" + + model = Testimonial + template_name = "testimonials/testimonials_index_page.html" + context_object_name = "testimonials" + + def get_queryset(self): + return Testimonial.objects.live().order_by("-first_published_at") + + +class TestimonialDetailView(DetailView): + """Optional non-Wagtail view for testimonial detail.""" + + model = Testimonial + template_name = "testimonials/testimonial.html" + slug_field = "author_slug" + slug_url_kwarg = "author_slug" + context_object_name = "page" # Changed to 'page' to match Wagtail convention