diff --git a/events/management/commands/send_email_updates.py b/events/management/commands/send_email_updates.py index f04178a3..88b2a0cb 100644 --- a/events/management/commands/send_email_updates.py +++ b/events/management/commands/send_email_updates.py @@ -38,7 +38,7 @@ utm = "utm_campaign=govtrack_email_update&utm_source=govtrack/email_update&utm_medium=email" template_body_text = None template_body_html = None -announce = None +latest_blog_post = None class Command(BaseCommand): help = 'Sends out email updates of events to subscribing users.' @@ -49,7 +49,7 @@ def add_arguments(self, parser): def handle(self, *args, **options): global template_body_text global template_body_html - global announce + global latest_blog_post if options["mode"][0] not in ('daily', 'weekly', 'testadmin', 'testcount'): print("Specify daily or weekly or testadmin or testcount.") @@ -109,7 +109,7 @@ def handle(self, *args, **options): # load globals template_body_text = get_template("events/emailupdate_body.txt") template_body_html = get_template("events/emailupdate_body.html") - announce = load_announcement("website/email/email_update_announcement.md", options["mode"][0] == "testadmin") + latest_blog_post = load_latest_blog_post() # counters for analytics on what we sent counts = { @@ -235,10 +235,12 @@ def pool_worker(conn): def send_email_update(user_id, list_email_freq, send_mail, mark_lists, send_old_events, mail_connection): global launch_time + global latest_blog_post user_start_time = datetime.now() user = User.objects.get(id=user_id) + profile = UserProfile.objects.get(user=user) # get the email's From: header and return path emailfromaddr = getattr(settings, 'EMAIL_UPDATES_FROMADDR', @@ -272,10 +274,15 @@ def send_email_update(user_id, list_email_freq, send_mail, mark_lists, send_old_ if most_recent_event is None: most_recent_event = max_id most_recent_event = max(most_recent_event, max_id) + # Suppress the latest blog post if the user was already sent it. + if latest_blog_post and profile.last_blog_post_emailed >= latest_blog_post.id: + latest_blog_post = None + user_querying_end_time = datetime.now() - # Don't send an empty email.... unless we're testing and we want to send some old events. - if len(eventslists) == 0 and not send_old_events and announce is None: + # Don't send an empty email (no events and no latest blog post) + # .... unless we're testing and we want to send some old events. + if len(eventslists) == 0 and not send_old_events and latest_blog_post is None: return { "total_time_querying": user_querying_end_time-user_start_time, } @@ -321,7 +328,7 @@ def send_email_update(user_id, list_email_freq, send_mail, mark_lists, send_old_ "emailpingurl": emailpingurl, "body_text": body_text, "body_html": body_html, - "announcement": announce, + "latest_blog_post": latest_blog_post, "SITE_ROOT_URL": settings.SITE_ROOT_URL, "utm": utm, }, @@ -329,7 +336,7 @@ def send_email_update(user_id, list_email_freq, send_mail, mark_lists, send_old_ 'Reply-To': emailfromaddr, 'Auto-Submitted': 'auto-generated', 'X-Auto-Response-Suppress': 'OOF', - 'X-Unsubscribe-Link': UserProfile.objects.get(user=user).get_one_click_unsub_url(), + 'X-Unsubscribe-Link': profile.get_one_click_unsub_url(), }, fail_silently=False, connection=mail_connection, @@ -358,6 +365,9 @@ def send_email_update(user_id, list_email_freq, send_mail, mark_lists, send_old_ sublist.last_event_mailed = max(sublist.last_event_mailed, most_recent_event) if sublist.last_event_mailed is not None else most_recent_event sublist.last_email_sent = launch_time sublist.save() + if latest_blog_post: + profile.last_blog_post_emailed = latest_blog_post.id + profile.save() user_sending_end_time = datetime.now() @@ -369,46 +379,19 @@ def send_email_update(user_id, list_email_freq, send_mail, mark_lists, send_old_ "total_time_sending": user_sending_end_time-user_rendering_end_time, } -def load_announcement(template_path, testing): - # Load the Markdown template for the current blast. - templ = get_template(template_path) - - # Get the text-only body content, which also includes some email metadata. - # Replace Markdown-style [text][href] links with the text plus bracketed href. - ctx = { "format": "text", "utm": "" } - body_text = templ.render(ctx).strip() - body_text = re.sub(r"\[(.*?)\]\((.*?)\)", r"\1 at \2", body_text) - - # The top of the text content contains metadata in YAML format, - # with "id" and "subject" required and active: true or rundate set to today's date in ISO format. - meta_info, body_text = body_text.split("----------", 1) - body_text = body_text.strip() - meta_info = yaml.load(meta_info) - - # Under what cases do we use this announcement? - if meta_info.get("active"): - pass # active is set to something truthy - elif meta_info.get("rundate") == launch_time.date().isoformat(): - pass # rundate matches date this job was started - elif "rundate" in meta_info and testing: - pass # when testing ignore the actual date set - else: - # the announcement file is inactive/stale +def load_latest_blog_post(): + from website.models import BlogPost + latest_blog_post = BlogPost.objects\ + .filter(published=True)\ + .order_by('-created')\ + .first() + if not latest_blog_post: return None - # Get the HTML body content. - ctx = { - "format": "html", - "utm": "", - } - body_html = templ.render(ctx).strip() - body_html = markdown(body_html) + # Pre-render the HTML and plain text versions. + latest_blog_post.body_html = latest_blog_post.body_html() + latest_blog_post.body_text = latest_blog_post.body_text() - # Store everything in meta_info. - - meta_info["body_text"] = body_text - meta_info["body_html"] = body_html - - return meta_info + return latest_blog_post diff --git a/requirements.txt b/requirements.txt index f179872b..047f5112 100644 --- a/requirements.txt +++ b/requirements.txt @@ -44,3 +44,4 @@ cmarkgfm<0.5.0 django_otp Mastodon.py requests +django-markdownx diff --git a/settings.py b/settings.py index a541d451..108ec328 100644 --- a/settings.py +++ b/settings.py @@ -99,6 +99,8 @@ 'crispy_forms', 'django_otp', 'django_otp.plugins.otp_totp', + #'django_otp.plugins.otp_static', # necessary for bootstrapping access to the admin + 'markdownx', 'haystack', 'htmlemailer', @@ -208,3 +210,5 @@ SHOW_TOOLBAR_CALLBACK = lambda : True OTP_TOTP_ISSUER = "GovTrack.us" + +MARKDOWNX_MARKDOWNIFY_FUNCTION = 'website.templatetags.govtrack_utils.markdown' diff --git a/static/markdownx b/static/markdownx new file mode 120000 index 00000000..5ef984e1 --- /dev/null +++ b/static/markdownx @@ -0,0 +1 @@ +../.venv/lib/python3.6/site-packages/markdownx/static/markdownx/ \ No newline at end of file diff --git a/templates/events/emailupdate.html b/templates/events/emailupdate.html index be1aecb8..d7b658d7 100644 --- a/templates/events/emailupdate.html +++ b/templates/events/emailupdate.html @@ -92,16 +92,6 @@ - - {% if announcement %} -
-
- {% if announcement.subject %}

