Files
org-social-relay/app/feeds/models.py
Andros Fenollosa b13cf2fad4 Add support for Org Social v1.6
New features from Org Social v1.6:
- Add LOCATION, BIRTHDAY, LANGUAGE, PINNED fields to Profile model
- Support post ID in header (** 2025-05-01T12:00:00+0100)
- Header ID takes priority over property drawer ID
- Parse and store all new v1.6 metadata fields

Changes:
- Updated Profile model with 4 new fields
- Updated parser to extract new metadata fields
- Updated parser to support ID in post headers
- Updated tasks.py to save new profile fields
- Added database migration 0010
- Added 3 new tests for v1.6 features
- Renamed SKILL.md to CLAUDE.MD

All tests passing (58/58)
2026-01-05 13:55:56 +01:00

315 lines
9.1 KiB
Python

from django.db import models
from django.utils import timezone
import logging
logger = logging.getLogger(__name__)
class Profile(models.Model):
"""
Represents an Org Social profile (social.org file)
"""
feed = models.URLField(unique=True, help_text="URL to the social.org file")
title = models.CharField(max_length=200, help_text="Title of the social feed")
nick = models.CharField(max_length=100, help_text="Nickname (no spaces allowed)")
description = models.TextField(
blank=True, help_text="Short description about the profile"
)
avatar = models.URLField(
blank=True, help_text="URL to avatar image (128x128px JPG/PNG)"
)
location = models.CharField(
max_length=200, blank=True, help_text="User location (city, country, etc.)"
)
birthday = models.DateField(
blank=True, null=True, help_text="User birthday in YYYY-MM-DD format"
)
language = models.CharField(
max_length=100,
blank=True,
help_text="Space-separated language codes (ISO 639-1)",
)
pinned = models.CharField(
max_length=50, blank=True, help_text="Pinned post ID (timestamp)"
)
version = models.CharField(
max_length=50,
blank=True,
help_text="Version identifier for tracking changes (hash of content)",
)
last_updated = models.DateTimeField(auto_now=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-last_updated"]
def __str__(self):
return f"{self.nick} ({self.feed})"
class ProfileLink(models.Model):
"""
Links associated with a profile (LINK field in social.org)
"""
profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="links")
url = models.URLField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["profile", "url"]
class ProfileContact(models.Model):
"""
Contact information for a profile (CONTACT field in social.org)
"""
profile = models.ForeignKey(
Profile, on_delete=models.CASCADE, related_name="contacts"
)
contact_type = models.CharField(
max_length=50, help_text="Type of contact (email, xmpp, matrix, etc.)"
)
contact_value = models.CharField(max_length=200, help_text="Contact information")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["profile", "contact_type", "contact_value"]
class Follow(models.Model):
"""
Represents a follow relationship between profiles
"""
follower = models.ForeignKey(
Profile, on_delete=models.CASCADE, related_name="following"
)
followed = models.ForeignKey(
Profile, on_delete=models.CASCADE, related_name="followers"
)
nickname = models.CharField(
max_length=100,
blank=True,
help_text="Optional nickname for the followed profile",
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["follower", "followed"]
def __str__(self):
return f"{self.follower.nick} follows {self.followed.nick}"
class Post(models.Model):
"""
Represents a post in Org Social
"""
profile = models.ForeignKey(Profile, on_delete=models.CASCADE, related_name="posts")
post_id = models.CharField(
max_length=50, help_text="Unique timestamp identifier (RFC 3339 format)"
)
content = models.TextField(help_text="Post content")
# Optional properties
language = models.CharField(
max_length=10, blank=True, help_text="Language code (LANG property)"
)
tags = models.CharField(
max_length=500, blank=True, help_text="Space-separated tags"
)
client = models.CharField(
max_length=100, blank=True, help_text="Client application used"
)
reply_to = models.CharField(
max_length=300,
blank=True,
help_text="ID of post being replied to (URL#ID format)",
)
mood = models.CharField(
max_length=10, blank=True, help_text="Mood indicator (emoji)"
)
group = models.CharField(
max_length=100, blank=True, help_text="Group name (GROUP property)"
)
include = models.CharField(
max_length=300,
blank=True,
help_text="Post being boosted/shared (URL#ID format)",
)
# Poll related fields
poll_end = models.DateTimeField(
blank=True, null=True, help_text="End time for polls"
)
# Metadata
created_at = models.DateTimeField(
default=timezone.now,
help_text="Creation timestamp, parsed from post_id when available",
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
unique_together = ["profile", "post_id"]
ordering = ["-created_at"]
def __str__(self):
return f"{self.profile.nick}: {self.content[:50]}..."
@property
def is_poll(self):
return self.poll_end is not None
@property
def is_reply(self):
return bool(self.reply_to)
@property
def is_boost(self):
return bool(self.include)
class PollOption(models.Model):
"""
Options for a poll post
"""
post = models.ForeignKey(
Post, on_delete=models.CASCADE, related_name="poll_options"
)
option_text = models.CharField(max_length=200)
order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ["order"]
unique_together = ["post", "option_text"]
def __str__(self):
return f"{self.post.profile.nick} poll: {self.option_text}"
class PollVote(models.Model):
"""
Represents a vote on a poll
"""
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="poll_votes")
poll_post = models.ForeignKey(
Post,
on_delete=models.CASCADE,
related_name="votes_received",
help_text="The original poll post",
)
poll_option = models.CharField(max_length=200, help_text="Selected poll option")
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["post", "poll_post"]
def __str__(self):
return f"{self.post.profile.nick} voted {self.poll_option}"
class Mention(models.Model):
"""
Represents a mention in a post
"""
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="mentions")
mentioned_profile = models.ForeignKey(
Profile,
on_delete=models.CASCADE,
related_name="incoming_mentions",
help_text="The profile that was mentioned",
)
nickname = models.CharField(
max_length=100, blank=True, help_text="Nickname used in the mention"
)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
unique_together = ["post", "mentioned_profile"]
ordering = ["-created_at"]
def __str__(self):
return f"{self.post.profile.nick} mentioned {self.mentioned_profile.nick}"
class Feed(models.Model):
"""
Represents a feed URL
"""
url = models.URLField()
created_at = models.DateTimeField(auto_now_add=True)
last_successful_fetch = models.DateTimeField(
null=True,
blank=True,
help_text="Last time this feed was successfully fetched with HTTP 200",
)
def __str__(self):
return self.url
class RelayMetadata(models.Model):
"""
Global metadata for the relay - used for HTTP caching headers.
This model ensures all endpoints return consistent ETag and Last-Modified headers.
Updated by the scan_feeds task after each scan.
"""
key = models.CharField(max_length=50, unique=True, primary_key=True)
etag = models.CharField(max_length=16, help_text="Global ETag for HTTP caching")
last_modified = models.DateTimeField(
help_text="Global Last-Modified timestamp for HTTP caching"
)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Relay Metadata"
verbose_name_plural = "Relay Metadata"
def __str__(self):
return f"RelayMetadata({self.key}): ETag={self.etag}"
@classmethod
def get_global_metadata(cls):
"""
Get or create the global relay metadata.
Returns a tuple of (etag, last_modified).
"""
import hashlib
from django.utils import timezone
metadata, created = cls.objects.get_or_create(
key="global",
defaults={
"etag": hashlib.md5(str(timezone.now()).encode()).hexdigest()[:8],
"last_modified": timezone.now(),
},
)
return metadata.etag, metadata.last_modified
@classmethod
def update_global_metadata(cls):
"""
Update the global ETag and Last-Modified.
Called by scan_feeds task after scanning all feeds.
"""
import hashlib
from django.utils import timezone
now = timezone.now()
etag = hashlib.md5(str(now.timestamp()).encode()).hexdigest()[:8]
cls.objects.update_or_create(
key="global", defaults={"etag": etag, "last_modified": now}
)
logger.info(f"Updated global relay metadata: ETag={etag}, Last-Modified={now}")