From f5051ad1eaef04c1ff4b14cc95e142ed2e7bdf43 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Mon, 30 Mar 2026 20:12:22 +1100 Subject: [PATCH] Update share post logic --- data/db.sqlite3 | Bin 774144 -> 811008 bytes links/apps.py | 73 +++++++++++++ .../migrations/0047_add_is_public_to_post.py | 18 ++++ links/models.py | 1 + links/post_views.py | 42 ++++++-- links/serializers.py | 14 +-- links/templates/links/knowledge_graph.html | 11 ++ links/templates/links/post_detail.html | 78 ++++++++++++-- links/templates/links/settings.html | 97 ++++++++++++++++++ links/urls.py | 1 + links/views.py | 12 ++- 11 files changed, 326 insertions(+), 21 deletions(-) create mode 100644 links/migrations/0047_add_is_public_to_post.py diff --git a/data/db.sqlite3 b/data/db.sqlite3 index ffedfdd6992a4ea1b5a67b44979c08252c26507f..0471407b3690e8d8e02c5af6273219ec39dc485c 100644 GIT binary patch delta 1231 zcmb`FTWB0r7{||lcj`GmeD=d+4|ZcSv@%~)e;aq^%NwrYj&Wzw z!IO?~(jj{{Y2ae91;qYhf3V-!P4)}>k$um;VPCK->{GVP7TH2RrL?Nj7@ zL!}`MA!$X5h}G7rwO}1Ft{SsiP~J#Kg(1&gBu7O-`P_T^BH1j85ZP1SSC>fr)>6&v zMeIX1$-1mt)=BG8OEW(+r_El7t<`LMphH3>(db%Uj?)y0^(7LqBt-Y({=sBCHj-Kk zG311rgHAXYc5J702fX2y`j$|0b10IVzC!@fo4zBZUh`3U8L_yUGYw@$?xvT)q@TKn zUZ=g0(M(7#>G0!>KRKM}e|i0c?y}pS2n{s#IpOu6t?t#hP}~Lw`5$coySvK{tTzYj z`vd>(J+N&&ooSRy{C;7g!Ts(MDHG5+H`<5q^Yl-MAKP{IUCs9XRBF)f>VC}b>h0{@ zmf4+g+;`rfJ?^R~)p%_)^a4?7rjf#tAe1vsP(TJ0Km|0jxwiRrM9Bvsv=2{)nievZ_MCoFIFQ zAl8uoRL@$)duD~ zUEtwRD_dsjXq-oxeG*np{7R2_uiT)Mm|ZC|43s&Lnf+&-s#W>}_ICbEKakAxd?o?8 zNcbAL`KWW$%$(3dy3p#-40N6iG&3uVZLlyHu M-lKZy_vtb9Zx)a-cK`qY delta 460 zcmZoTVA!xge}c4NAOi!#ULb}7Mv;j+#*Bd*6OPIYO7L6oUFAE-XU)&Vr^3g?dwa8^ z!BXDIw{?Z4r_W>)VPjJ8W_D$q9zT;!X0o#0H%5cWQue!Cn3fqZ2Pm)t z&1L0(4l+H5|2O|P{*U}`_@A>jv9d8AV>D!Nn(SzxwB2a}^HP3Gppp`PAd$}tB$By- zL?%0suwn!8Sy&7hSOZy^Stha=aB*}>pjFWdN1OFcWHhw?8k9^Dc z0{OUjFYrzUdbgUled=FkAZFP<^)GAwBt907V+{Psob9|k9LLxdfhyA3rdLj53!T1V z8XN!A1FSBhElG{~?Bf3Zj2*@$iAg!Bd8y^`@!&vWMKBrjrp{)SoSr(9O=_|$kKyz! zbJ^apG}<;AOkY2TEqMAn7FOl$4l~&fGjqnmOl#+v%Lc^kK+FNeoIuRAoo6oh7b5^V CsewQM diff --git a/links/apps.py b/links/apps.py index 702d4fc..e4581f1 100644 --- a/links/apps.py +++ b/links/apps.py @@ -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', + }, + }) diff --git a/links/migrations/0047_add_is_public_to_post.py b/links/migrations/0047_add_is_public_to_post.py new file mode 100644 index 0000000..667819a --- /dev/null +++ b/links/migrations/0047_add_is_public_to_post.py @@ -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'), + ), + ] diff --git a/links/models.py b/links/models.py index 4d8b632..36b0537 100644 --- a/links/models.py +++ b/links/models.py @@ -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') diff --git a/links/post_views.py b/links/post_views.py index f6ed2e1..469ed5a 100644 --- a/links/post_views.py +++ b/links/post_views.py @@ -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 diff --git a/links/serializers.py b/links/serializers.py index b41098c..7bf48b7 100644 --- a/links/serializers.py +++ b/links/serializers.py @@ -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 diff --git a/links/templates/links/knowledge_graph.html b/links/templates/links/knowledge_graph.html index b925cc0..a7a08b1 100644 --- a/links/templates/links/knowledge_graph.html +++ b/links/templates/links/knowledge_graph.html @@ -81,6 +81,17 @@ + + {% if snapshot %} + {% with built_at=snapshot.completed_at|default:snapshot.created_at %} + + {% trans "Last built" %}: {{ built_at|timesince }} {% trans "ago" %} + + {% endwith %} + {% endif %} +