Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions testbed/core/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,18 @@ class NoteAdmin(admin.ModelAdmin):
list_display = ("actor", "content", "published", "visibility")
list_filter = ("visibility", "published")
search_fields = ("content", "actor__username")
# `published` is editable now that it is `default=` rather than `auto_now_add=`.
# Migration metadata is collapsed: it is empty on every source-authored note.
fieldsets = (
(None, {"fields": ("actor", "content", "published", "visibility")}),
(
"Migration metadata",
{
"classes": ("collapse",),
"fields": ("summary", "to", "cc", "in_reply_to", "url", "source", "previously"),
},
),
)


@admin.register(CreateActivity)
Expand Down
2 changes: 2 additions & 0 deletions testbed/core/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ class Meta:
actor = factory.SubFactory(ActorFactory)
content = factory.Faker("text", max_nb_chars=200)
visibility = factory.Iterator(["public", "private", "followers-only"])
# One day older per note, so a batch is distinct and strictly descending by `published`
published = factory.Sequence(lambda n: datetime.now(timezone.utc) - timedelta(days=n))

class CreateActivityFactory(DjangoModelFactory):
# Can create activities for notes or actor creation announcements
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Generated by Django 5.1.3 on 2026-09-02 22:39

import django.utils.timezone
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('core', '0010_transfer_job_and_item'),
]

operations = [
migrations.AddField(
model_name='note',
name='cc',
field=models.JSONField(blank=True, default=list, help_text='Same rule as `to`: descriptive, never an access decision.'),
),
migrations.AddField(
model_name='note',
name='in_reply_to',
field=models.URLField(blank=True, help_text='The object this one replies to. LOLA §7.1.5.', max_length=500, null=True),
),
migrations.AddField(
model_name='note',
name='previously',
field=models.JSONField(blank=True, default=list, help_text='Object breadcrumbs: [{actor, id}], newest first (LOLA §7.1.8).'),
),
migrations.AddField(
model_name='note',
name='source',
field=models.JSONField(blank=True, help_text='Original markup as authored. LOLA §6.3.', null=True),
),
migrations.AddField(
model_name='note',
name='summary',
field=models.TextField(blank=True, default='', help_text='Short human-readable summary précis of the object. LOLA §6.3.'),
),
migrations.AddField(
model_name='note',
name='to',
field=models.JSONField(blank=True, default=list, help_text='Descriptive only: a historical record of where the object was distributed. LOLA §7.1.6.'),
),
migrations.AddField(
model_name='note',
name='url',
field=models.URLField(blank=True, help_text='Canonical human-facing URL of the object at its origin. LOLA §6.3.', max_length=500, null=True),
),
migrations.AlterField(
model_name='note',
name='published',
field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, help_text='When the object was originally published.'),
),
]
53 changes: 51 additions & 2 deletions testbed/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.conf import settings
from datetime import timezone
from django.utils import timezone
from cryptography.fernet import Fernet

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -197,7 +197,13 @@ def __str__(self):
class Note(models.Model):
actor = models.ForeignKey(Actor, on_delete=models.CASCADE, related_name="notes")
content = models.TextField()
published = models.DateTimeField(auto_now_add=True)
published = models.DateTimeField(
default=timezone.now,
db_index=True,
help_text=(
"When the object was originally published."
),
)
visibility = models.CharField(
max_length=20,
default="public",
Expand All @@ -208,6 +214,49 @@ class Note(models.Model):
],
)

# Empty for source-authored notes; populated by the destination transform when an object is copied in
summary = models.TextField(
blank=True,
default="",
help_text="Short human-readable summary précis of the object. LOLA §6.3.",
)
to = models.JSONField(
default=list,
blank=True,
help_text=(
"Descriptive only: a historical record of where the object was distributed. LOLA §7.1.6."
),
)
cc = models.JSONField(
default=list,
blank=True,
help_text="Same rule as `to`: descriptive, never an access decision.",
)
in_reply_to = models.URLField(
max_length=500,
null=True,
blank=True,
help_text="The object this one replies to. LOLA §7.1.5.",
)
url = models.URLField(
max_length=500,
null=True,
blank=True,
help_text="Canonical human-facing URL of the object at its origin. LOLA §6.3.",
)
source = models.JSONField(
null=True,
blank=True,
help_text="Original markup as authored. LOLA §6.3.",
)
previously = models.JSONField(
default=list,
blank=True,
help_text=(
"Object breadcrumbs: [{actor, id}], newest first (LOLA §7.1.8)."
),
)

def __str__(self):
return f"Note by {self.actor.user.username}: {self.content[:30]}"