{{announcement.subject}}

{% endif %} - {{announcement.body_html|safe}} -
-
- {% endif %} -
Like these updates? A recurring tip or one-time tip will help us keep this service free for everyone. @@ -109,6 +99,14 @@ for more updates.
+ {% if latest_blog_post %} +
+
+

{{latest_blog_post.title}}

+ {{latest_blog_post.body_html|safe}} +
+
+ {% endif %}
{{body_html|safe}} diff --git a/templates/events/emailupdate.txt b/templates/events/emailupdate.txt index e7317e24..5ba71308 100644 --- a/templates/events/emailupdate.txt +++ b/templates/events/emailupdate.txt @@ -1,9 +1,16 @@ {% autoescape off %}GovTrack Email Update ===================== -{% if announcement %}----- {{announcement.body_text}} ----- +This is your email update from www.GovTrack.us. To change your email update settings, including to unsubscribe, go to {{SITE_ROOT_URL}}/accounts/profile. -{% endif %}This is your email update from www.GovTrack.us. To change your email update settings, including to unsubscribe, go to {{SITE_ROOT_URL}}/accounts/profile. +{% if latest_blog_post %}===================================================================== + +{{latest_blog_post.title}} + +{{last_blog_post_emailed.body_text}} + +{% endif %} +===================================================================== {{body_text}} diff --git a/templates/events/emailupdate_subject.txt b/templates/events/emailupdate_subject.txt index b71538fb..de51b321 100644 --- a/templates/events/emailupdate_subject.txt +++ b/templates/events/emailupdate_subject.txt @@ -1 +1 @@ -GovTrack Update for {{date}}{% if announcement.subject %} | {{announcement.subject}}{% endif %} +Activity in Congress{% if latest_blog_post %}: {{latest_blog_post.title}}{% endif %} ({{date}}) diff --git a/templates/website/index.html b/templates/website/index.html index a18bd782..dcb7b256 100644 --- a/templates/website/index.html +++ b/templates/website/index.html @@ -210,19 +210,20 @@

Find legislation that affects you:

-
+
+ {% if latest_blog_post %}
-
How a bill actually becomes a law... in 2020
+
{{latest_blog_post.title}}
-

Middle school social studies textbooks and Schoolhouse Rock songs paint a stereotypical portrait of how congressional legislation gets passed. But in real life, the story of how federal laws actually get enacted usually proves far more complex. To examine how, GovTrack Insider browsed through all the laws enacted by Congress in 2019–20, looking for one that seemed both particularly interesting and undercovered by national media.

