| # notes/views.py | |
| import csv | |
| import json | |
| import logging | |
| from datetime import timedelta | |
| from io import StringIO | |
| from django.db.models import Count, Q | |
| from django.http import Http404, HttpResponse | |
| from django.shortcuts import get_object_or_404 | |
| from django.utils import timezone | |
| from rest_framework import status | |
| from rest_framework.decorators import api_view, permission_classes | |
| from rest_framework.pagination import PageNumberPagination | |
| from rest_framework.permissions import AllowAny, IsAuthenticated | |
| from rest_framework.response import Response | |
| from .filters import NoteFilter | |
| from .models import NoteMention, ResearcherNote, WebhookSubscription | |
| from .serializers import ( | |
| NoteImageSerializer, | |
| ResearcherNoteSerializer, | |
| WebhookSubscriptionSerializer, | |
| ) | |
| logger = logging.getLogger(__name__) | |
| class StandardPagination(PageNumberPagination): | |
| page_size = 20 | |
| page_size_query_param = "page_size" | |
| max_page_size = 100 | |
| from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector | |
| from .models import NoteComment, NoteTemplate, Notification | |
| # ─── به ابتدای فایل اضافه کنید ─── | |
| from .report_generator import generate_experiment_report | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 18. Auto-Generate Experiment Report | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def generate_run_report(request, run_id, version="v1"): | |
| """Auto-generate a rich Markdown report from a training run.""" | |
| note = generate_experiment_report(run_id=run_id, author=request.user, save=True) | |
| if note is None: | |
| return Response({"error": "Run not found"}, status=404) | |
| serializer = ResearcherNoteSerializer(note) | |
| return Response(serializer.data, status=status.HTTP_201_CREATED) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 19. Note Templates | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def note_templates(request, version="v1"): | |
| """List all templates or create a new one.""" | |
| if request.method == "GET": | |
| note_type = request.query_params.get("note_type") | |
| templates = NoteTemplate.objects.all() | |
| if note_type: | |
| templates = templates.filter(note_type=note_type) | |
| data = [ | |
| { | |
| "id": str(t.id), | |
| "name": t.name, | |
| "description": t.description, | |
| "note_type": t.note_type, | |
| "variables_schema": t.variables_schema, | |
| "is_default": t.is_default, | |
| } | |
| for t in templates | |
| ] | |
| return Response(data) | |
| elif request.method == "POST": | |
| template = NoteTemplate.objects.create( | |
| name=request.data.get("name", "Untitled Template"), | |
| description=request.data.get("description", ""), | |
| note_type=request.data.get("note_type", "general"), | |
| content_template=request.data.get("content_template", ""), | |
| variables_schema=request.data.get("variables_schema", {}), | |
| created_by=request.user, | |
| ) | |
| return Response({"id": str(template.id), "status": "created"}, status=201) | |
| def render_template(request, template_id, version="v1"): | |
| """Render a template with provided variables and create a note.""" | |
| template = get_object_or_404(NoteTemplate, id=template_id) | |
| variables = request.data.get("variables", {}) | |
| content = template.render(variables) | |
| note = ResearcherNote.objects.create( | |
| author=request.user, | |
| title=request.data.get("title", template.name), | |
| content=content, | |
| note_type=template.note_type, | |
| tags=request.data.get("tags", []), | |
| run_id=request.data.get("run_id"), | |
| ) | |
| serializer = ResearcherNoteSerializer(note) | |
| return Response(serializer.data, status=201) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 20. Full-Text Search | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def fulltext_search(request, version="v1"): | |
| """Full-text search across all notes using PostgreSQL.""" | |
| query = request.query_params.get("q", "").strip() | |
| if not query: | |
| return Response({"results": [], "total": 0}) | |
| search_query = SearchQuery(query, config="english") | |
| search_vector = SearchVector("title", weight="A", config="english") + SearchVector( | |
| "content", weight="B", config="english" | |
| ) | |
| notes = ( | |
| ResearcherNote.objects.filter(deleted_at__isnull=True) | |
| .annotate(rank=SearchRank(search_vector, search_query)) | |
| .filter(rank__gte=0.05) | |
| .order_by("-rank") | |
| ) | |
| if not request.user.is_staff: | |
| notes = notes.filter(models.Q(is_public=True) | models.Q(author=request.user)) | |
| paginator = StandardPagination() | |
| page = paginator.paginate_queryset(notes, request) | |
| data = [ | |
| { | |
| "id": str(n.id), | |
| "title": n.title, | |
| "content_preview": n.content[:300], | |
| "author": n.author.username, | |
| "note_type": n.note_type, | |
| "tags": n.tags, | |
| "rank": round(n.rank, 4), | |
| "created_at": n.created_at, | |
| } | |
| for n in page | |
| ] | |
| return paginator.get_paginated_response(data) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 21. Favorite / Bookmark | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def toggle_favorite(request, note_id, version="v1"): | |
| """Toggle favorite/bookmark status of a note.""" | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if not note.is_public and note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| note.toggle_favorite() | |
| return Response({"is_favorite": note.is_favorite}) | |
| def favorite_notes(request, version="v1"): | |
| """List all favorite/bookmarked notes for the user.""" | |
| notes = ResearcherNote.objects.filter( | |
| author=request.user, is_favorite=True, deleted_at__isnull=True | |
| ).order_by("-updated_at") | |
| paginator = StandardPagination() | |
| page = paginator.paginate_queryset(notes, request) | |
| serializer = ResearcherNoteSerializer(page, many=True) | |
| return paginator.get_paginated_response(serializer.data) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 22. Comments on Notes | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def note_comments(request, note_id, version="v1"): | |
| """Get or add comments on a note.""" | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if request.method == "GET": | |
| comments = note.comments.filter(parent_comment__isnull=True).order_by( | |
| "created_at" | |
| ) | |
| data = [ | |
| { | |
| "id": str(c.id), | |
| "author": c.author.username, | |
| "content": c.content, | |
| "is_resolved": c.is_resolved, | |
| "created_at": c.created_at, | |
| "replies": [ | |
| { | |
| "id": str(r.id), | |
| "author": r.author.username, | |
| "content": r.content, | |
| "created_at": r.created_at, | |
| } | |
| for r in c.replies.all() | |
| ], | |
| } | |
| for c in comments | |
| ] | |
| return Response(data) | |
| elif request.method == "POST": | |
| comment = NoteComment.objects.create( | |
| note=note, | |
| author=request.user, | |
| content=request.data.get("content", ""), | |
| parent_comment_id=request.data.get("parent_comment_id"), | |
| ) | |
| return Response({"id": str(comment.id), "status": "created"}, status=201) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 23. Dashboard / Analytics | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def notes_dashboard(request, version="v1"): | |
| """Analytics dashboard for notes.""" | |
| base_qs = ResearcherNote.objects.filter(deleted_at__isnull=True) | |
| user_qs = base_qs.filter(author=request.user) | |
| return Response( | |
| { | |
| "total_notes": base_qs.count(), | |
| "my_notes": user_qs.count(), | |
| "public_notes": base_qs.filter(is_public=True).count(), | |
| "favorites": user_qs.filter(is_favorite=True).count(), | |
| "pinned": user_qs.filter(is_pinned=True).count(), | |
| "by_type": { | |
| t[0]: user_qs.filter(note_type=t[0]).count() | |
| for t in ResearcherNote.NOTE_TYPES | |
| }, | |
| "by_tag": _tag_distribution(user_qs), | |
| "recent_activity": list( | |
| user_qs.order_by("-updated_at")[:5].values( | |
| "id", "title", "note_type", "updated_at" | |
| ) | |
| ), | |
| "linked_runs": user_qs.filter(run__isnull=False).count(), | |
| "total_comments": NoteComment.objects.filter( | |
| note__author=request.user | |
| ).count(), | |
| "total_mentions": NoteMention.objects.filter( | |
| mentioned_user=request.user, is_read=False | |
| ).count(), | |
| } | |
| ) | |
| def _tag_distribution(queryset): | |
| """Compute tag frequency across notes.""" | |
| tag_counts = {} | |
| for note in queryset: | |
| for tag in note.tags or []: | |
| tag_counts[tag] = tag_counts.get(tag, 0) + 1 | |
| return dict(sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)[:20]) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 24. Export as Markdown | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def export_note_markdown(request, note_id, version="v1"): | |
| """Export note as raw Markdown file.""" | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if not note.is_public and note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| md_content = f"""--- | |
| title: "{note.title}" | |
| author: "{note.author.username}" | |
| date: "{note.created_at.isoformat()}" | |
| tags: {json.dumps(note.tags)} | |
| note_type: "{note.note_type}" | |
| version: {note.version} | |
| --- | |
| {note.content} | |
| """ | |
| response = HttpResponse(md_content, content_type="text/markdown") | |
| response["Content-Disposition"] = ( | |
| f'attachment; filename="note_{note.slug or note.id}.md"' | |
| ) | |
| return response | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 25. Notifications | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def my_notifications(request, version="v1"): | |
| """List user's notifications.""" | |
| notifs = Notification.objects.filter(recipient=request.user).order_by("-created_at") | |
| unread_only = request.query_params.get("unread_only", "false").lower() == "true" | |
| if unread_only: | |
| notifs = notifs.filter(is_read=False) | |
| paginator = StandardPagination() | |
| page = paginator.paginate_queryset(notifs, request) | |
| data = [ | |
| { | |
| "id": str(n.id), | |
| "type": n.type, | |
| "content": n.content, | |
| "sender": n.sender.username if n.sender else None, | |
| "note_id": str(n.note_id) if n.note else None, | |
| "is_read": n.is_read, | |
| "created_at": n.created_at, | |
| } | |
| for n in page | |
| ] | |
| return paginator.get_paginated_response(data) | |
| def mark_all_notifications_read(request, version="v1"): | |
| """Mark all notifications as read.""" | |
| Notification.objects.filter(recipient=request.user, is_read=False).update( | |
| is_read=True | |
| ) | |
| return Response({"status": "all marked read"}) | |
| # ---------------------------------------------------------------------- | |
| # 1. Note List & Create | |
| # ---------------------------------------------------------------------- | |
| def note_list(request, version="v1"): | |
| if request.method == "GET": | |
| notes = ResearcherNote.objects.filter(deleted_at__isnull=True) | |
| filter_set = NoteFilter(request.query_params, queryset=notes) | |
| notes = filter_set.qs | |
| if not request.user.is_staff: | |
| notes = notes.filter(Q(is_public=True) | Q(author=request.user)) | |
| if request.query_params.get("export") == "csv": | |
| return export_notes_csv(notes) | |
| if request.query_params.get("export") == "json": | |
| serializer = ResearcherNoteSerializer(notes, many=True) | |
| return Response(serializer.data) | |
| paginator = StandardPagination() | |
| page = paginator.paginate_queryset(notes, request) | |
| serializer = ResearcherNoteSerializer(page, many=True) | |
| return paginator.get_paginated_response(serializer.data) | |
| elif request.method == "POST": | |
| serializer = ResearcherNoteSerializer(data=request.data) | |
| if serializer.is_valid(): | |
| note = serializer.save(author=request.user) | |
| logger.info( | |
| "Note created", | |
| extra={ | |
| "user": request.user.username, | |
| "note_id": str(note.id), | |
| "event": "note.created", | |
| }, | |
| ) | |
| return Response(serializer.data, status=status.HTTP_201_CREATED) | |
| return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) | |
| # ---------------------------------------------------------------------- | |
| # 2. Note Detail, Update, Delete | |
| # ---------------------------------------------------------------------- | |
| def note_detail(request, note_id, version="v1"): | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| # ----- Privacy & Permission Guard ----- | |
| if not note.is_public and note.author != request.user and not request.user.is_staff: | |
| # For GET requests, hide the existence of the note entirely | |
| if request.method == "GET": | |
| raise Http404 | |
| # For any modifying request, return Forbidden (403) | |
| return Response({"error": "Not allowed"}, status=403) | |
| # For GET, everyone who passed the guard can see the note | |
| if request.method == "GET": | |
| serializer = ResearcherNoteSerializer(note) | |
| return Response(serializer.data) | |
| # For modifying requests, only author or staff are allowed | |
| if note.author != request.user and not request.user.is_staff: | |
| return Response({"error": "Not allowed"}, status=403) | |
| if request.method == "PUT" or request.method == "PATCH": | |
| partial = request.method == "PATCH" | |
| serializer = ResearcherNoteSerializer(note, data=request.data, partial=partial) | |
| if serializer.is_valid(): | |
| serializer.save() | |
| if "content" in request.data: | |
| logger.info( | |
| "Note updated", | |
| extra={ | |
| "user": request.user.username, | |
| "note_id": str(note.id), | |
| "event": "note.updated", | |
| }, | |
| ) | |
| return Response(serializer.data) | |
| return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) | |
| elif request.method == "DELETE": | |
| note.soft_delete() | |
| logger.info( | |
| "Note deleted", | |
| extra={ | |
| "user": request.user.username, | |
| "note_id": str(note.id), | |
| "event": "note.deleted", | |
| }, | |
| ) | |
| return Response(status=status.HTTP_204_NO_CONTENT) | |
| # ---------------------------------------------------------------------- | |
| # 3. Reactions | |
| # ---------------------------------------------------------------------- | |
| def note_reaction(request, note_id, version="v1"): | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| reaction_type = request.data.get("reaction") | |
| if not reaction_type: | |
| return Response({"error": "reaction type required"}, status=400) | |
| reactions = note.reactions or {} | |
| users = reactions.get(reaction_type, []) | |
| if request.user.username in users: | |
| users.remove(request.user.username) | |
| else: | |
| users.append(request.user.username) | |
| reactions[reaction_type] = users | |
| note.reactions = reactions | |
| note.save() | |
| logger.info( | |
| "Reaction added", | |
| extra={ | |
| "user": request.user.username, | |
| "note_id": str(note.id), | |
| "reaction": reaction_type, | |
| }, | |
| ) | |
| return Response({"status": "updated", "reactions": reactions}) | |
| # ---------------------------------------------------------------------- | |
| # 4. Image Upload | |
| # ---------------------------------------------------------------------- | |
| def upload_note_image(request, note_id, version="v1"): | |
| note = get_object_or_404(ResearcherNote, id=note_id) | |
| if note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| serializer = NoteImageSerializer(data=request.data) | |
| if serializer.is_valid(): | |
| serializer.save(note=note) | |
| return Response(serializer.data, status=201) | |
| return Response(serializer.errors, status=400) | |
| # ---------------------------------------------------------------------- | |
| # 5. Bulk Operations | |
| # ---------------------------------------------------------------------- | |
| def bulk_notes(request, version="v1"): | |
| action = request.data.get("action") | |
| if not action: | |
| return Response({"error": "action required"}, status=400) | |
| if action == "delete": | |
| note_ids = request.data.get("ids", []) | |
| notes = ResearcherNote.objects.filter( | |
| id__in=note_ids, author=request.user, deleted_at__isnull=True | |
| ) | |
| count = notes.count() | |
| for note in notes: | |
| note.soft_delete() | |
| return Response({"deleted": count}) | |
| elif action == "create": | |
| items = request.data.get("items", []) | |
| created = [] | |
| for item in items: | |
| serializer = ResearcherNoteSerializer(data=item) | |
| if serializer.is_valid(): | |
| note = serializer.save(author=request.user) | |
| created.append(str(note.id)) | |
| else: | |
| return Response(serializer.errors, status=400) | |
| return Response({"created": created}) | |
| return Response({"error": f"Unknown action {action}"}, status=400) | |
| # ---------------------------------------------------------------------- | |
| # 6. Export CSV | |
| # ---------------------------------------------------------------------- | |
| def export_notes_csv(queryset): | |
| output = StringIO() | |
| writer = csv.writer(output) | |
| writer.writerow(["ID", "Title", "Author", "Run ID", "Tags", "Created At"]) | |
| for note in queryset: | |
| writer.writerow( | |
| [ | |
| note.id, | |
| note.title, | |
| note.author.username, | |
| note.run_id, | |
| ",".join(note.tags), | |
| note.created_at, | |
| ] | |
| ) | |
| response = HttpResponse(output.getvalue(), content_type="text/csv") | |
| response["Content-Disposition"] = 'attachment; filename="notes_export.csv"' | |
| return response | |
| # ---------------------------------------------------------------------- | |
| # 7. Webhook Management | |
| # ---------------------------------------------------------------------- | |
| def webhook_list(request, version="v1"): | |
| if request.method == "GET": | |
| subs = WebhookSubscription.objects.filter(user=request.user) | |
| serializer = WebhookSubscriptionSerializer(subs, many=True) | |
| return Response(serializer.data) | |
| elif request.method == "POST": | |
| serializer = WebhookSubscriptionSerializer(data=request.data) | |
| if serializer.is_valid(): | |
| serializer.save(user=request.user) | |
| return Response(serializer.data, status=201) | |
| return Response(serializer.errors, status=400) | |
| def webhook_delete(request, webhook_id, version="v1"): | |
| sub = get_object_or_404(WebhookSubscription, id=webhook_id, user=request.user) | |
| sub.delete() | |
| return Response(status=204) | |
| # ---------------------------------------------------------------------- | |
| # 8. Versioned URL wrapper | |
| # ---------------------------------------------------------------------- | |
| def versioned_view(view_func): | |
| from functools import wraps | |
| from django.views.decorators.csrf import csrf_exempt | |
| def wrapper(request, *args, **kwargs): | |
| version = request.resolver_match.kwargs.get("version", "v1") | |
| return view_func(request, version=version, *args, **kwargs) | |
| return wrapper | |
| # ---------------------------------------------------------------------- | |
| # 9. Note Versions (history) | |
| # ---------------------------------------------------------------------- | |
| def note_versions(request, note_id, version="v1"): | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if not request.user.is_staff and note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| versions = note.versions.all().order_by("-version_number") | |
| data = [ | |
| { | |
| "version": v.version_number, | |
| "content_preview": v.content_snapshot[:200], | |
| "changed_by": v.changed_by.username if v.changed_by else None, | |
| "change_reason": v.change_reason, | |
| "created_at": v.created_at, | |
| } | |
| for v in versions | |
| ] | |
| return Response(data) | |
| def note_version_detail(request, note_id, version_number, version="v1"): | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if not request.user.is_staff and note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| version_obj = get_object_or_404(note.versions, version_number=version_number) | |
| return Response( | |
| { | |
| "version": version_obj.version_number, | |
| "title": version_obj.title_snapshot or note.title, | |
| "content": version_obj.content_snapshot, | |
| "changed_by": ( | |
| version_obj.changed_by.username if version_obj.changed_by else None | |
| ), | |
| "change_reason": version_obj.change_reason, | |
| "created_at": version_obj.created_at, | |
| } | |
| ) | |
| # ---------------------------------------------------------------------- | |
| # 10. Mentions | |
| # ---------------------------------------------------------------------- | |
| def note_mentions(request, note_id, version="v1"): | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if not request.user.is_staff and note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| mentions = note.mentions.select_related("mentioned_user").all() | |
| data = [ | |
| { | |
| "user": m.mentioned_user.username, | |
| "is_read": m.is_read, | |
| "created_at": m.created_at, | |
| } | |
| for m in mentions | |
| ] | |
| return Response(data) | |
| def mark_mention_read(request, mention_id, version="v1"): | |
| mention = get_object_or_404(NoteMention, id=mention_id, mentioned_user=request.user) | |
| mention.is_read = True | |
| mention.save() | |
| return Response({"status": "marked_read"}) | |
| # ---------------------------------------------------------------------- | |
| # 11. Replies | |
| # ---------------------------------------------------------------------- | |
| def note_replies(request, note_id, version="v1"): | |
| note = get_object_or_404( | |
| ResearcherNote, id=note_id, deleted_at__isnull=True, parent_note__isnull=True | |
| ) | |
| replies = note.replies.filter(deleted_at__isnull=True).order_by("created_at") | |
| serializer = ResearcherNoteSerializer(replies, many=True) | |
| return Response(serializer.data) | |
| # ---------------------------------------------------------------------- | |
| # 12. Restore | |
| # ---------------------------------------------------------------------- | |
| def restore_note(request, note_id, version="v1"): | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=False) | |
| if note.author != request.user and not request.user.is_staff: | |
| return Response({"error": "Not allowed"}, status=403) | |
| note.deleted_at = None | |
| note.save(update_fields=["deleted_at"]) | |
| logger.info( | |
| "Note restored", | |
| extra={ | |
| "user": request.user.username, | |
| "note_id": str(note.id), | |
| "event": "note.restored", | |
| }, | |
| ) | |
| return Response({"status": "restored"}) | |
| # ---------------------------------------------------------------------- | |
| # 13. Export as PDF | |
| # ---------------------------------------------------------------------- | |
| def export_note_pdf(request, note_id, version="v1"): | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if not note.is_public and note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| from weasyprint import HTML | |
| html_content = f""" | |
| <html> | |
| <head><title>{note.title or 'Note'}</title></head> | |
| <body> | |
| <h1>{note.title or 'Untitled'}</h1> | |
| <p>Author: {note.author.username}</p> | |
| <p>Created: {note.created_at}</p> | |
| <hr> | |
| <div>{note.content}</div> | |
| </body> | |
| </html> | |
| """ | |
| pdf_file = HTML(string=html_content).write_pdf() | |
| response = HttpResponse(pdf_file, content_type="application/pdf") | |
| response["Content-Disposition"] = f'attachment; filename="note_{note_id}.pdf"' | |
| return response | |
| # ---------------------------------------------------------------------- | |
| # 14. Test Webhook | |
| # ---------------------------------------------------------------------- | |
| def test_webhook(request, webhook_id, version="v1"): | |
| from .webhook_utils import manual_send | |
| sub = get_object_or_404(WebhookSubscription, id=webhook_id, user=request.user) | |
| test_payload = { | |
| "event": "test", | |
| "timestamp": timezone.now().isoformat(), | |
| "data": {"message": "This is a test webhook payload"}, | |
| } | |
| success = manual_send(sub.url, test_payload, sub.secret) | |
| return Response({"success": success, "url": sub.url}) | |
| # ---------------------------------------------------------------------- | |
| # 15. Statistics | |
| # ---------------------------------------------------------------------- | |
| def notes_statistics(request, version="v1"): | |
| total_notes = ResearcherNote.objects.filter(deleted_at__isnull=True).count() | |
| public_notes = ResearcherNote.objects.filter( | |
| deleted_at__isnull=True, is_public=True | |
| ).count() | |
| notes_by_author = ( | |
| ResearcherNote.objects.filter(deleted_at__isnull=True) | |
| .values("author__username") | |
| .annotate(count=Count("id")) | |
| .order_by("-count")[:10] | |
| ) | |
| notes_by_run = ( | |
| ResearcherNote.objects.filter(deleted_at__isnull=True, run__isnull=False) | |
| .values("run__id") | |
| .annotate(count=Count("id")) | |
| .order_by("-count")[:10] | |
| ) | |
| return Response( | |
| { | |
| "total_notes": total_notes, | |
| "public_notes": public_notes, | |
| "private_notes": total_notes - public_notes, | |
| "top_authors": list(notes_by_author), | |
| "top_runs": list(notes_by_run), | |
| } | |
| ) | |
| # ---------------------------------------------------------------------- | |
| # 16. Recent Notes | |
| # ---------------------------------------------------------------------- | |
| def recent_notes(request, version="v1"): | |
| days = int(request.query_params.get("days", 7)) | |
| cutoff = timezone.now() - timedelta(days=days) | |
| notes = ResearcherNote.objects.filter( | |
| deleted_at__isnull=True, updated_at__gte=cutoff | |
| ).order_by("-updated_at") | |
| if not request.user.is_staff: | |
| notes = notes.filter(Q(is_public=True) | Q(author=request.user)) | |
| paginator = StandardPagination() | |
| page = paginator.paginate_queryset(notes, request) | |
| serializer = ResearcherNoteSerializer(page, many=True) | |
| return paginator.get_paginated_response(serializer.data) | |
| # ---------------------------------------------------------------------- | |
| # 17. Bulk Tag Operations | |
| # ---------------------------------------------------------------------- | |
| def bulk_tags(request, version="v1"): | |
| action = request.data.get("action") | |
| note_ids = request.data.get("ids", []) | |
| tags = request.data.get("tags", []) | |
| if not action or not note_ids or not tags: | |
| return Response({"error": "action, ids, and tags required"}, status=400) | |
| notes = ResearcherNote.objects.filter( | |
| id__in=note_ids, author=request.user, deleted_at__isnull=True | |
| ) | |
| if action == "add": | |
| for note in notes: | |
| new_tags = list(set(note.tags + tags)) | |
| note.tags = new_tags | |
| note.save(update_fields=["tags"]) | |
| elif action == "remove": | |
| for note in notes: | |
| new_tags = [t for t in note.tags if t not in tags] | |
| note.tags = new_tags | |
| note.save(update_fields=["tags"]) | |
| else: | |
| return Response({"error": "action must be 'add' or 'remove'"}, status=400) | |
| return Response({"updated": notes.count()}) | |
| def run_notes(request, run_id): | |
| """Get all notes linked to a specific training run.""" | |
| notes = ResearcherNote.objects.filter( | |
| run__id=run_id, deleted_at__isnull=True | |
| ).order_by("-created_at") | |
| if not request.user.is_staff: | |
| notes = notes.filter(Q(is_public=True) | Q(author=request.user)) | |
| paginator = StandardPagination() | |
| page = paginator.paginate_queryset(notes, request) | |
| serializer = ResearcherNoteSerializer(page, many=True) | |
| return paginator.get_paginated_response(serializer.data) | |
| def share_note(request, note_id): | |
| note = get_object_or_404(ResearcherNote, id=note_id) | |
| if note.author != request.user: | |
| return Response({"error": "Only the author can share"}, status=403) | |
| username = request.data.get("username") | |
| if not username: | |
| return Response({"error": "username required"}, status=400) | |
| try: | |
| user_to_share = User.objects.get(username=username) | |
| except User.DoesNotExist: | |
| return Response({"error": "User not found"}, status=404) | |
| note.shared_with.add(user_to_share) | |
| # After note.shared_with.add(user_to_share) | |
| from experiment.activity import log_activity | |
| log_activity( | |
| user=request.user, | |
| action_type="share", | |
| description=f'Shared note "{note.title or "Untitled"}" with {user_to_share.username}', | |
| content_object=note, | |
| metadata={"note_id": str(note.id), "shared_with": user_to_share.username}, | |
| ) | |
| return Response( | |
| { | |
| "status": "shared", | |
| "shared_with": [u.username for u in note.shared_with.all()], | |
| } | |
| ) | |
| def unshare_note(request, note_id): | |
| note = get_object_or_404(ResearcherNote, id=note_id) | |
| if note.author != request.user: | |
| return Response({"error": "Only the author can unshare"}, status=403) | |
| username = request.data.get("username") | |
| if not username: | |
| return Response({"error": "username required"}, status=400) | |
| note.shared_with.remove(User.objects.get(username=username)) | |
| return Response({"status": "unshared"}) | |
| def shared_with_me(request): | |
| notes = ResearcherNote.objects.filter( | |
| shared_with=request.user, deleted_at__isnull=True | |
| ) | |
| serializer = ResearcherNoteSerializer(notes, many=True) | |
| return Response(serializer.data) | |
| def toggle_pin(request, note_id): | |
| note = get_object_or_404(ResearcherNote, id=note_id) | |
| if note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| note.pinned = not note.pinned | |
| note.save(update_fields=["pinned"]) | |
| return Response({"pinned": note.pinned}) | |
| # notes/views.py – add at the bottom | |
| from .hf_upload import upload_note_to_hub | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 26. HuggingFace Upload | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def upload_note_to_huggingface(request, note_id, version="v1"): | |
| """Upload a note to HuggingFace Hub as a Markdown dataset/space.""" | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if note.author != request.user and not request.user.is_staff: | |
| return Response({"error": "Not allowed"}, status=403) | |
| try: | |
| hub_url = upload_note_to_hub(note) | |
| note.metadata["hf_url"] = hub_url | |
| note.save(update_fields=["metadata"]) | |
| return Response({"status": "uploaded", "url": hub_url}) | |
| except Exception as e: | |
| return Response({"error": str(e)}, status=500) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 27. Workspace CRUD | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def workspace_list(request, version="v1"): | |
| if request.method == "GET": | |
| workspaces = Workspace.objects.filter( | |
| models.Q(created_by=request.user) | models.Q(members=request.user) | |
| ).distinct() | |
| data = [ | |
| { | |
| "id": str(w.id), | |
| "name": w.name, | |
| "description": w.description, | |
| "member_count": w.members.count(), | |
| "is_archived": w.is_archived, | |
| } | |
| for w in workspaces | |
| ] | |
| return Response(data) | |
| elif request.method == "POST": | |
| workspace = Workspace.objects.create( | |
| name=request.data.get("name", "Untitled"), | |
| description=request.data.get("description", ""), | |
| created_by=request.user, | |
| ) | |
| return Response({"id": str(workspace.id), "status": "created"}, status=201) | |
| def workspace_add_note(request, workspace_id, note_id, version="v1"): | |
| workspace = get_object_or_404(Workspace, id=workspace_id) | |
| note = get_object_or_404(ResearcherNote, id=note_id) | |
| workspace.notes.add(note) | |
| return Response({"status": "added"}) | |
| # (You can add more workspace endpoints similarly) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 28. Task CRUD | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def task_list(request, note_id, version="v1"): | |
| note = get_object_or_404(ResearcherNote, id=note_id) | |
| if request.method == "GET": | |
| tasks = note.tasks.all() | |
| data = [ | |
| { | |
| "id": str(t.id), | |
| "title": t.title, | |
| "status": t.status, | |
| "due_date": t.due_date, | |
| "assigned_to": t.assigned_to.username if t.assigned_to else None, | |
| } | |
| for t in tasks | |
| ] | |
| return Response(data) | |
| elif request.method == "POST": | |
| task = Task.objects.create( | |
| note=note, | |
| title=request.data.get("title", ""), | |
| description=request.data.get("description", ""), | |
| assigned_to_id=request.data.get("assigned_to"), | |
| status=request.data.get("status", "pending"), | |
| due_date=request.data.get("due_date"), | |
| ) | |
| return Response({"id": str(task.id)}, status=201) | |
| def task_update(request, note_id, task_id, version="v1"): | |
| task = get_object_or_404(Task, id=task_id, note_id=note_id) | |
| if request.data.get("status"): | |
| task.status = request.data["status"] | |
| task.save() | |
| return Response({"status": "updated"}) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 29. Model Registry | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def model_registry_list(request, version="v1"): | |
| if request.method == "GET": | |
| models_qs = ModelRegistry.objects.filter(created_by=request.user) | |
| data = [ | |
| { | |
| "id": str(m.id), | |
| "name": m.name, | |
| "version": m.version, | |
| "stage": m.stage, | |
| "created_at": m.created_at, | |
| } | |
| for m in models_qs | |
| ] | |
| return Response(data) | |
| elif request.method == "POST": | |
| registry = ModelRegistry.objects.create( | |
| name=request.data["name"], | |
| version=request.data["version"], | |
| description=request.data.get("description", ""), | |
| stage=request.data.get("stage", "development"), | |
| created_by=request.user, | |
| ) | |
| return Response({"id": str(registry.id)}, status=201) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 30. Note Links | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def create_note_link(request, version="v1"): | |
| source_id = request.data.get("source_id") | |
| target_id = request.data.get("target_id") | |
| link_type = request.data.get("link_type", "related") | |
| source = get_object_or_404(ResearcherNote, id=source_id, author=request.user) | |
| target = get_object_or_404(ResearcherNote, id=target_id) | |
| link, created = NoteLink.objects.get_or_create( | |
| source=source, target=target, defaults={"link_type": link_type} | |
| ) | |
| return Response({"status": "created" if created else "already_exists"}) | |
| # notes/views.py — append at the bottom of the file | |
| from .hf_upload import sync_note_to_hf, upload_note_as_space | |
| from .models import Artifact, NoteLink, Protocol, ShareableLink | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 26. HuggingFace Upload — as Dataset or Space (free hosting) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def upload_note_to_huggingface(request, note_id, version="v1"): | |
| """Upload a note to HuggingFace as a Dataset (publicly viewable).""" | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if note.author != request.user and not request.user.is_staff: | |
| return Response({"error": "Not allowed"}, status=403) | |
| try: | |
| make_public = request.data.get("make_public", True) | |
| repo_type = request.data.get("repo_type", "dataset") | |
| result = upload_note_to_hub(note, repo_type=repo_type, make_public=make_public) | |
| # Store URL in metadata | |
| note.metadata = note.metadata or {} | |
| note.metadata["hf_url"] = result["url"] | |
| note.metadata["hf_repo_id"] = result["repo_id"] | |
| note.save(update_fields=["metadata"]) | |
| return Response(result) | |
| except Exception as e: | |
| return Response({"error": str(e)}, status=500) | |
| def upload_note_as_hf_space(request, note_id, version="v1"): | |
| """Upload a note to HuggingFace as a static Space (free web hosting).""" | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if note.author != request.user and not request.user.is_staff: | |
| return Response({"error": "Not allowed"}, status=403) | |
| try: | |
| result = upload_note_as_space(note) | |
| note.metadata = note.metadata or {} | |
| note.metadata["hf_space_url"] = result["url"] | |
| note.save(update_fields=["metadata"]) | |
| return Response(result) | |
| except Exception as e: | |
| return Response({"error": str(e)}, status=500) | |
| def sync_note_to_hf_view(request, note_id, version="v1"): | |
| """Sync updated note content to HuggingFace.""" | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| result = sync_note_to_hf(note) | |
| return Response(result) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 27. Shareable Links — generate, view, revoke | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def create_share_link(request, note_id, version="v1"): | |
| """Create a public share link for a note.""" | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| expires_in_days = request.data.get("expires_in_days") | |
| expires_at = ( | |
| timezone.now() + timedelta(days=expires_in_days) if expires_in_days else None | |
| ) | |
| link = ShareableLink.objects.create( | |
| note=note, | |
| created_by=request.user, | |
| password=request.data.get("password", ""), | |
| expires_at=expires_at, | |
| max_views=request.data.get("max_views"), | |
| ) | |
| return Response( | |
| { | |
| "id": str(link.id), | |
| "token": link.token, | |
| "url": link.public_url, | |
| "expires_at": link.expires_at, | |
| "is_active": link.is_active, | |
| }, | |
| status=201, | |
| ) | |
| def view_shared_note(request, token, version="v1"): | |
| """ | |
| Public endpoint — view a note via share link. | |
| No authentication required, but password may be needed. | |
| """ | |
| link = get_object_or_404(ShareableLink, token=token, is_active=True) | |
| # Check expiration | |
| if link.is_expired: | |
| return Response({"error": "This share link has expired"}, status=410) | |
| # Check password | |
| if link.password: | |
| provided_password = request.query_params.get("password", "") | |
| if provided_password != link.password: | |
| return Response( | |
| {"error": "Password required", "password_required": True}, status=401 | |
| ) | |
| note = link.note | |
| if note.deleted_at: | |
| return Response({"error": "Note has been deleted"}, status=404) | |
| # Record view | |
| link.record_view() | |
| serializer = ResearcherNoteSerializer(note) | |
| return Response(serializer.data) | |
| def list_share_links(request, note_id, version="v1"): | |
| """List all share links for a note.""" | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| links = note.share_links.all() | |
| data = [ | |
| { | |
| "id": str(l.id), | |
| "token": l.token, | |
| "url": l.public_url, | |
| "is_active": l.is_active, | |
| "view_count": l.view_count, | |
| "expires_at": l.expires_at, | |
| "created_at": l.created_at, | |
| } | |
| for l in links | |
| ] | |
| return Response(data) | |
| def revoke_share_link(request, note_id, link_id, version="v1"): | |
| """Revoke a share link.""" | |
| link = get_object_or_404(ShareableLink, id=link_id, note_id=note_id) | |
| if link.created_by != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| link.is_active = False | |
| link.save(update_fields=["is_active"]) | |
| return Response({"status": "revoked"}) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 28. Share with Users (direct) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def share_note(request, note_id, version="v1"): | |
| """Share a note with specific users by username.""" | |
| note = get_object_or_404(ResearcherNote, id=note_id) | |
| if note.author != request.user: | |
| return Response({"error": "Only the author can share"}, status=403) | |
| username = request.data.get("username") | |
| if not username: | |
| return Response({"error": "username required"}, status=400) | |
| try: | |
| user_to_share = User.objects.get(username=username) | |
| except User.DoesNotExist: | |
| return Response({"error": "User not found"}, status=404) | |
| note.shared_with.add(user_to_share) | |
| Notification.objects.create( | |
| recipient=user_to_share, | |
| sender=request.user, | |
| type="share", | |
| note=note, | |
| content=f"{request.user.username} shared a note with you: {note.title or 'Untitled'}", | |
| ) | |
| return Response( | |
| { | |
| "status": "shared", | |
| "shared_with": [u.username for u in note.shared_with.all()], | |
| } | |
| ) | |
| def unshare_note(request, note_id, version="v1"): | |
| note = get_object_or_404(ResearcherNote, id=note_id) | |
| if note.author != request.user: | |
| return Response({"error": "Only the author can unshare"}, status=403) | |
| username = request.data.get("username") | |
| if not username: | |
| return Response({"error": "username required"}, status=400) | |
| note.shared_with.remove(User.objects.get(username=username)) | |
| return Response({"status": "unshared"}) | |
| def shared_with_me(request, version="v1"): | |
| notes = ResearcherNote.objects.filter( | |
| shared_with=request.user, deleted_at__isnull=True | |
| ) | |
| serializer = ResearcherNoteSerializer(notes, many=True) | |
| return Response(serializer.data) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 29. Artifact CRUD | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def artifact_list(request, note_id, version="v1"): | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if request.method == "GET": | |
| artifacts = note.artifacts.all() | |
| data = [ | |
| { | |
| "id": str(a.id), | |
| "name": a.name, | |
| "file_type": a.file_type, | |
| "file_size_mb": a.file_size_mb, | |
| "description": a.description, | |
| "file_url": request.build_absolute_uri(a.file.url) if a.file else None, | |
| "created_at": a.created_at, | |
| } | |
| for a in artifacts | |
| ] | |
| return Response(data) | |
| elif request.method == "POST": | |
| uploaded_file = request.FILES.get("file") | |
| if not uploaded_file: | |
| return Response({"error": "file required"}, status=400) | |
| size_mb = round(uploaded_file.size / (1024 * 1024), 2) | |
| artifact = Artifact.objects.create( | |
| note=note, | |
| name=request.data.get("name", uploaded_file.name), | |
| file=uploaded_file, | |
| file_type=request.data.get("file_type", ""), | |
| file_size_mb=size_mb, | |
| description=request.data.get("description", ""), | |
| ) | |
| return Response( | |
| { | |
| "id": str(artifact.id), | |
| "file_url": request.build_absolute_uri(artifact.file.url), | |
| }, | |
| status=201, | |
| ) | |
| def artifact_delete(request, note_id, artifact_id, version="v1"): | |
| note = get_object_or_404(ResearcherNote, id=note_id) | |
| artifact = get_object_or_404(Artifact, id=artifact_id, note=note) | |
| if note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| artifact.file.delete(save=False) | |
| artifact.delete() | |
| return Response(status=204) | |
| def artifact_download(request, note_id, artifact_id, version="v1"): | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| artifact = get_object_or_404(Artifact, id=artifact_id, note=note) | |
| if not note.is_public and note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| response = FileResponse(artifact.file.open("rb"), as_attachment=True) | |
| response["Content-Disposition"] = f'attachment; filename="{artifact.name}"' | |
| return response | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 30. Protocol CRUD | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def protocol_list(request, version="v1"): | |
| if request.method == "GET": | |
| protocols = Protocol.objects.filter( | |
| models.Q(created_by=request.user) | models.Q(is_public=True) | |
| ).order_by("-created_at") | |
| data = [ | |
| { | |
| "id": str(p.id), | |
| "name": p.name, | |
| "description": p.description, | |
| "steps_count": len(p.steps), | |
| "is_public": p.is_public, | |
| "created_by": p.created_by.username, | |
| "created_at": p.created_at, | |
| } | |
| for p in protocols | |
| ] | |
| return Response(data) | |
| elif request.method == "POST": | |
| protocol = Protocol.objects.create( | |
| name=request.data.get("name", "Untitled Protocol"), | |
| description=request.data.get("description", ""), | |
| steps=request.data.get("steps", []), | |
| created_by=request.user, | |
| is_public=request.data.get("is_public", False), | |
| ) | |
| return Response({"id": str(protocol.id), "status": "created"}, status=201) | |
| def protocol_detail(request, protocol_id, version="v1"): | |
| protocol = get_object_or_404(Protocol, id=protocol_id) | |
| if request.method == "GET": | |
| return Response( | |
| { | |
| "id": str(protocol.id), | |
| "name": protocol.name, | |
| "description": protocol.description, | |
| "steps": protocol.steps, | |
| "is_public": protocol.is_public, | |
| "created_by": protocol.created_by.username, | |
| "created_at": protocol.created_at, | |
| "updated_at": protocol.updated_at, | |
| } | |
| ) | |
| if protocol.created_by != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| if request.method in ("PUT", "PATCH"): | |
| protocol.name = request.data.get("name", protocol.name) | |
| protocol.description = request.data.get("description", protocol.description) | |
| protocol.steps = request.data.get("steps", protocol.steps) | |
| protocol.is_public = request.data.get("is_public", protocol.is_public) | |
| protocol.save() | |
| return Response({"status": "updated"}) | |
| elif request.method == "DELETE": | |
| protocol.delete() | |
| return Response(status=204) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 31. Note Links (Knowledge Graph) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def create_note_link(request, version="v1"): | |
| source_id = request.data.get("source_id") | |
| target_id = request.data.get("target_id") | |
| link_type = request.data.get("link_type", "related") | |
| source = get_object_or_404( | |
| ResearcherNote, id=source_id, author=request.user, deleted_at__isnull=True | |
| ) | |
| target = get_object_or_404(ResearcherNote, id=target_id, deleted_at__isnull=True) | |
| link, created = NoteLink.objects.get_or_create( | |
| source=source, target=target, defaults={"link_type": link_type} | |
| ) | |
| return Response( | |
| {"status": "created" if created else "already_exists", "id": str(link.id)} | |
| ) | |
| def note_links_graph(request, note_id, version="v1"): | |
| """Return the knowledge graph centered on a note.""" | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| outgoing = note.outgoing_links.select_related("target") | |
| incoming = note.incoming_links.select_related("source") | |
| nodes = { | |
| str(note.id): { | |
| "id": str(note.id), | |
| "title": note.title or "Untitled", | |
| "type": "current", | |
| } | |
| } | |
| edges = [] | |
| for link in outgoing: | |
| target = link.target | |
| if target.deleted_at is None: | |
| nodes[str(target.id)] = { | |
| "id": str(target.id), | |
| "title": target.title or "Untitled", | |
| "type": "outgoing", | |
| } | |
| edges.append( | |
| {"from": str(note.id), "to": str(target.id), "type": link.link_type} | |
| ) | |
| for link in incoming: | |
| source = link.source | |
| if source.deleted_at is None: | |
| nodes[str(source.id)] = { | |
| "id": str(source.id), | |
| "title": source.title or "Untitled", | |
| "type": "incoming", | |
| } | |
| edges.append( | |
| {"from": str(source.id), "to": str(note.id), "type": link.link_type} | |
| ) | |
| return Response({"nodes": list(nodes.values()), "edges": edges}) | |
| def delete_note_link(request, link_id, version="v1"): | |
| link = get_object_or_404(NoteLink, id=link_id) | |
| if link.source.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| link.delete() | |
| return Response(status=204) | |
| # ═══════════════════════════════════════════════════════════════════ | |
| # 32. Toggle Pin | |
| # ═══════════════════════════════════════════════════════════════════ | |
| def toggle_pin(request, note_id, version="v1"): | |
| note = get_object_or_404(ResearcherNote, id=note_id) | |
| if note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| note.pinned = not note.pinned | |
| note.save(update_fields=["pinned"]) | |
| return Response({"pinned": note.pinned}) | |
| def create_note_from_paper(request, version="v1"): | |
| """ | |
| Save a paper and create a note with its details. | |
| Body: { paper_id, source, note_title (optional) } | |
| """ | |
| paper_id = request.data.get("paper_id") | |
| source = request.data.get("source", "semantic_scholar") | |
| if not paper_id: | |
| return Response({"error": "paper_id required"}, status=400) | |
| # Fetch paper details | |
| from agents.tools import get_paper_details, save_paper | |
| result = get_paper_details(paper_id=paper_id, source=source) | |
| if result.get("status") != "success": | |
| return Response(result, status=404) | |
| paper = result["paper"] | |
| # Save to user's library | |
| save_result = save_paper( | |
| title=paper.get("title", ""), | |
| source=source, | |
| source_id=paper_id, | |
| authors=paper.get("authors", []), | |
| abstract=paper.get("abstract", ""), | |
| url=paper.get("url", ""), | |
| year=paper.get("year"), | |
| citation_count=paper.get("citation_count", 0), | |
| pdf_url=paper.get("pdf_url", ""), | |
| user_id=request.user.id, | |
| ) | |
| # Create a rich note | |
| note = ResearcherNote.objects.create( | |
| author=request.user, | |
| note_type="literature_review", | |
| title=request.data.get("note_title") or paper.get("title", "Untitled"), | |
| content=f"""## 📄 {paper.get('title','Untitled')} | |
| **Authors:** {', '.join(paper.get('authors',[]))} | |
| **Year:** {paper.get('year','N/A')} | |
| **Citations:** {paper.get('citation_count',0)} | |
| **Source:** [{paper.get('source','')}]({paper.get('url','')}) | |
| ### Abstract | |
| {paper.get('abstract','')} | |
| ### Notes | |
| *(Add your notes here)* | |
| """, | |
| tags=["paper-review", source], | |
| metadata={"paper": paper}, | |
| ) | |
| serializer = ResearcherNoteSerializer(note) | |
| return Response(serializer.data, status=201) | |
| def note_bibliography(request, note_id, version="v1"): | |
| """ | |
| Return a formatted bibliography (BibTeX or APA) for all papers linked to a note. | |
| """ | |
| note = get_object_or_404(ResearcherNote, id=note_id, deleted_at__isnull=True) | |
| if not note.is_public and note.author != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| papers = note.metadata.get("papers", []) | |
| if not papers: | |
| return Response({"references": [], "bibtex": ""}) | |
| format_type = request.query_params.get("format", "apa") | |
| references = [] | |
| bibtex_entries = [] | |
| for paper in papers: | |
| title = paper.get("title", "") | |
| authors = paper.get("authors", []) | |
| year = paper.get("year", "") | |
| source = paper.get("source", "") | |
| source_id = paper.get("source_id", "") | |
| first_author = authors[0].split()[-1] if authors else "Unknown" | |
| ref_key = f"{first_author.lower()}{year}" | |
| # APA style | |
| if len(authors) == 1: | |
| author_str = authors[0] | |
| elif len(authors) == 2: | |
| author_str = f"{authors[0]} & {authors[1]}" | |
| elif len(authors) > 2: | |
| author_str = f"{authors[0]} et al." | |
| else: | |
| author_str = "Unknown" | |
| apa_ref = f"{author_str} ({year}). {title}." | |
| references.append(apa_ref) | |
| # BibTeX | |
| bibtex = f"""@article{{{ref_key}, | |
| title = {{{title}}}, | |
| author = {{{' and '.join(authors)}}}, | |
| year = {{{year}}}, | |
| url = {{https://api.semanticscholar.org/CorpusID:{source_id}}} | |
| }}""" | |
| bibtex_entries.append(bibtex) | |
| bibtex_all = "\n\n".join(bibtex_entries) | |
| return Response( | |
| { | |
| "references": references, | |
| "bibtex": bibtex_all, | |
| "count": len(references), | |
| } | |
| ) | |
| def search_and_format_paper(request, version="v1"): | |
| """Search for a paper and return a formatted Markdown snippet for insertion into a note.""" | |
| query = request.query_params.get("q", "") | |
| source = request.query_params.get("source", "semantic_scholar") | |
| if not query: | |
| return Response({"results": []}) | |
| from agents.tools import search_papers | |
| result = search_papers(query=query, limit=5, source=source) | |
| papers = result.get("results", []) | |
| formatted = [] | |
| for paper in papers: | |
| md = f"""### [{paper.get('title','Untitled')}]({paper.get('url','')}) | |
| - **Authors:** {', '.join(paper.get('authors',[]))} | |
| - **Year:** {paper.get('year','N/A')} | **Citations:** {paper.get('citation_count',0)} | |
| - **Abstract:** {paper.get('abstract','')[:300]}... | |
| ```bibtex | |
| @article{{{paper.get('authors',[['Unknown']])[0].split()[-1].lower()}{paper.get('year','')}, | |
| title = {{{paper.get('title','')}}}, | |
| author = {{{' and '.join(paper.get('authors',[]))}}}, | |
| year = {{{paper.get('year','')}}} | |
| }} | |
| ```""" | |
| formatted.append( | |
| { | |
| "source_id": paper.get("source_id"), | |
| "source": paper.get("source"), | |
| "markdown": md, | |
| } | |
| ) | |
| return Response({"results": formatted}) | |
| # notes/views.py — add this | |
| def create_data_app(request): | |
| """Convert a notebook/script into an interactive web app.""" | |
| import nbformat | |
| from nbconvert import HTMLExporter | |
| uploaded = request.FILES.get("notebook") | |
| if not uploaded: | |
| return Response({"error": "Notebook file required"}, status=400) | |
| # Convert notebook to HTML | |
| notebook = nbformat.read(uploaded, as_version=4) | |
| html_exporter = HTMLExporter() | |
| html_exporter.theme = "dark" | |
| body, _ = html_exporter.from_notebook_node(notebook) | |
| app = DataApp.objects.create( | |
| user=request.user, | |
| title=request.data.get("title", uploaded.name), | |
| source_notebook=uploaded, | |
| generated_html=body, | |
| is_public=request.data.get("is_public", False), | |
| ) | |
| # Optional: deploy to HuggingFace Space | |
| if request.data.get("deploy_to_hf"): | |
| from notes.hf_upload import upload_data_app_to_hf | |
| space_url = upload_data_app_to_hf(app) | |
| app.hugginface_space_url = space_url | |
| app.save(update_fields=["hugginface_space_url"]) | |
| return Response( | |
| { | |
| "app_id": str(app.id), | |
| "html_url": f"/api/notes/data-apps/{app.id}/render/", | |
| "hugginface_space_url": app.hugginface_space_url, | |
| }, | |
| status=201, | |
| ) | |
| def render_data_app(request, app_id): | |
| """Render a data app as a standalone HTML page.""" | |
| app = get_object_or_404(DataApp, id=app_id) | |
| if not app.is_public and app.user != request.user: | |
| return Response({"error": "Not allowed"}, status=403) | |
| return HttpResponse(app.generated_html) | |
Xet Storage Details
- Size:
- 67.7 kB
- Xet hash:
- bc8b9f2dc94caaca48404785b4781c00f44f517407f4d316f33448a64954e6d8
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.