Update share post logic

This commit is contained in:
2026-03-30 20:12:22 +11:00
parent bd37986b21
commit f5051ad1ea
11 changed files with 326 additions and 21 deletions
BIN
View File
Binary file not shown.
+73
View File
@@ -180,3 +180,76 @@ class LinksConfig(AppConfig):
'delete': 'bulk_delete_image_imports',
},
})
# ── Knowledge Graph Snapshots ────────────────────────────────────
from .models import KnowledgeGraphSnapshot
def kg_stats():
return {
'total': KnowledgeGraphSnapshot.objects.count(),
'processing': KnowledgeGraphSnapshot.objects.filter(
status=KnowledgeGraphSnapshot.Status.BUILDING
).count(),
'completed': KnowledgeGraphSnapshot.objects.filter(
status=KnowledgeGraphSnapshot.Status.READY
).count(),
'failed': KnowledgeGraphSnapshot.objects.filter(
status=KnowledgeGraphSnapshot.Status.FAILED
).count(),
}
def kg_queryset(sf):
qs = KnowledgeGraphSnapshot.objects.order_by('-created_at')
if sf == 'processing':
return qs.filter(status=KnowledgeGraphSnapshot.Status.BUILDING)
if sf == 'completed':
return qs.filter(status=KnowledgeGraphSnapshot.Status.READY)
if sf == 'failed':
return qs.filter(status=KnowledgeGraphSnapshot.Status.FAILED)
return qs
def kg_serialize(obj):
if obj.status == KnowledgeGraphSnapshot.Status.BUILDING:
pct = obj.progress_data.get('pct', 0) if isinstance(obj.progress_data, dict) else 0
title = f'Building… {pct}%'
else:
title = f'{obj.node_count} nodes / {obj.edge_count} edges'
if obj.used_llm:
title += ' (LLM)'
return {
'id': str(obj.id),
'title': title,
'detail_url': reverse('knowledge-graph'),
'status': obj.status,
'retry': None,
'retry_max': None,
'error': obj.error_message or '',
'updated_at': obj.completed_at or obj.created_at,
'extra': {
'duration': (
f'{obj.build_duration_ms // 1000}s' if obj.build_duration_ms else ''
),
},
}
job_registry.register({
'id': 'knowledge_graph',
'label': 'Knowledge Graph',
'icon_color': 'text-pink-500',
'icon_path': (
'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101'
'm-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1'
),
'title_label': 'Snapshot',
'status_choices': [
('all', 'All'), ('processing', 'Building'), ('completed', 'Ready'),
('failed', 'Failed'),
],
'columns': ['id', 'title', 'status', 'error', 'updated'],
'get_stats': kg_stats,
'get_queryset': kg_queryset,
'serialize': kg_serialize,
'bulk_actions': {
'delete': 'bulk_delete_knowledge_graph_snapshots',
},
})
@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-03-30 09:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('links', '0046_kg_progress_data'),
]
operations = [
migrations.AddField(
model_name='post',
name='is_public',
field=models.BooleanField(db_index=True, default=False, verbose_name='Is Public'),
),
]
+1
View File
@@ -272,6 +272,7 @@ class Post(models.Model):
title = models.CharField(_('Title'), max_length=200)
summary = models.TextField(_('Summary'), blank=True, help_text=_('A brief summary of the post'))
content = models.TextField(_('Content'))
is_public = models.BooleanField(_('Is Public'), default=False, db_index=True)
created_at = models.DateTimeField(_('Created at'), auto_now_add=True)
updated_at = models.DateTimeField(_('Updated at'), auto_now=True)
tags = models.ManyToManyField('Tag', blank=True, related_name='posts')
+35 -7
View File
@@ -1,12 +1,15 @@
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
from django.urls import reverse_lazy
import json
from django.http import Http404, JsonResponse
from django.urls import reverse, reverse_lazy
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django.http import Http404
from django.views import View
from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from django.utils import timezone
from datetime import datetime
from .models import Post
from .templatetags.tasklist_markdown import update_task_in_markdown
@@ -105,10 +108,35 @@ class PublicPostView(DetailView):
return context
def get_object(self, queryset=None):
try:
return super().get_object(queryset)
except Http404:
obj = super().get_object(queryset)
if not obj.is_public:
raise Http404("Post not found")
return obj
class PostShareView(View):
"""Toggle public sharing for a post. POST body: {"enable": true|false}"""
def post(self, request, pk):
try:
post = Post.objects.get(pk=pk)
except Post.DoesNotExist:
return JsonResponse({'error': 'Not found'}, status=404)
try:
body = json.loads(request.body or '{}')
enable = bool(body.get('enable', True))
except (json.JSONDecodeError, AttributeError):
enable = True
post.is_public = enable
post.save(update_fields=['is_public'])
public_url = (
request.build_absolute_uri(reverse('public-post', args=[post.pk]))
if post.is_public else None
)
return JsonResponse({'is_public': post.is_public, 'url': public_url})
class StandardResultsSetPagination(PageNumberPagination):
page_size = 10
+7 -7
View File
@@ -25,24 +25,24 @@ class PageSerializer(serializers.ModelSerializer):
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', 'tags', 'tag_details', 'created_at', 'updated_at']
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)
@@ -50,14 +50,14 @@ class PostSerializer(serializers.ModelSerializer):
# 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:
@@ -67,7 +67,7 @@ class PostSerializer(serializers.ModelSerializer):
# 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
@@ -81,6 +81,17 @@
<span class="px-2.5 py-1 rounded-lg bg-white border border-gray-200 text-xs text-gray-600 shadow-sm"
x-text="statusText"></span>
<!-- Last built timestamp -->
{% if snapshot %}
{% with built_at=snapshot.completed_at|default:snapshot.created_at %}
<a href="{% url 'jobs' %}?tab=knowledge_graph"
class="px-2.5 py-1 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-700 shadow-sm hover:bg-amber-100 transition"
title="{{ built_at|date:'Y-m-d H:i:s' }}">
{% trans "Last built" %}: {{ built_at|timesince }} {% trans "ago" %}
</a>
{% endwith %}
{% endif %}
<!-- Node type filters -->
<template x-for="f in filters" :key="f.type">
<span class="filter-badge shadow-sm"
+72 -6
View File
@@ -518,15 +518,37 @@
</svg>
{% trans "Edit" %}
</a>
<a href="{% url 'public-post' post.pk %}"
target="_blank"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-green-700 hover:text-green-800 bg-green-50 hover:bg-green-100 rounded-md transition duration-150">
<!-- Share widget -->
<div x-data="shareWidget()" x-init="init()" class="relative">
<!-- Not yet shared -->
<button x-show="!isPublic" @click="enableShare()"
:disabled="busy"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-green-700 hover:text-green-800 bg-green-50 hover:bg-green-100 rounded-md transition duration-150 disabled:opacity-50">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"/>
</svg>
{% trans "Share" %}
</a>
</button>
<!-- Already shared: copy + revoke -->
<div x-show="isPublic" class="flex items-center gap-1">
<button @click="copyUrl()"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-green-700 bg-green-50 hover:bg-green-100 border border-green-200 rounded-md transition duration-150">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"/>
</svg>
<span x-text="copied ? '{% trans "Copied!" %}' : '{% trans "Shared" %}'"></span>
</button>
<button @click="revokeShare()" :disabled="busy"
title="{% trans 'Revoke public access' %}"
class="p-1.5 text-gray-400 hover:text-red-500 rounded transition disabled:opacity-50">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
</div>
<button onclick="openDeleteModal()"
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-700 hover:text-red-800 bg-red-50 hover:bg-red-100 rounded-md transition duration-150">
<svg class="w-4 h-4 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -723,6 +745,50 @@
{% block extra_js %}
<script>
function shareWidget() {
return {
isPublic: {{ post.is_public|yesno:"true,false" }},
publicUrl: '{{ request.build_absolute_uri }}' .replace('/ui/posts/{{ post.pk }}/', '/public/posts/{{ post.pk }}/'),
busy: false,
copied: false,
init() {
// Compute the actual public URL from the current origin
this.publicUrl = window.location.origin + '{% url "public-post" post.pk %}';
},
async _toggle(enable) {
this.busy = true;
try {
const resp = await fetch('{% url "post-share" post.pk %}', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '',
},
body: JSON.stringify({ enable }),
});
const data = await resp.json();
this.isPublic = data.is_public;
if (data.url) this.publicUrl = data.url;
} finally {
this.busy = false;
}
},
enableShare() { this._toggle(true); },
revokeShare() { this._toggle(false); },
async copyUrl() {
try {
await navigator.clipboard.writeText(this.publicUrl);
this.copied = true;
setTimeout(() => { this.copied = false; }, 2000);
} catch(e) {
window.open(this.publicUrl, '_blank');
}
},
};
}
</script>
<script>
function openDeleteModal() {
document.getElementById('deleteModal').classList.add('show');
document.body.style.overflow = 'hidden';
+97
View File
@@ -268,11 +268,108 @@
</button>
</div>
</form>
<!-- Shared Posts Management -->
<div class="bg-white rounded-lg shadow p-6 mt-6"
x-data="sharedPostsMgr()"
x-init="init()">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-semibold text-gray-700">{% trans "Shared Posts" %}</h2>
<p class="text-sm text-gray-500 mt-0.5">{% trans "Posts currently accessible via public links. Disable to return to 404." %}</p>
</div>
<button x-show="posts.length > 0" @click="revokeAll()"
:disabled="busy"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm text-red-600 border border-red-200 rounded-md hover:bg-red-50 disabled:opacity-50 transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"/>
</svg>
{% trans "Revoke All" %}
</button>
</div>
<!-- Empty state -->
<div x-show="posts.length === 0 && !loading" class="py-6 text-center text-sm text-gray-400">
{% trans "No posts are currently shared publicly." %}
</div>
<!-- Loading -->
<div x-show="loading" class="py-6 text-center text-sm text-gray-400">
{% trans "Loading…" %}
</div>
<!-- List -->
<ul x-show="!loading" class="divide-y divide-gray-100">
<template x-for="post in posts" :key="post.id">
<li class="flex items-center gap-3 py-3">
<div class="flex-1 min-w-0">
<a :href="'/ui/posts/' + post.id + '/'"
class="font-medium text-gray-800 hover:text-blue-600 text-sm truncate block" x-text="post.title"></a>
<a :href="post.publicUrl" target="_blank"
class="text-xs text-gray-400 hover:text-gray-600 font-mono" x-text="post.publicUrl"></a>
</div>
<button @click="revoke(post)" :disabled="busy"
class="flex-shrink-0 flex items-center gap-1 px-2.5 py-1 text-xs text-red-600 border border-red-200 rounded hover:bg-red-50 disabled:opacity-50 transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
{% trans "Revoke" %}
</button>
</li>
</template>
</ul>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function sharedPostsMgr() {
return {
posts: [],
loading: false,
busy: false,
init() {
{% for p in public_posts %}
this.posts.push({
id: {{ p.pk }},
title: '{{ p.title|escapejs }}',
publicUrl: window.location.origin + '{% url "public-post" p.pk %}',
});
{% endfor %}
},
async _toggle(postId, enable) {
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
await fetch('/ui/posts/' + postId + '/share/', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrf },
body: JSON.stringify({ enable }),
});
},
async revoke(post) {
this.busy = true;
try {
await this._toggle(post.id, false);
this.posts = this.posts.filter(p => p.id !== post.id);
} finally {
this.busy = false;
}
},
async revokeAll() {
if (!confirm('{% trans "Revoke public access for all shared posts?" %}')) return;
this.busy = true;
try {
await Promise.all(this.posts.map(p => this._toggle(p.id, false)));
this.posts = [];
} finally {
this.busy = false;
}
},
};
}
function llmSettingsHelper() {
return {
provider: '{{ site_settings.llm_provider }}',
+1
View File
@@ -43,6 +43,7 @@ urlpatterns = [
path('ui/posts/<int:pk>/', post_views.PostDetailView.as_view(), name='post-detail'),
path('ui/posts/<int:pk>/edit/', post_views.PostUpdateView.as_view(), name='post-update'),
path('ui/posts/<int:pk>/delete/', post_views.PostDeleteView.as_view(), name='post-delete'),
path('ui/posts/<int:pk>/share/', post_views.PostShareView.as_view(), name='post-share'),
path('ui/posts/<int:post_id>/tts/', views.generate_tts, name='post-tts'),
# Collection slideshow
+11 -1
View File
@@ -567,7 +567,11 @@ class SiteSettingsView(View):
def get(self, request):
site_settings = SiteSettings.get()
return render(request, self.template_name, {'site_settings': site_settings})
public_posts = Post.objects.filter(is_public=True).order_by('-updated_at')
return render(request, self.template_name, {
'site_settings': site_settings,
'public_posts': public_posts,
})
def post(self, request):
from apscheduler.triggers.interval import IntervalTrigger
@@ -812,6 +816,12 @@ class JobsView(View):
n = qs.delete()[0]
messages.success(request, _(f'Deleted {n} netscan run(s).'))
elif action == 'bulk_delete_knowledge_graph_snapshots':
from .models import KnowledgeGraphSnapshot
qs = KnowledgeGraphSnapshot.objects.filter(pk__in=ids) if ids else KnowledgeGraphSnapshot.objects.none()
n = qs.delete()[0]
messages.success(request, _(f'Deleted {n} knowledge graph snapshot(s).'))
else:
messages.error(request, _('Unknown action.'))