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 OutgoingWebmention(models.Model): """ Outbox of Webmentions sent by the relay on behalf of feed authors. The (source, target) pair is unique, which guarantees each link found in a post is notified at most once no matter how many times the feed is rescanned. """ STATUS_PENDING = "pending" STATUS_SENT = "sent" STATUS_FAILED = "failed" STATUS_NO_ENDPOINT = "no_endpoint" STATUS_CHOICES = [ (STATUS_PENDING, "Pending"), (STATUS_SENT, "Sent"), (STATUS_FAILED, "Failed"), (STATUS_NO_ENDPOINT, "No endpoint"), ] source = models.URLField( max_length=500, help_text="Post URL (feed#post_id) containing the link" ) target = models.URLField(max_length=500, help_text="External URL the post links to") endpoint = models.URLField( max_length=500, blank=True, help_text="Discovered Webmention endpoint" ) status = models.CharField( max_length=20, choices=STATUS_CHOICES, default=STATUS_PENDING ) attempts = models.PositiveSmallIntegerField(default=0) response_code = models.IntegerField( null=True, blank=True, help_text="HTTP status of the last delivery attempt" ) last_attempt_at = models.DateTimeField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: unique_together = ["source", "target"] ordering = ["created_at"] def __str__(self): return f"{self.source} -> {self.target} ({self.status})" 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}")