Files
links/links/page_views.py
T
2025-11-04 22:22:40 +11:00

336 lines
12 KiB
Python

from django.views.generic import ListView, DetailView, CreateView, UpdateView, DeleteView, TemplateView
from django.urls import reverse_lazy
from django.http import JsonResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404
from django.contrib import messages
from django.utils.translation import gettext_lazy as _
from django.core.files.base import ContentFile
from django.conf import settings
from rest_framework import viewsets, status
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from .models import Page, Screenshot, Tag
from .forms import PageForm
from .serializers import PageSerializer
import os
import requests
from bs4 import BeautifulSoup
from urllib.parse import urlparse, quote
import re
import base64
from .tasks import capture_screenshot, process_page, fetch_webpage_content_from_crawl4ai
from pathlib import Path
from datetime import datetime
from rest_framework.decorators import action
from threading import Thread
def trigger_page_processing(page):
"""
Trigger background tasks for a newly created page:
1. Screenshot capture
2. Crawl4AI content extraction
3. Page metadata processing
This is called whenever a page is created, regardless of the source (UI or API).
"""
from .tasks import capture_screenshot, fetch_webpage_content_from_crawl4ai, process_page
# Create screenshot record and trigger capture
screenshot = Screenshot.objects.create(
page=page,
status=Screenshot.Status.PENDING
)
# Start screenshot capture thread
thread_screenshot = Thread(target=capture_screenshot, args=(page.id, screenshot.id))
thread_screenshot.daemon = True
thread_screenshot.start()
# Start Crawl4AI content extraction thread
thread_crawl = Thread(target=fetch_webpage_content_from_crawl4ai, args=(page.id,))
thread_crawl.daemon = True
thread_crawl.start()
# Start page metadata processing thread (if needed)
if not page.title or not page.summary:
thread_process = Thread(target=process_page, args=(page.id,))
thread_process.daemon = True
thread_process.start()
class PageListView(ListView):
model = Page
template_name = 'links/page_list.html'
context_object_name = 'pages'
paginate_by = 10
class PageDetailView(DetailView):
model = Page
template_name = 'links/page_detail.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['all_tags'] = Tag.objects.all()
return context
class PageCreateView(CreateView):
model = Page
form_class = PageForm
template_name = 'links/page_form.html'
success_url = reverse_lazy('page-list')
def form_valid(self, form):
response = super().form_valid(form)
page = self.object
# Trigger all background processing tasks
try:
trigger_page_processing(page)
messages.success(self.request, _('Page created successfully and processing started.'))
except Exception as e:
messages.error(self.request, _('Page created but processing failed to start.'))
page.error_message = str(e)
page.save()
return response
class PageUpdateView(UpdateView):
model = Page
form_class = PageForm
template_name = 'links/page_form.html'
success_url = reverse_lazy('page-list')
def form_valid(self, form):
response = super().form_valid(form)
# Check if reset_status was checked
if self.request.POST.get('reset_status'):
self.object.process_status = Page.ProcessStatus.PENDING
self.object.retry_count = 0
self.object.error_message = ''
self.object.screenshot_path = ''
self.object.save()
# Trigger processing task
from .tasks import process_page
thread = Thread(target=process_page, args=(self.object.id,))
thread.daemon = True
thread.start()
return response
class PageDeleteView(DeleteView):
model = Page
template_name = 'links/page_confirm_delete.html'
success_url = reverse_lazy('page-list')
def fetch_page_info(request):
url = request.GET.get('url')
if not url:
return JsonResponse({'error': 'URL is required'}, status=400)
url = url.lstrip('@')
try:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.5',
}
response = requests.get(url, headers=headers, timeout=5, verify=False) # Reduced timeout to 5 seconds
if response.encoding == 'ISO-8859-1':
response.encoding = response.apparent_encoding or 'utf-8'
soup = BeautifulSoup(response.text, 'html.parser')
# Get title
title = None
if soup.title:
title = soup.title.string
elif soup.find('h1'):
title = soup.find('h1').get_text(strip=True)
elif soup.find('meta', property='og:title'):
title = soup.find('meta', property='og:title').get('content')
if title:
title = re.sub(r'\s+', ' ', title.strip())
title = title.replace(' | ', ' - ').replace(' :: ', ' - ')
else:
title = urlparse(url).netloc
# Get description
description = None
meta_desc = soup.find('meta', {'name': 'description'}) or soup.find('meta', {'property': 'og:description'})
if meta_desc:
description = meta_desc.get('content')
if not description:
for tag in soup(['script', 'style', 'nav', 'header', 'footer']):
tag.decompose()
paragraphs = soup.find_all(['p', 'div'])
for p in paragraphs:
text = p.get_text(strip=True)
if len(text) > 100:
description = text
break
if not description:
description = title
description = re.sub(r'\s+', ' ', description.strip())
description = description[:500] + '...' if len(description) > 500 else description
return JsonResponse({
'title': title,
'summary': description
})
except Exception as e:
return JsonResponse({
'error': f'Error processing page: {str(e)}'
}, status=400)
class StandardResultsSetPagination(PageNumberPagination):
page_size = 10
page_size_query_param = 'page_size'
max_page_size = 100
class PageViewSet(viewsets.ModelViewSet):
queryset = Page.objects.all().order_by('-created_at')
serializer_class = PageSerializer
pagination_class = StandardResultsSetPagination
def get_queryset(self):
return Page.objects.all().order_by('-created_at')
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer):
instance = serializer.save()
if instance.title and instance.summary:
instance.process_status = Page.ProcessStatus.COMPLETED
else:
instance.process_status = Page.ProcessStatus.PENDING
instance.save()
# Trigger all background processing tasks
trigger_page_processing(instance)
@action(detail=True, methods=['post'], url_path='take-screenshot')
def take_screenshot(self, request, pk=None):
"""API endpoint to take a new screenshot of a page"""
page = self.get_object()
# Create new Screenshot instance
screenshot = Screenshot.objects.create(
page=page,
status=Screenshot.Status.PENDING
)
# Trigger screenshot capture task
try:
thread = Thread(target=capture_screenshot, args=(page.id, screenshot.id))
thread.daemon = True
thread.start()
return Response({
'status': 'success',
'message': 'Screenshot capture initiated',
'screenshot_id': screenshot.id
})
except Exception as e:
screenshot.status = Screenshot.Status.FAILED
screenshot.error = str(e)
screenshot.save()
return Response({
'status': 'error',
'error': str(e)
}, status=400)
@action(detail=False, methods=['delete'], url_path='screenshots/(?P<screenshot_id>[^/.]+)')
def delete_screenshot(self, request, screenshot_id=None):
"""Delete a specific screenshot"""
try:
screenshot = Screenshot.objects.get(id=screenshot_id)
# Optional: Add permission check here
# if screenshot.page.user != request.user:
# return Response(status=status.HTTP_403_FORBIDDEN)
# Delete the actual file
if screenshot.path:
file_path = os.path.join(settings.MEDIA_ROOT, screenshot.path)
try:
os.remove(file_path)
except OSError:
pass # File doesn't exist or can't be deleted
# Delete the database record
screenshot.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
except Screenshot.DoesNotExist:
return Response(
{"error": "Screenshot not found"},
status=status.HTTP_404_NOT_FOUND
)
except Exception as e:
return Response(
{"error": str(e)},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
class ScreenshotGalleryView(TemplateView):
template_name = 'links/screenshot_gallery.html'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
screenshots_dir = os.path.join(settings.MEDIA_ROOT, 'screenshots')
screenshots = []
if os.path.exists(screenshots_dir):
for filename in os.listdir(screenshots_dir):
if filename.endswith('.png'):
file_path = os.path.join(screenshots_dir, filename)
# Extract page ID and clean up filename
name_parts = filename.split('_', 2) # Split into ['page', id, 'rest_of_name']
if len(name_parts) >= 2:
id = name_parts[1].replace('.png', '') # Remove .png extension
else:
id = '1'
if len(name_parts) >= 3:
display_name = name_parts[2].replace('.png', '')
else:
display_name = filename.replace('.png', '')
screenshots.append({
'url': f'/media/screenshots/{filename}',
'created': datetime.fromtimestamp(os.path.getctime(file_path)),
'display_name': display_name,
'id': id
})
# Sort screenshots by creation time, newest first
screenshots.sort(key=lambda x: x['created'], reverse=True)
context['screenshots'] = screenshots
return context
def update_page_tags(request, pk):
if request.method == 'POST':
page = get_object_or_404(Page, pk=pk)
selected_tags = request.POST.getlist('tags')
page.tags.clear()
if selected_tags:
page.tags.add(*selected_tags)
messages.success(request, _('Tags updated successfully'))
return HttpResponseRedirect(reverse('page-detail', args=[pk]))
return HttpResponseRedirect(reverse('page-detail', args=[pk]))