From b56e6be8c996f861f14a5e5c0637f95d2db0241d Mon Sep 17 00:00:00 2001 From: Matthias Portzel Date: Tue, 12 Dec 2023 16:21:23 -0500 Subject: [PATCH] Calculate ETag for the index page using last modified date --- .gitignore | 1 + .../migrations/0008_thought_last_updated.py | 19 +++++++ ..._thoughts_th_posted_6b4f21_idx_and_more.py | 21 ++++++++ thoughts/models.py | 8 ++- thoughts/views.py | 54 +++++++++++++++---- whispermaphone/settings.py | 9 ++++ 6 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 thoughts/migrations/0008_thought_last_updated.py create mode 100644 thoughts/migrations/0009_thought_thoughts_th_posted_6b4f21_idx_and_more.py diff --git a/.gitignore b/.gitignore index 62054a1..35442cc 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ stale_outputs_checked __pycache__ *.iml *.pages +/git_commit diff --git a/thoughts/migrations/0008_thought_last_updated.py b/thoughts/migrations/0008_thought_last_updated.py new file mode 100644 index 0000000..5192414 --- /dev/null +++ b/thoughts/migrations/0008_thought_last_updated.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.3 on 2023-12-12 20:39 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('thoughts', '0007_thought_html_content_alter_thought_posted'), + ] + + operations = [ + migrations.AddField( + model_name='thought', + name='last_updated', + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/thoughts/migrations/0009_thought_thoughts_th_posted_6b4f21_idx_and_more.py b/thoughts/migrations/0009_thought_thoughts_th_posted_6b4f21_idx_and_more.py new file mode 100644 index 0000000..093a8de --- /dev/null +++ b/thoughts/migrations/0009_thought_thoughts_th_posted_6b4f21_idx_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 4.0.3 on 2023-12-12 21:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('thoughts', '0008_thought_last_updated'), + ] + + operations = [ + migrations.AddIndex( + model_name='thought', + index=models.Index(fields=['-posted'], name='thoughts_th_posted_6b4f21_idx'), + ), + migrations.AddIndex( + model_name='thought', + index=models.Index(fields=['-last_updated'], name='thoughts_th_last_up_2f98ba_idx'), + ), + ] diff --git a/thoughts/models.py b/thoughts/models.py index 8507dcc..92da9e3 100644 --- a/thoughts/models.py +++ b/thoughts/models.py @@ -68,6 +68,12 @@ class Thought(models.Model): except cls.DoesNotExist: return None + class Meta: + indexes = [ + models.Index(fields=["-posted"]), + models.Index(fields=["-last_updated"]) + ] + # Honestly I'm so sick of this problem that writing out a comment here to explain why it is necessary is beyond me. # I'm calling this CharField and not MySpecialLineNormalizingCharField # because I think this should be the default behavior and I don't understand why it's not. @@ -126,7 +132,7 @@ class ThoughtForm(forms.ModelForm): class Meta: model = Thought - exclude = ["posted"] + exclude = ["posted", "last_updated"] widgets = { "timezone_offset": forms.NumberInput, } diff --git a/thoughts/views.py b/thoughts/views.py index 9f4539d..a5faf40 100644 --- a/thoughts/views.py +++ b/thoughts/views.py @@ -3,6 +3,7 @@ import uuid import subprocess import base64 import collections +import hashlib from itertools import islice import magic @@ -10,6 +11,7 @@ import magic from django import forms from django.shortcuts import render, redirect from django.utils.crypto import constant_time_compare +from django.views.decorators.http import condition from haystack.views import SearchView, search_view_factory from haystack.query import SearchQuerySet @@ -21,7 +23,7 @@ from .pagination import get_all_pages, get_page_slug # Python's itertools standard library is bad # Instead of providing functions -# They provide "receipes" that you can copy-paste into your own program +# They provide "recipes" that you can copy-paste into your own program # => https://docs.python.org/3/library/itertools.html#itertools-recipes def sliding_window(iterable, n): # sliding_window('ABCDEFG', 4) --> ABCD BCDE CDEF DEFG @@ -43,15 +45,14 @@ def check_authenticated(request): pass return authenticated - -def index(request): - authenticated = check_authenticated(request) - +def get_highlighted_uuid(request): try: - highlighted_uuid = uuid.UUID(request.GET.get("show", "")) + return uuid.UUID(request.GET.get("show", "")) except ValueError: - highlighted_uuid = "" + return "" +# Return the previous, current, and next page +def get_pages(request): ## Figure out what page we're viewing pages = get_all_pages() @@ -59,6 +60,7 @@ def index(request): # If we've passed a valid highlighted thought uuid, # that takes priority over a page= value, so we overwrite requested_slug + highlighted_uuid = get_highlighted_uuid(request) if highlighted_uuid: try: requested_slug = get_page_slug(Thought.objects.get(uuid=highlighted_uuid)) @@ -82,8 +84,42 @@ def index(request): if nex.slug == requested_slug: previous_page, current_page, next_page = curr, nex, None + return previous_page, current_page, next_page + + +# Takes a Queryset of Thoughts and returns a hash of: +# the last-modified time for the most recently modified +# the number of thoughts +# and the current git commit (read from settings (which reads it from the git_commit file or randomly generates an id)) +# I can't think of any edge cases. It's impossible to change page without changing the query params, the time, or the count +# I still need to double check that different query params are treated differently +def get_etag_from_thoughts(thoughts): + count = thoughts.count() + last_updated = thoughts.order_by("-last_updated").first().last_updated + # Do I need to include query params? I'd hope that it was seen as a different URL + + hash_str = f"{count}:{last_updated}:{settings.COMMIT}" + return hashlib.sha256(hash_str.encode()).hexdigest() + + +def get_etag(request): + previous_page, current_page, next_page = get_pages(request) + thoughts = current_page.get_all_entries() + return get_etag_from_thoughts(thoughts) + + +@condition(etag_func=get_etag) +def index(request): + authenticated = check_authenticated(request) + + previous_page, current_page, next_page = get_pages(request) + + thoughts = current_page.get_all_entries() + + highlighted_uuid = get_highlighted_uuid(request) + return render(request, "thoughts/index.html", { "thoughts": thoughts, "highlighted": highlighted_uuid, @@ -91,8 +127,8 @@ def index(request): "page": current_page, "previous_page_slug": getattr(previous_page, "slug", None), "next_page_slug": getattr(next_page, "slug", None), - "is_first_page": current_page.slug == pages[0].slug, # if you're viewing the first page - "is_last_page": current_page.slug == pages[-1].slug + "is_first_page": previous_page is None, # if you're viewing the first page + "is_last_page": next_page is None }) diff --git a/whispermaphone/settings.py b/whispermaphone/settings.py index 184b000..a6c204d 100644 --- a/whispermaphone/settings.py +++ b/whispermaphone/settings.py @@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/ from pathlib import Path from decouple import config +import secrets import os # Build paths inside the project like this: BASE_DIR / "subdir". @@ -40,6 +41,14 @@ def split_string(string): # DO NOT USE THE COLORS I USE. These values right here are black and white, if you're lazy, keep them COLORS = config("COLORS", default="#FEFEFE,#222222,#999999", cast=split_string) +# The current git commit if known; used for cache invalidation +COMMIT = "" +try: + with open(BASE_DIR / "git_commit", "r") as f: + COMMIT = f.read().strip() +except FileNotFoundError: + COMMIT = "unknown-" + secrets.token_hex(15) + INSTALLED_APPS = [ "django.contrib.contenttypes", "django.contrib.staticfiles",