- Read - the story of The Emancipation National Historic Trail Study Act » + {{latest_blog_post.body_html|safe}} +
— {{latest_blog_post.created|date:"SHORT_DATETIME_FORMAT"}}
+ {% endif %}
-
+
{% for post_group in post_groups %}
diff --git a/urls.py b/urls.py index 5069c81f..4cc9fab7 100644 --- a/urls.py +++ b/urls.py @@ -22,8 +22,9 @@ urlpatterns += [ url(r'^admin/', admin.site.urls), + url('markdownx/', include('markdownx.urls')), - # main URLs + # main URLs url(r'', include('redirect.urls')), url(r'', include('website.urls')), url(r'^congress/members(?:$|/)', include('person.urls')), @@ -44,7 +45,7 @@ url(r'^accounts/logout$', auth_views.LogoutView.as_view(), { "redirect_field_name": "next" }), url(r'^accounts/profile$', registration.views.profile, name='registration.views.profile'), - url(r'^dump_request', website.views.dumprequest), + url(r'^dump_request', website.views.dumprequest), ] # sitemaps diff --git a/website/admin.py b/website/admin.py index 56873fe3..100564c7 100644 --- a/website/admin.py +++ b/website/admin.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 from django.contrib import admin +from markdownx.admin import MarkdownxModelAdmin from website.models import * class UserProfileAdmin(admin.ModelAdmin): @@ -11,9 +12,12 @@ class UserProfileAdmin(admin.ModelAdmin): class MediumPostAdmin(admin.ModelAdmin): list_display = ['published', 'title', 'url'] +class BlogPostAdmin(MarkdownxModelAdmin): + list_display = ['title', 'published', 'created', 'updated'] + admin.site.register(UserProfile, UserProfileAdmin) admin.site.register(MediumPost, MediumPostAdmin) admin.site.register(Community) admin.site.register(CommunityMessageBoard) admin.site.register(CommunityMessage) - +admin.site.register(BlogPost, BlogPostAdmin) \ No newline at end of file diff --git a/website/migrations/0008_blogpost.py b/website/migrations/0008_blogpost.py new file mode 100644 index 00000000..d6fcb4a8 --- /dev/null +++ b/website/migrations/0008_blogpost.py @@ -0,0 +1,34 @@ +# Generated by Django 2.2.28 on 2024-01-12 16:11 + +from django.db import migrations, models +import markdownx.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('website', '0007_ipaddrinfo'), + ] + + operations = [ + migrations.CreateModel( + name='BlogPost', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=128)), + ('body', markdownx.models.MarkdownxField()), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('published', models.BooleanField(db_index=True, default=False)), + ], + options={ + 'index_together': {('published', 'created')}, + }, + ), + + migrations.AddField( + model_name='userprofile', + name='last_blog_post_emailed', + field=models.IntegerField(default=0), + ), + ] diff --git a/website/models.py b/website/models.py index cb23cdf0..e9c01c66 100644 --- a/website/models.py +++ b/website/models.py @@ -3,6 +3,7 @@ from django.conf import settings from jsonfield import JSONField +from markdownx.models import MarkdownxField from events.models import Feed, SubscriptionList @@ -11,6 +12,7 @@ class UserProfile(models.Model): massemail = models.BooleanField(default=True) # may we send you mail? old_id = models.IntegerField(blank=True, null=True) # from the pre-2012 GovTrack database last_mass_email = models.IntegerField(default=0) + last_blog_post_emailed = models.IntegerField(default=0) congressionaldistrict = models.CharField(max_length=4, blank=True, null=True, db_index=True) # or 'XX00' if the user doesn't want to provide it # monetization @@ -436,3 +438,27 @@ class IpAddrInfo(models.Model): last_hit = models.DateTimeField(auto_now=True, db_index=True) hits = models.IntegerField(default=1, db_index=True) leadfeeder = JSONField(default={}, blank=True, null=True) + +class BlogPost(models.Model): + title = models.CharField(max_length=128) + body = MarkdownxField() + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + published = models.BooleanField(default=False, db_index=True) + + class Meta: + index_together = [("published", "created")] + + def __str__(self): + return self.title + + def body_html(self): + from website.templatetags.govtrack_utils import markdown + return markdown(self.body) + + def body_text(self): + # Replace Markdown-style [text][href] links with the text plus bracketed href. + import re + body_text = self.body + body_text = re.sub(r"\[(.*?)\]\((.*?)\)", r"\1 at \2", body_text) + return body_text diff --git a/website/views.py b/website/views.py index a89b0990..c74fae79 100644 --- a/website/views.py +++ b/website/views.py @@ -27,10 +27,16 @@ def index(request): from bill.views import subject_choices bill_subject_areas = subject_choices() - post_groups = [] MAX_PER_GROUP = 3 + # Get our latest blog post. + from .models import BlogPost + latest_blog_post = BlogPost.objects\ + .filter(published=True)\ + .order_by('-created')\ + .first() + # Trending feeds. These are short (no image, no snippet) so they go first. trending_feeds = [Feed.objects.get(id=f) for f in Feed.get_trending_feeds()[0:6]] if len(trending_feeds) > 0: @@ -64,13 +70,9 @@ def index(request): }) - from person.models import Person - from vote.models import Vote return { - # for the action area below the splash - 'bill_subject_areas': bill_subject_areas, - - # for the highlights blocks + 'bill_subject_areas': bill_subject_areas, # for the action area below the splash + 'latest_blog_post': latest_blog_post, 'post_groups': post_groups, }