mirror of
https://github.com/wahyd4/links.git
synced 2026-08-09 05:06:16 +10:00
Update share post logic
This commit is contained in:
Binary file not shown.
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 }}',
|
||||
|
||||
@@ -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
@@ -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.'))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user