diff --git a/openstax/settings/base.py b/openstax/settings/base.py index cd2f297e4..fc8b8a5f9 100644 --- a/openstax/settings/base.py +++ b/openstax/settings/base.py @@ -233,6 +233,7 @@ 'rangefilter', 'reversion', 'wagtail_modeladmin', + 'wagtailautocomplete', # custom 'accounts', 'api', diff --git a/openstax/urls.py b/openstax/urls.py index 7c5d4c428..61f236e0f 100644 --- a/openstax/urls.py +++ b/openstax/urls.py @@ -3,6 +3,7 @@ from django.conf.urls.static import static from django.contrib import admin from wagtail.admin import urls as wagtailadmin_urls +from wagtailautocomplete.urls.admin import urlpatterns as autocomplete_admin_urls from wagtail import urls as wagtail_urls from wagtail.documents import urls as wagtaildocs_urls from accounts import urls as accounts_urls @@ -17,6 +18,7 @@ admin.site.site_header = 'OpenStax' urlpatterns = [ + path('admin/autocomplete/', include(autocomplete_admin_urls)), path('admin/', include(wagtailadmin_urls)), path('django-admin/error/', throw_error, name='throw_error'), diff --git a/pages/migrations/0163_rootpage_school.py b/pages/migrations/0163_rootpage_school.py new file mode 100644 index 000000000..7e603759b --- /dev/null +++ b/pages/migrations/0163_rootpage_school.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.12 on 2025-12-05 18:24 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pages", "0162_assignable_cta_section_footer"), + ("salesforce", "0114_school_industry"), + ] + + operations = [ + migrations.AddField( + model_name="rootpage", + name="school", + field=models.ForeignKey( + blank=True, + help_text="Link a school to this landing page. School information will be included in the API response.", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="salesforce.school", + ), + ), + ] diff --git a/pages/models.py b/pages/models.py index f07a90c7a..809f38db7 100644 --- a/pages/models.py +++ b/pages/models.py @@ -1,9 +1,10 @@ from django import forms from django.db import models from django.shortcuts import render - +from django.utils.functional import cached_property from modelcluster.fields import ParentalKey from wagtail.admin.panels import FieldPanel, InlinePanel, MultiFieldPanel, TitleFieldPanel +from wagtailautocomplete.edit_handlers import AutocompletePanel from wagtail.admin.widgets.slug import SlugInput from wagtail import blocks from wagtail.fields import RichTextField, StreamField @@ -17,7 +18,7 @@ from webinars.models import Webinar from news.models import BlogStreamBlock # for use on the ImpactStories -from salesforce.models import PartnerTypeMapping, PartnerFieldNameMapping, PartnerCategoryMapping, Partner +from salesforce.models import PartnerTypeMapping, PartnerFieldNameMapping, PartnerCategoryMapping, Partner, School from .custom_blocks import ImageBlock, \ APIImageChooserBlock, \ @@ -188,9 +189,31 @@ class RootPage(Page): related_name='+' ) + school = models.ForeignKey( + School, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='+', + help_text='Link a school to this landing page. School information will be included in the API response.' + ) + + def get_school_data(self): + """Return serialized school data for API""" + if self.school: + from salesforce.serializers import SchoolSerializer + serializer = SchoolSerializer(self.school) + return serializer.data + return None + + @cached_property + def school_data(self): + return self.get_school_data() + api_fields = [ APIField('layout'), APIField('body'), + APIField('school_data'), APIField('slug'), APIField('seo_title'), APIField('search_description'), @@ -199,6 +222,7 @@ class RootPage(Page): content_panels = [ TitleFieldPanel('title', help_text="For CMS use only. Use 'Promote' tab above to edit SEO information."), FieldPanel('layout'), + AutocompletePanel('school'), FieldPanel('body'), ] @@ -211,9 +235,7 @@ class RootPage(Page): template = 'page.html' max_count = 1 - # TODO: we are allowing this to be built as a child of the homepage. Not ideal. - # Once the home page is released, use something to migrate homepage children to root page and remove this parent type. - parent_page_types = ['wagtailcore.Page', 'pages.HomePage'] + parent_page_types = ['wagtailcore.Page'] def __str__(self): return self.path diff --git a/pages/static/pages/conditional-school-field.js b/pages/static/pages/conditional-school-field.js new file mode 100644 index 000000000..0fa517c77 --- /dev/null +++ b/pages/static/pages/conditional-school-field.js @@ -0,0 +1,161 @@ +(function() { + // Get jQuery - try django.jQuery first (Django admin), then fall back to regular jQuery + var getJQuery = function() { + if (typeof django !== 'undefined' && django.jQuery) { + return django.jQuery; + } else if (typeof jQuery !== 'undefined') { + return jQuery; + } else if (typeof $ !== 'undefined') { + return $; + } + return null; + }; + + // Wait for jQuery to be available + var init = function() { + var $ = getJQuery(); + if (!$) { + // jQuery not ready yet, try again + setTimeout(init, 50); + return; + } + + $(document).ready(function(){ + function checkAndToggleSchoolField() { + // Find the layout StreamField container + // Wagtail StreamFields have a data-contentpath attribute + var $layoutField = $('[data-contentpath="layout"]'); + + if ($layoutField.length === 0) { + return; + } + + // Check if there's a block with type "landing" (displayed as "Landing Page") + var hasLandingPage = false; + + // Method 1: Check for the block type label "Landing Page" in the layout field + var $landingPageLabel = $layoutField.find('.c-sf-block_type').filter(function() { + return $(this).text().trim() === 'Landing Page'; + }); + + if ($landingPageLabel.length > 0) { + hasLandingPage = true; + } else { + // Method 2: Check hidden inputs for block type value "landing" + $layoutField.find('input[type="hidden"][name*="type"], input[type="hidden"][name*="block_type"]').each(function() { + if ($(this).val() === 'landing') { + hasLandingPage = true; + return false; // break + } + }); + + // Method 3: Check StreamField value directly via JSON data + if (!hasLandingPage) { + var $hiddenInput = $layoutField.find('input[type="hidden"][name*="layout"]'); + if ($hiddenInput.length > 0) { + try { + var streamValue = JSON.parse($hiddenInput.val() || '[]'); + if (Array.isArray(streamValue) && streamValue.length > 0) { + hasLandingPage = streamValue[0].type === 'landing'; + } + } catch (e) { + // JSON parse failed, continue with other methods + } + } + } + } + + // Find the School field panel + // Try multiple selectors based on Wagtail panel structure + var $schoolPanel = null; + + // Try by ID first (based on image description showing panel IDs) + var schoolPanelIds = [ + '#panel-child-content-school', + '#panel-child-content-school-section', + '[id*="panel"][id*="school"]' + ]; + + for (var i = 0; i < schoolPanelIds.length; i++) { + var $found = $(schoolPanelIds[i]).closest('.w-panel, section.w-panel'); + if ($found.length > 0) { + $schoolPanel = $found; + break; + } + } + + // If not found by ID, try finding by label/heading text "School" + // Scope search to the form container to avoid false matches + if (!$schoolPanel || $schoolPanel.length === 0) { + var $formContainer = $('.w-form-width, .w-form, [class*="wagtail"]').first(); + if ($formContainer.length === 0) { + $formContainer = $('form').first(); + } + + $formContainer.find('label, .w-panel_heading, h2, [data-panel-heading-text]').each(function() { + var $el = $(this); + var text = $el.text().trim(); + if (text.toLowerCase() === 'school') { + $schoolPanel = $el.closest('.w-panel, section.w-panel'); + if ($schoolPanel.length > 0) { + return false; // break + } + } + }); + } + + // Last resort: try finding by data-contentpath + if (!$schoolPanel || $schoolPanel.length === 0) { + $schoolPanel = $('[data-contentpath="school"]').closest('.w-panel, section.w-panel'); + } + + // Show or hide the School panel + if ($schoolPanel && $schoolPanel.length > 0) { + if (hasLandingPage) { + $schoolPanel.show(); + } else { + $schoolPanel.hide(); + } + } + } + + // Check on page load + checkAndToggleSchoolField(); + + // Also check after a delay to ensure StreamField is fully initialized + setTimeout(checkAndToggleSchoolField, 500); + + // Listen for StreamField changes (when blocks are added/removed/changed) + // Wagtail triggers custom events on StreamField changes + $(document).on('wagtail:stream-field-block-added wagtail:stream-field-block-removed wagtail:stream-field-block-moved', function() { + setTimeout(checkAndToggleSchoolField, 100); + }); + + // Also listen for any changes in the layout field container + var $layoutField = $('[data-contentpath="layout"]'); + if ($layoutField.length > 0) { + // Use MutationObserver to watch for DOM changes in the layout field + var observer = new MutationObserver(function(mutations) { + checkAndToggleSchoolField(); + }); + + observer.observe($layoutField[0], { + childList: true, + subtree: true, + attributes: true, + characterData: true + }); + } + + // Fallback: Also check periodically (with debouncing) + var checkTimeout; + $(document).on('change click input', '[data-contentpath="layout"]', function() { + clearTimeout(checkTimeout); + checkTimeout = setTimeout(checkAndToggleSchoolField, 200); + }); + }); + }; + + // Start initialization + init(); +})(); diff --git a/pages/wagtail_hooks.py b/pages/wagtail_hooks.py new file mode 100644 index 000000000..fbaf39ce8 --- /dev/null +++ b/pages/wagtail_hooks.py @@ -0,0 +1,15 @@ +from django.templatetags.static import static +from django.utils.html import format_html +from wagtail import hooks +from pages.models import RootPage + +@hooks.register('insert_editor_js') +def conditional_school_field_js(request): + """Inject JavaScript to conditionally show school field only for RootPage edit views""" + # Only inject JS if editing a RootPage instance + if hasattr(request, 'instance') and isinstance(getattr(request, 'instance', None), RootPage): + return format_html( + '', + static('pages/conditional-school-field.js') + ) + return '' diff --git a/requirements/base.txt b/requirements/base.txt index 10a1cfb29..1dad103d9 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -31,5 +31,6 @@ ua-parser==1.0.1 unicodecsv==0.14.1 vcrpy==7.0.0 wagtail==7.1.1 +wagtail-autocomplete==0.12.0 wagtail-modeladmin==2.2.0 whitenoise==6.9.0 diff --git a/salesforce/management/commands/update_schools.py b/salesforce/management/commands/update_schools.py index 3fe59ea5c..08270ba06 100644 --- a/salesforce/management/commands/update_schools.py +++ b/salesforce/management/commands/update_schools.py @@ -13,6 +13,7 @@ def handle(self, *args, **options): fetch_results = sf.bulk.Account.query("SELECT Name, Id, Phone, " \ "Website, " \ "Type, " \ + "Industry, " \ "School_Location__c, " \ "Students_Current_Year__c, " \ "Total_School_Enrollment__c, " \ @@ -23,21 +24,26 @@ def handle(self, *args, **options): "BillingCountry, " \ "BillingLatitude, " \ "BillingLongitude " \ - "FROM Account", lazy_operation=True) + "FROM Account WHERE Industry = 'HE' OR Industry = 'K12'", lazy_operation=True) sf_schools = [] for list_results in fetch_results: sf_schools.extend(list_results) updated_schools = 0 created_schools = 0 + seen_salesforce_ids = set() for sf_school in sf_schools: + salesforce_id = sf_school['Id'] + seen_salesforce_ids.add(salesforce_id) + school, created = School.objects.update_or_create( - salesforce_id=sf_school['Id'], + salesforce_id=salesforce_id, defaults={'name': sf_school['Name'], 'phone': sf_school['Phone'], 'website': sf_school['Website'], 'type': sf_school['Type'], + 'industry': sf_school['Industry'], 'location': sf_school['School_Location__c'], 'current_year_students': sf_school['Students_Current_Year__c'], 'total_school_enrollment': sf_school['Total_School_Enrollment__c'], @@ -51,13 +57,18 @@ def handle(self, *args, **options): }, ) - school.save() if created: created_schools = created_schools + 1 else: updated_schools = updated_schools + 1 + # Delete schools that no longer exist in Salesforce + deleted_schools = School.objects.exclude( + salesforce_id__in=seen_salesforce_ids + ).delete()[0] + invalidate_cloudfront_caches('salesforce/schools') response = self.style.SUCCESS( - "Successfully updated {} schools, created {} schools.".format(updated_schools, created_schools)) + "Successfully updated {} schools, created {} schools, deleted {} schools.".format( + updated_schools, created_schools, deleted_schools)) self.stdout.write(response) diff --git a/salesforce/migrations/0114_school_industry.py b/salesforce/migrations/0114_school_industry.py new file mode 100644 index 000000000..da7005338 --- /dev/null +++ b/salesforce/migrations/0114_school_industry.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.12 on 2025-12-05 17:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("salesforce", "0113_school_created_school_updated"), + ] + + operations = [ + migrations.AddField( + model_name="school", + name="industry", + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/salesforce/models.py b/salesforce/models.py index 204a35d37..1cdd68f66 100644 --- a/salesforce/models.py +++ b/salesforce/models.py @@ -3,6 +3,7 @@ from wagtail import hooks from wagtail.admin.menu import MenuItem +from wagtail.search import index from books.models import Book @@ -35,12 +36,13 @@ def __str__(self): return self.opportunity_id -class School(models.Model): +class School(index.Indexed, models.Model): salesforce_id = models.CharField(max_length=255, blank=True, null=True) name = models.CharField(max_length=255) phone = models.CharField(max_length=255, null=True, blank=True) website = models.CharField(max_length=255, null=True, blank=True) type = models.CharField(max_length=255, null=True, blank=True) + industry = models.CharField(max_length=255, null=True, blank=True) location = models.CharField(max_length=255, null=True, blank=True) adoption_date = models.CharField(max_length=255, null=True, blank=True) key_institutional_partner = models.BooleanField(default=False) @@ -65,6 +67,19 @@ class School(models.Model): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + search_fields = [ + index.SearchField('name', boost=10), + index.AutocompleteField('name'), + index.FilterField('name'), + index.FilterField('type'), + index.FilterField('location'), + ] + + autocomplete_search_field = 'name' + + def autocomplete_label(self): + return self.name + def __str__(self): return self.name diff --git a/salesforce/serializers.py b/salesforce/serializers.py index 548a08711..73428b3e8 100644 --- a/salesforce/serializers.py +++ b/salesforce/serializers.py @@ -8,9 +8,11 @@ class SchoolSerializer(serializers.ModelSerializer): class Meta: model = School fields = ('id', + 'salesforce_id', 'name', 'phone', 'website', + 'industry', 'type', 'location', 'adoption_date', @@ -34,9 +36,11 @@ class Meta: 'long', 'lat') read_only_fields = ('id', + 'salesforce_id', 'name', 'phone', 'website', + 'industry', 'type', 'location', 'adoption_date',