mirror of
https://github.com/tanrax/org-social-relay
synced 2026-01-10 15:03:33 +01:00
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)
315 lines
9.1 KiB
Python
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}")
|