mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
- Image model: new file OneToOneField → FileUpload; get_url/get_thumbnail_url serve via /ui/files/ (Pillow thumbs) with legacy R2 fallback until backfill - upload_images API now persists through _save_uploaded_file (files backend); deletes clean up FileUpload disk+record; collection destroy cascades files - R2Storage.delete_file implemented (was silently missing → legacy R2 delete no-op) - manage.py migrate_image_storage: idempotent R2→files backfill (dry-run default, --commit, optional --delete-r2); nothing removed from R2 without explicit flag - Collections list: Apple design system, cover cards, hover actions, mobile-first - Collection detail: native drag&drop upload (CDN Dropzone removed), lightbox with keyboard/swipe nav + description edit + delete, valid JSON image data via json_script - Slideshow untouched (automatically uses new backend) - tests: 10 new (upload/delete/URLs/backfill), 148 total green
306 lines
12 KiB
Python
306 lines
12 KiB
Python
from rest_framework import serializers
|
|
from .models import Link, Page, Post, ImageCollection, Image, Tag, FileUpload, Bookmark
|
|
import hashlib, json
|
|
from datetime import datetime
|
|
from django.utils import timezone
|
|
|
|
|
|
def compute_bookmark_content_hash(raw: dict) -> str:
|
|
"""Compute a stable SHA-256 hash of the key content fields from a raw
|
|
import payload. Two payloads with the same meaningful content produce the
|
|
same hash, even if JSON key ordering differs."""
|
|
# Only hash the fields that represent actual content (not metadata like
|
|
# timestamps that may drift between exports).
|
|
content = {
|
|
'text': raw.get('text', ''),
|
|
'url': raw.get('url', ''),
|
|
'media': raw.get('media', []),
|
|
'card': raw.get('card'),
|
|
'quoted': raw.get('quoted'),
|
|
'retweet': raw.get('retweet'),
|
|
'author': raw.get('author', {}),
|
|
'view_count': raw.get('view_count', 0),
|
|
'favorite_count': raw.get('favorite_count', 0),
|
|
'retweet_count': raw.get('retweet_count', 0),
|
|
'reply_count': raw.get('reply_count', 0),
|
|
'bookmark_count': raw.get('bookmark_count', 0),
|
|
'quote_count': raw.get('quote_count', 0),
|
|
}
|
|
canonical = json.dumps(content, sort_keys=True, ensure_ascii=False, default=str)
|
|
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
|
|
|
|
class LinkSerializer(serializers.ModelSerializer):
|
|
tags = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = Link
|
|
fields = ['id', 'alias', 'original_url', 'description', 'link_type',
|
|
'click_count', 'tags', 'created_at', 'updated_at']
|
|
read_only_fields = ['id', 'click_count', 'created_at', 'updated_at']
|
|
|
|
def get_tags(self, obj):
|
|
return [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in obj.tags.all()]
|
|
|
|
class PageSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = Page
|
|
fields = ['id', 'url', 'title', 'summary', 'content', 'screenshot_path',
|
|
'process_status', 'created_at', 'updated_at']
|
|
read_only_fields = ['id', 'screenshot_path', 'process_status',
|
|
'created_at', 'updated_at']
|
|
|
|
class PostSerializer(serializers.ModelSerializer):
|
|
tags = serializers.ListField(child=serializers.CharField(), required=False, write_only=True, help_text="List of tag slugs")
|
|
tag_details = serializers.SerializerMethodField(read_only=True)
|
|
|
|
class Meta:
|
|
model = Post
|
|
fields = ['id', 'title', 'summary', 'content', 'is_public', 'tags', 'tag_details', 'created_at', 'updated_at']
|
|
read_only_fields = ['id', 'created_at', 'updated_at']
|
|
extra_kwargs = {
|
|
'title': {'required': True},
|
|
'content': {'required': True},
|
|
'summary': {'required': False}
|
|
}
|
|
|
|
def get_tag_details(self, obj):
|
|
return [{'id': tag.id, 'name': tag.name, 'slug': tag.slug} for tag in obj.tags.all()]
|
|
|
|
def create(self, validated_data):
|
|
tag_slugs = validated_data.pop('tags', [])
|
|
post = Post.objects.create(**validated_data)
|
|
|
|
for tag_slug in tag_slugs:
|
|
try:
|
|
tag = Tag.objects.get(slug=tag_slug)
|
|
except Tag.DoesNotExist:
|
|
# If tag doesn't exist, create it with the slug as both name and slug
|
|
tag = Tag.objects.create(name=tag_slug, slug=tag_slug)
|
|
post.tags.add(tag)
|
|
|
|
return post
|
|
|
|
def update(self, instance, validated_data):
|
|
tag_slugs = validated_data.pop('tags', None)
|
|
for attr, value in validated_data.items():
|
|
setattr(instance, attr, value)
|
|
|
|
if tag_slugs is not None:
|
|
instance.tags.clear()
|
|
for tag_slug in tag_slugs:
|
|
try:
|
|
tag = Tag.objects.get(slug=tag_slug)
|
|
except Tag.DoesNotExist:
|
|
# If tag doesn't exist, create it with the slug as both name and slug
|
|
tag = Tag.objects.create(name=tag_slug, slug=tag_slug)
|
|
instance.tags.add(tag)
|
|
|
|
instance.save()
|
|
return instance
|
|
|
|
class ImageSerializer(serializers.ModelSerializer):
|
|
url = serializers.SerializerMethodField()
|
|
thumbnail_url = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = Image
|
|
fields = ['id', 'collection', 'title', 'description', 'content_type', 'size', 'created_at', 'updated_at', 'url', 'thumbnail_url']
|
|
read_only_fields = ['id', 'collection', 'content_type', 'size', 'created_at', 'updated_at']
|
|
|
|
def get_url(self, obj):
|
|
return obj.get_url()
|
|
|
|
def get_thumbnail_url(self, obj):
|
|
return obj.get_thumbnail_url()
|
|
|
|
class ImageDescriptionSerializer(serializers.ModelSerializer):
|
|
class Meta:
|
|
model = Image
|
|
fields = ['description']
|
|
|
|
class ImageCollectionSerializer(serializers.ModelSerializer):
|
|
image_count = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = ImageCollection
|
|
fields = ['id', 'name', 'description', 'image_count', 'created_at']
|
|
|
|
def get_image_count(self, obj):
|
|
return obj.images.count()
|
|
|
|
class FileUploadSerializer(serializers.ModelSerializer):
|
|
formatted_size = serializers.SerializerMethodField()
|
|
public_url = serializers.SerializerMethodField()
|
|
is_expired = serializers.SerializerMethodField()
|
|
thumbnail_url = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = FileUpload
|
|
fields = [
|
|
'id', 'name', 'mime_type', 'size', 'formatted_size',
|
|
'is_public', 'public_token', 'public_url',
|
|
'expires_at', 'is_expired',
|
|
'download_count', 'created_at', 'updated_at',
|
|
'thumbnail_url',
|
|
]
|
|
read_only_fields = [
|
|
'id', 'mime_type', 'size', 'formatted_size',
|
|
'public_token', 'public_url', 'is_expired',
|
|
'download_count', 'created_at', 'updated_at',
|
|
'thumbnail_url',
|
|
]
|
|
|
|
def get_formatted_size(self, obj):
|
|
return obj.formatted_size()
|
|
|
|
def get_public_url(self, obj):
|
|
return obj.public_url
|
|
|
|
def get_is_expired(self, obj):
|
|
return obj.is_expired
|
|
|
|
def get_thumbnail_url(self, obj):
|
|
return obj.thumbnail_url
|
|
|
|
|
|
class BookmarkSerializer(serializers.ModelSerializer):
|
|
"""Full bookmark for read / single-create / update."""
|
|
|
|
class Meta:
|
|
model = Bookmark
|
|
fields = [
|
|
'id', 'tweet_id', 'url',
|
|
'author_screen_name', 'author_name', 'author_profile_image_url',
|
|
'text', 'summary',
|
|
'tweet_created_at', 'bookmark_created_at',
|
|
'view_count', 'favorite_count', 'retweet_count', 'reply_count',
|
|
'bookmark_count', 'quote_count',
|
|
'has_video', 'has_photo', 'has_card', 'is_quote', 'is_retweet',
|
|
'media', 'card_data', 'quoted_data',
|
|
'data',
|
|
'content_hash', 'last_imported_at',
|
|
'created_at', 'updated_at',
|
|
]
|
|
read_only_fields = ['id', 'content_hash', 'last_imported_at',
|
|
'created_at', 'updated_at']
|
|
|
|
|
|
def _norm_bookmark_for_import(raw, obj=None):
|
|
"""
|
|
Map a normalised x-bookmarks-exporter object (or any compatible dict) onto
|
|
a Bookmark instance so it can be upserted. Reused by API import + mgmt cmd.
|
|
"""
|
|
author = raw.get('author') or {}
|
|
media = raw.get('media') or []
|
|
|
|
tweet_id = raw.get('id') or raw.get('tweet_id')
|
|
if not tweet_id:
|
|
raise serializers.ValidationError('missing id/tweet_id')
|
|
|
|
b = obj or Bookmark(tweet_id=tweet_id)
|
|
b.tweet_id = str(tweet_id)
|
|
b.url = raw.get('url') or ''
|
|
b.author_screen_name = author.get('screen_name') or ''
|
|
b.author_name = author.get('name') or ''
|
|
b.author_profile_image_url = author.get('profile_image_url') or ''
|
|
b.text = raw.get('text') or ''
|
|
b.summary = raw.get('summary') or ''
|
|
|
|
# parse tweet created_at ("Wed Jul 30 12:00:00 +0000 2025" style) — leave
|
|
# None if not parseable, view layer handles null.
|
|
tca = raw.get('created_at')
|
|
if isinstance(tca, str) and tca:
|
|
try:
|
|
b.tweet_created_at = datetime.strptime(tca, '%a %b %d %H:%M:%S %z %Y')
|
|
except ValueError:
|
|
b.tweet_created_at = None
|
|
elif isinstance(tca, datetime):
|
|
b.tweet_created_at = tca
|
|
|
|
bca = raw.get('bookmark_created_at')
|
|
if isinstance(bca, str) and bca:
|
|
try:
|
|
b.bookmark_created_at = datetime.strptime(
|
|
bca, '%a %b %d %H:%M:%S %z %Y')
|
|
except ValueError:
|
|
b.bookmark_created_at = None
|
|
elif isinstance(bca, datetime):
|
|
b.bookmark_created_at = bca
|
|
|
|
b.view_count = raw.get('view_count') or 0
|
|
b.favorite_count = raw.get('favorite_count') or 0
|
|
b.retweet_count = raw.get('retweet_count') or 0
|
|
b.reply_count = raw.get('reply_count') or 0
|
|
b.bookmark_count = raw.get('bookmark_count') or 0
|
|
b.quote_count = raw.get('quote_count') or 0
|
|
|
|
b.has_video = any(m.get('type') in ('video', 'animated_gif') for m in media)
|
|
b.has_photo = any(m.get('type') == 'photo' for m in media)
|
|
b.has_card = bool(raw.get('card'))
|
|
b.is_quote = bool(raw.get('quoted'))
|
|
b.is_retweet = bool(raw.get('retweet'))
|
|
|
|
b.media = media
|
|
b.card_data = raw.get('card')
|
|
b.quoted_data = raw.get('quoted')
|
|
b.data = raw
|
|
|
|
# dedup: record content hash + import timestamp
|
|
b.content_hash = compute_bookmark_content_hash(raw)
|
|
b.last_imported_at = timezone.now()
|
|
|
|
return b
|
|
|
|
|
|
class BookmarkImportItemSerializer(serializers.Serializer):
|
|
"""One element of a bulk-import payload. Accepts the full export object."""
|
|
def to_internal_value(self, data):
|
|
# we don't validate strictly — _norm_bookmark_for_import does the mapping
|
|
return data
|
|
|
|
def create(self, validated):
|
|
tweet_id = str(validated.get('id') or validated.get('tweet_id'))
|
|
new_hash = compute_bookmark_content_hash(validated)
|
|
obj = Bookmark.objects.filter(tweet_id=tweet_id).first()
|
|
if obj is not None and obj.content_hash == new_hash:
|
|
# Content unchanged — only bump the import timestamp.
|
|
obj.last_imported_at = timezone.now()
|
|
obj.save(update_fields=['last_imported_at'])
|
|
return obj
|
|
b = _norm_bookmark_for_import(validated, obj)
|
|
b.save()
|
|
return b
|
|
|
|
def update(self, instance, validated):
|
|
return _norm_bookmark_for_import(validated, instance).save() or instance
|
|
|
|
|
|
class BookmarkImportSerializer(serializers.Serializer):
|
|
"""Top-level bulk-import payload: {bookmarks: [ ... ]}."""
|
|
bookmarks = BookmarkImportItemSerializer(many=True)
|
|
|
|
def create(self, validated):
|
|
items = validated.get('bookmarks', [])
|
|
created = updated = unchanged = skipped = 0
|
|
for raw in items:
|
|
tweet_id = str(raw.get('id') or raw.get('tweet_id') or '')
|
|
if not tweet_id:
|
|
skipped += 1
|
|
continue
|
|
new_hash = compute_bookmark_content_hash(raw)
|
|
existed = Bookmark.objects.filter(tweet_id=tweet_id).first()
|
|
if existed is None:
|
|
BookmarkImportItemSerializer().create(raw)
|
|
created += 1
|
|
elif existed.content_hash == new_hash:
|
|
# Content unchanged — only bump import timestamp.
|
|
existed.last_imported_at = timezone.now()
|
|
existed.save(update_fields=['last_imported_at'])
|
|
unchanged += 1
|
|
else:
|
|
BookmarkImportItemSerializer().create(raw)
|
|
updated += 1
|
|
return {'created': created, 'updated': updated,
|
|
'unchanged': unchanged, 'skipped': skipped}
|