Expand Down
16 changes: 16 additions & 0 deletions testbed/core/tests/test_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from testbed.core.admin import NoteAdmin
from testbed.core.models import Note

# Declaring `fieldsets` on NoteAdmin makes the form's field list explicit, so a column added
# to Note later would silently disappear from the admin. This catches that.
def test_note_admin_fieldsets_cover_every_editable_field():
listed = {name for _, options in NoteAdmin.fieldsets for name in options["fields"]}
editable = {
field.name
for field in Note._meta.get_fields()
if getattr(field, "editable", False) and not field.auto_created
}

assert editable - listed == set(), (
f"Note fields missing from NoteAdmin.fieldsets: {sorted(editable - listed)}"
)
88 changes: 88 additions & 0 deletions testbed/core/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import pytest
from datetime import datetime, timedelta
from django.core.exceptions import ValidationError
from django.utils import timezone
from testbed.core.models import Actor, Note, CreateActivity, LikeActivity, FollowActivity, PortabilityOutbox, Following, Followers
Expand Down Expand Up @@ -59,11 +60,98 @@ def test_actor_move_history(actor):
assert actor.previously[0]["object"] == "https://old-server.com/users/old_username"
assert actor.previously[0]["published"] == test_date.isoformat()

# Test that record_move stamps the current time when no date is supplied
def test_actor_move_history_defaults_to_now(actor):
before = timezone.now()
actor.record_move("old-server.com", "old_username")
after = timezone.now()

assert len(actor.previously) == 1
recorded = datetime.fromisoformat(actor.previously[0]["published"])
assert before <= recorded <= after

# Test basic note creation
def test_note_creation(note):
assert note.content is not None
assert note.visibility in ["public", "private", "followers-only"]

# Test that an explicitly supplied `published` survives to the database
def test_note_published_is_settable(actor):
original = timezone.now() - timedelta(days=400)

note = Note.objects.create(actor=actor, content="historical", published=original)
note.refresh_from_db()

assert note.published == original

# Test that omitting `published` still stamps the current time, so making the field
# settable did not quietly make it required.
def test_note_published_defaults_to_now(actor):
before = timezone.now()
note = Note.objects.create(actor=actor, content="fresh")
after = timezone.now()

note.refresh_from_db()
assert before <= note.published <= after

# Test that every migration metadata field survives a round trip to the database
def test_note_metadata_fields_round_trip(actor):
breadcrumbs = [
{"actor": "https://newsite.example/aurora/", "id": "https://newsite.example/items/02751cab"},
{"actor": "https://lemongrove.example/", "id": "https://lemongrove.example/2016/05/minimal"},
]
note = Note.objects.create(
actor=actor,
content="<p>copied</p>",
summary="A copied article",
to=["https://lemongrove.example/followers"],
cc=["https://oakfrost.example/brock"],
in_reply_to="https://lemongrove.example/2016/05/parent",
url="https://lemongrove.example/2016/05/minimal",
source={"content": "A copied article", "mediaType": "text/markdown"},
previously=breadcrumbs,
)
note.refresh_from_db()

assert note.summary == "A copied article"
assert note.to == ["https://lemongrove.example/followers"]
assert note.cc == ["https://oakfrost.example/brock"]
assert note.in_reply_to == "https://lemongrove.example/2016/05/parent"
assert note.url == "https://lemongrove.example/2016/05/minimal"
assert note.source == {"content": "A copied article", "mediaType": "text/markdown"}
assert note.previously == breadcrumbs

# Test that unset list fields default to [] rather than None
def test_note_metadata_defaults_are_empty_not_null(actor):
note = Note.objects.create(actor=actor, content="bare")
note.refresh_from_db()

assert note.to == []
assert note.cc == []
assert note.previously == []
assert note.summary == ""
assert note.source is None
assert note.in_reply_to is None
assert note.url is None

# Test that the list defaults are per-instance
def test_note_list_defaults_are_not_shared(actor):
first = Note.objects.create(actor=actor, content="first")
second = Note.objects.create(actor=actor, content="second")

first.to.append("https://example.test/followers")

assert second.to == []
assert Note.objects.get(pk=second.pk).to == []

# Test that the factory spreads timestamps, so `-published` is a total order with no ties.
def test_note_factory_spreads_published(actor):
notes = NoteFactory.create_batch(5, actor=actor)
stamps = [n.published for n in notes]

assert len(set(stamps)) == 5
assert stamps == sorted(stamps, reverse=True)

# Test note string representation
def test_note_str_representation(note):
expected = f"Note by {note.actor.user.username}: {note.content[:30]}"
Expand Down
Loading