Files
links/links/views.py
T
2026-01-18 10:28:00 +11:00

753 lines
29 KiB
Python

from django.shortcuts import render, redirect, get_object_or_404
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView, TemplateView
from django.views import View # Add this import
from django.urls import reverse_lazy, reverse # Add 'reverse' here
from django.db.models import F, Count, Q, Case, When, Value, IntegerField
from django.db.models.functions import TruncDate
from .models import Link, ClickLog, LinkChangeLog, Page, Post
from .forms import LinkForm, PageForm
import json
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib import messages
from django.db import IntegrityError
from django.utils.translation import gettext as _
from django.core.exceptions import ValidationError
from django.http import JsonResponse, HttpResponse
from django.core.serializers import serialize
from django.utils.dateparse import parse_datetime
import random
from django.utils.text import slugify
import markdown
from .templatetags import think_markdown, tasklist_markdown
import logging
import re
import os
import tarfile
import tempfile
from datetime import datetime
from django.conf import settings
from django.http import FileResponse
from wsgiref.util import FileWrapper
from django.contrib.auth.decorators import user_passes_test
from django.contrib.auth.mixins import LoginRequiredMixin
import requests
from django.views.decorators.http import require_http_methods
from django.views.decorators.csrf import csrf_exempt
from bs4 import BeautifulSoup
from .page_views import (
PageListView, PageDetailView, PageCreateView,
PageUpdateView, PageDeleteView, fetch_page_info,
PageViewSet
)
from .search_views import SearchView, search_aliases, search
logger = logging.getLogger(__name__)
class LinkListView(ListView):
model = Link
template_name = 'links/link_list.html'
context_object_name = 'links'
def get_queryset(self):
queryset = super().get_queryset()
sort_by = self.request.GET.get('sort', 'click_count')
order = self.request.GET.get('order', 'asc')
if sort_by == 'alias':
ordering = F('alias').asc(nulls_last=True) if order == 'asc' else F('alias').desc(nulls_last=True)
elif sort_by == 'link_type':
ordering = F('link_type').asc(nulls_last=True) if order == 'asc' else F('link_type').desc(nulls_last=True)
elif sort_by == 'original_url':
ordering = F('original_url').asc(nulls_last=True) if order == 'asc' else F('original_url').desc(nulls_last=True)
elif sort_by == 'clicks':
ordering = F('click_count').asc(nulls_last=True) if order == 'asc' else F('click_count').desc(nulls_last=True)
elif sort_by == 'created_at':
ordering = F('created_at').asc(nulls_last=True) if order == 'asc' else F('created_at').desc(nulls_last=True)
elif sort_by == 'updated_at':
ordering = F('updated_at').asc(nulls_last=True) if order == 'asc' else F('updated_at').desc(nulls_last=True)
else:
ordering = F('click_count').desc(nulls_last=True)
return queryset.order_by(ordering)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['current_sort'] = self.request.GET.get('sort', 'click_count')
context['current_order'] = self.request.GET.get('order', 'asc')
# Modern color palette
colors = [
'#3498db', '#2ecc71', '#e74c3c', '#f39c12', '#9b59b6',
'#1abc9c', '#d35400', '#34495e', '#16a085', '#27ae60'
]
# Add top 3 popular links to the context with random colors
popular_links = Link.objects.order_by('-click_count')[:3]
context['popular_links'] = [
{'link': link, 'color': random.choice(colors)}
for link in popular_links
]
# Add latest 3 posts to the context (most recent first)
context['latest_posts'] = Post.objects.all().order_by('-created_at')[:3]
# Add pinned mini apps with random colors
mini_apps = [
{'name': 'Image Gallery', 'description': 'Browse Images', 'url': 'mini-apps-image-gallery'},
{'name': 'TTS', 'description': 'Convert text to speech', 'url': 'mini-apps-tts'},
{'name': 'Coming Soon', 'description': 'New App', 'url': '#'}
]
context['mini_apps'] = [
{**app, 'color': random.choice(colors)}
for app in mini_apps
]
# Add timestamp for random image cache busting
import time
context['timestamp'] = int(time.time())
return context
class LinkCreateView(CreateView):
model = Link
form_class = LinkForm
template_name = 'links/link_form.html' # 确保这里的路径是正确的
success_url = reverse_lazy('link_list')
def get_initial(self):
initial = super().get_initial()
alias = self.request.GET.get('alias')
if alias:
initial['alias'] = alias
return initial
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['is_new'] = True
return context
def form_valid(self, form):
form.instance.alias = form.instance.alias.lower()
alias = form.cleaned_data.get('alias').lower()
if not self.is_valid_alias(alias):
form.add_error('alias', _("Alias cannot contain special characters."))
return self.form_invalid(form)
if Link.objects.filter(alias=alias).exists():
form.add_error('alias', _("This alias is already in use. Please choose another one."))
return self.form_invalid(form)
# Set original_url for custom links
if form.cleaned_data.get('link_type') == Link.LinkType.CUSTOM:
form.instance.original_url = f'/custom/{alias}'
return super().form_valid(form)
def is_valid_alias(self, alias):
return alias.isalnum()
class LinkUpdateView(UpdateView):
model = Link
form_class = LinkForm
template_name = 'links/link_form.html'
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
# Prevent caching to ensure fresh markdown content after task toggles
response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response['Pragma'] = 'no-cache'
response['Expires'] = '0'
return response
def get_object(self, queryset=None):
# Handle both pk (for regular links) and alias (for custom links)
pk = self.kwargs.get('pk')
alias = self.kwargs.get('alias')
if pk:
# Regular link lookup by pk
return get_object_or_404(Link, pk=pk)
elif alias:
# Custom link lookup by alias
alias = alias.lower()
return get_object_or_404(Link, alias=alias, link_type=Link.LinkType.CUSTOM)
else:
# Fallback to default behavior
return super().get_object(queryset)
def get_initial(self):
initial = super().get_initial()
self.original_url = self.object.original_url
return initial
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
link = self.get_object()
context['is_custom'] = link.link_type == Link.LinkType.CUSTOM
return context
def form_valid(self, form):
form.instance.alias = form.instance.alias.lower()
alias = form.cleaned_data.get('alias').lower()
if not self.is_valid_alias(alias):
form.add_error('alias', _("Alias cannot contain special characters."))
return self.form_invalid(form)
if Link.objects.filter(alias=alias).exclude(pk=self.object.pk).exists():
form.add_error('alias', _("This alias is already in use. Please choose another one."))
return self.form_invalid(form)
# 更新 custome link type, original_url 字段
if form.cleaned_data.get('link_type') == Link.LinkType.CUSTOM:
form.instance.original_url = f'/custom/{alias}'
response = super().form_valid(form)
new_url = form.cleaned_data['original_url']
if self.original_url != new_url:
logger.info(f"URL changed from {self.original_url} to {new_url}")
try:
self.object.log_url_change(self.original_url)
logger.info("LinkChangeLog created successfully")
except Exception as e:
logger.error(f"Error creating LinkChangeLog: {str(e)}")
else:
logger.info("URL did not change")
return response
def form_invalid(self, form):
for field, errors in form.errors.items():
for error in errors:
messages.error(self.request, f"{error}")
return super().form_invalid(form)
def get_success_url(self):
return reverse('link_detail', kwargs={'pk': self.object.pk})
def is_valid_alias(self, alias):
return alias.isalnum()
class LinkDeleteView(DeleteView):
model = Link
template_name = 'links/link_confirm_delete.html'
success_url = reverse_lazy('link_list')
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['object'] = self.get_object()
return context
def delete_selected(request):
if request.method == 'POST':
selected_ids = request.POST.getlist('selected_links')
Link.objects.filter(id__in=selected_ids).delete()
return redirect('link_list')
def redirect_to_original(request, alias, param=None):
processed_alias = ''.join(e for e in alias.lower() if e.isalnum())
try:
link = Link.objects.get(alias=processed_alias)
link.click_count = F('click_count') + 1
link.save()
ClickLog.objects.create(link=link)
try:
# 如果是模板 URL 并且提供了参数
if param:
# 从 URL 中提取参数名
pattern = r'\{([^{}]*)\}'
matches = re.finditer(pattern, link.original_url)
for match in matches:
param_str = match.group(1)
if ',' in param_str:
param_name = param_str.split(',')[0].strip()
else:
param_name = param_str.strip()
# 使用提供的参数值
url = link.get_processed_url(**{param_name: param})
return redirect(url)
# 如果没有提供参数,使用默认值
url = link.get_processed_url()
if url is None:
url = link.original_url
return redirect(url)
except ValueError as e:
messages.error(request, str(e))
return redirect('link_list')
except Link.DoesNotExist:
messages.warning(request, _("The alias '{}' doesn't exist. Do you want to create a new one?").format(processed_alias))
return redirect(reverse('link_create') + f'?alias={processed_alias}')
class LinkDetailView(DetailView):
model = Link
template_name = 'links/link_detail.html'
context_object_name = 'link'
def get_object(self, queryset=None):
if 'pk' in self.kwargs:
return super().get_object(queryset)
elif 'alias' in self.kwargs:
return get_object_or_404(Link, alias=self.kwargs['alias'])
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
from datetime import datetime, timedelta
import calendar
# Get the time period from request (default to 3 months)
period = self.request.GET.get('period', '3m')
# Calculate date range and interval based on period
end_date = datetime.now().date()
if period == '3m':
start_date = end_date - timedelta(days=89) # 90 days total
period_name = "3 Months"
interval_days = 1 # Daily
elif period == '6m':
start_date = end_date - timedelta(days=179) # 180 days total
period_name = "6 Months"
interval_days = 7 # Weekly
elif period == '1y':
start_date = end_date - timedelta(days=364) # 365 days total
period_name = "1 Year"
interval_days = 7 # Weekly
elif period == 'all':
# Get the earliest click date, or default to 1 year ago if no clicks
earliest_click = ClickLog.objects.filter(link=self.object).order_by('clicked_at').first()
if earliest_click:
start_date = earliest_click.clicked_at.date()
# Determine interval based on data range
days_range = (end_date - start_date).days
if days_range <= 90:
interval_days = 7 # Weekly
elif days_range <= 365:
interval_days = 7 # Weekly
else:
interval_days = 30 # Monthly
else:
start_date = end_date - timedelta(days=364)
interval_days = 7 # Weekly
period_name = "All Time"
else:
start_date = end_date - timedelta(days=89) # Default to 3 months
period_name = "3 Months"
interval_days = 1 # Daily
# Get click stats from database
click_stats = ClickLog.objects.filter(
link=self.object,
clicked_at__date__gte=start_date,
clicked_at__date__lte=end_date
).annotate(
date=TruncDate('clicked_at')
).values('date').annotate(count=Count('id')).order_by('date')
# Convert to dictionary for easy lookup
click_dict = {item['date']: item['count'] for item in click_stats}
# Create complete dataset with appropriate intervals
click_stats_list = []
current_date = start_date
if interval_days == 1:
# Daily intervals
while current_date <= end_date:
click_stats_list.append({
'date': current_date.strftime('%Y-%m-%d'),
'count': click_dict.get(current_date, 0)
})
current_date += timedelta(days=1)
else:
# Weekly, bi-weekly, or monthly intervals
while current_date <= end_date:
interval_end = min(current_date + timedelta(days=interval_days - 1), end_date)
# Sum clicks for this interval
interval_count = 0
temp_date = current_date
while temp_date <= interval_end:
interval_count += click_dict.get(temp_date, 0)
temp_date += timedelta(days=1)
# Format label based on interval
if interval_days == 7: # Weekly
label = f"{current_date.strftime('%m/%d')}"
elif interval_days == 14: # Bi-weekly
label = f"{current_date.strftime('%m/%d')}"
else: # Monthly
label = f"{calendar.month_abbr[current_date.month]} {current_date.year}"
click_stats_list.append({
'date': current_date.strftime('%Y-%m-%d'),
'count': interval_count,
'label': label
})
current_date += timedelta(days=interval_days)
context['click_stats'] = json.dumps(click_stats_list, cls=DjangoJSONEncoder)
context['current_period'] = period
context['period_name'] = period_name
context['interval_days'] = interval_days
# Convert markdown to HTML if the link is a custom type
if self.object.link_type == Link.LinkType.CUSTOM:
task_states = self.object.task_states if self.object.task_states else {}
context['rendered_text'] = markdown.markdown(
self.object.text,
extensions=[
'markdown.extensions.fenced_code',
'markdown.extensions.tables',
'markdown.extensions.nl2br',
think_markdown.ThinkExtension(),
tasklist_markdown.TaskListExtension(task_states=task_states)
]
)
# 获取所有与此链接相关的变更日志
context['change_logs'] = LinkChangeLog.objects.filter(link=self.object).order_by('-changed_at')
return context
class ToolsView(View):
template_name = 'links/tools.html'
def get(self, request):
return render(request, self.template_name)
def post(self, request):
if 'export' in request.POST:
links = Link.objects.prefetch_related('tags').all()
formatted_data = []
for link in links:
# Get all tag names for this link
tag_names = [tag.name for tag in link.tags.all()]
# Build the basic link data
link_data = {
'alias': link.alias,
'original_url': link.original_url,
'link_type': link.link_type,
'created_at': link.created_at.isoformat() if link.created_at else None,
'updated_at': link.updated_at.isoformat() if link.updated_at else None,
'tags': tag_names
}
# For custom types, include the text field (markdown content)
if link.link_type == Link.LinkType.CUSTOM and link.text:
link_data['text'] = link.text
formatted_data.append(link_data)
response = HttpResponse(json.dumps(formatted_data, indent=2), content_type='application/json')
response['Content-Disposition'] = 'attachment; filename="links_export.json"'
return response
elif 'import' in request.POST:
try:
json_file = request.FILES['json_file']
data = json.load(json_file)
if not isinstance(data, list):
raise ValueError(_("Invalid JSON format. Expected a list of objects."))
imported_count = 0
skipped_count = 0
for item in data:
if not isinstance(item, dict) or 'alias' not in item:
raise ValueError(_("Invalid item in JSON. Each item must be an object with an 'alias' field."))
if not Link.objects.filter(alias=item['alias']).exists():
Link.objects.create(
alias=item['alias'],
original_url=item.get('original_url', ''),
created_at=parse_datetime(item.get('created_at')) if item.get('created_at') else None,
updated_at=parse_datetime(item.get('updated_at')) if item.get('updated_at') else None
)
imported_count += 1
else:
skipped_count += 1
messages.success(request, _(f"{imported_count} aliases have been imported successfully. {skipped_count} were skipped."))
except json.JSONDecodeError:
messages.error(request, _("Invalid JSON file. Please check the file format."))
except ValueError as e:
messages.error(request, str(e))
except Exception as e:
messages.error(request, _(f"An error occurred during import: {str(e)}"))
return render(request, self.template_name)
class CustomLinkView(DetailView):
model = Link
template_name = 'links/custom_link.html'
context_object_name = 'link'
def get_object(self, queryset=None):
# Convert all uppercases to lowercases
alias = self.kwargs.get('alias').lower()
return get_object_or_404(Link, alias=alias, link_type=Link.LinkType.CUSTOM)
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
# Render markdown text as HTML with task list support
task_states = self.object.task_states if self.object.task_states else {}
context['rendered_text'] = markdown.markdown(
self.object.text,
extensions=[
'markdown.extensions.fenced_code',
'markdown.extensions.tables',
'markdown.extensions.nl2br',
think_markdown.ThinkExtension(),
tasklist_markdown.TaskListExtension(task_states=task_states)
]
)
return context
def export_database(request):
"""Export the entire SQLite database as a tarball"""
try:
# 获取数据库文件路径
db_path = settings.DATABASES['default']['NAME']
# 创建临时目录
with tempfile.TemporaryDirectory() as temp_dir:
# 创建带时间戳的文件名
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
tar_filename = f'database_backup_{timestamp}.tar.gz'
tar_filepath = os.path.join(temp_dir, tar_filename)
# 创建tar文件
with tarfile.open(tar_filepath, 'w:gz') as tar:
# 添加数据库文件到tar
tar.add(db_path, arcname=os.path.basename(db_path))
# 打开文件准备下载
wrapper = FileWrapper(open(tar_filepath, 'rb'))
response = FileResponse(wrapper, content_type='application/x-gzip')
response['Content-Disposition'] = f'attachment; filename="{tar_filename}"'
response['Content-Length'] = os.path.getsize(tar_filepath)
return response
except Exception as e:
logger.error(f"Database export failed: {str(e)}")
messages.error(request, _("Failed to export database: {}").format(str(e)))
return redirect('tools')
class HelpView(TemplateView):
template_name = 'links/help.html'
@require_http_methods(["POST"])
def generate_tts(request, post_id):
"""Generate TTS audio for a post"""
try:
post = get_object_or_404(Post, pk=post_id)
# Get voice parameter from request body
voice = 'zh-CN-XiaomengNeural' # Default voice
if request.content_type == 'application/json' and request.body:
import json
data = json.loads(request.body)
voice = data.get('voice', 'zh-CN-XiaomengNeural')
# Clean the post content - remove markdown and HTML
clean_text = clean_text_for_tts(post.content)
if not clean_text.strip():
return JsonResponse({'error': 'No text content to convert'}, status=400)
# Call TTS API with voice parameter
import urllib.parse
encoded_text = urllib.parse.quote(clean_text)
encoded_voice = urllib.parse.quote(voice)
tts_url = f"https://home-tts.junv.workers.dev/tts?text={encoded_text}&voice={encoded_voice}&haha=hahaJunv"
try:
response = requests.get(tts_url, timeout=30)
response.raise_for_status()
# Return the audio file
audio_response = HttpResponse(response.content, content_type='audio/mpeg')
audio_response['Content-Disposition'] = f'inline; filename="post_{post_id}_tts.mp3"'
return audio_response
except requests.RequestException as e:
return JsonResponse({'error': f'TTS service error: {str(e)}'}, status=500)
except Post.DoesNotExist:
return JsonResponse({'error': 'Post not found'}, status=404)
except Exception as e:
return JsonResponse({'error': f'Server error: {str(e)}'}, status=500)
@require_http_methods(["POST"])
def generate_tts_api(request):
"""General TTS API that accepts text content and voice parameter"""
try:
import json
# Get text and voice from request body
if request.content_type == 'application/json':
data = json.loads(request.body)
text_content = data.get('text', '')
voice = data.get('voice', 'zh-CN-XiaomengNeural') # Default to first Chinese voice
else:
text_content = request.POST.get('text', '')
voice = request.POST.get('voice', 'zh-CN-XiaomengNeural')
if not text_content.strip():
return JsonResponse({'error': 'No text content provided'}, status=400)
# Clean the text content - remove markdown and HTML
clean_text = clean_text_for_tts(text_content)
if not clean_text.strip():
return JsonResponse({'error': 'No valid text content to convert'}, status=400)
# Call TTS API with voice parameter
import urllib.parse
encoded_text = urllib.parse.quote(clean_text)
encoded_voice = urllib.parse.quote(voice)
tts_url = f"https://home-tts.junv.workers.dev/tts?text={encoded_text}&voice={encoded_voice}&haha=hahaJunv"
try:
response = requests.get(tts_url, timeout=30)
response.raise_for_status()
# Return the audio file
audio_response = HttpResponse(response.content, content_type='audio/mpeg')
audio_response['Content-Disposition'] = f'inline; filename="tts_audio.mp3"'
return audio_response
except requests.RequestException as e:
return JsonResponse({'error': f'TTS service error: {str(e)}'}, status=500)
except json.JSONDecodeError:
return JsonResponse({'error': 'Invalid JSON data'}, status=400)
except Exception as e:
return JsonResponse({'error': f'Server error: {str(e)}'}, status=500)
def clean_text_for_tts(content):
"""Clean markdown and HTML from text for TTS"""
# Convert markdown to HTML first
html = markdown.markdown(content)
# Parse HTML and extract text
soup = BeautifulSoup(html, 'html.parser')
# Remove script and style elements
for script in soup(["script", "style"]):
script.decompose()
# Get text and clean it
text = soup.get_text()
# Clean up whitespace and special characters
text = re.sub(r'\s+', ' ', text) # Multiple spaces to single
text = re.sub(r'\n+', '. ', text) # Multiple newlines to period
text = text.strip()
# Limit text length (TTS services often have limits)
if len(text) > 3000:
text = text[:3000] + "..."
return text
@require_http_methods(["POST"])
@csrf_exempt
def toggle_link_task(request, pk):
"""
Toggle the checked state of a task in a link's custom content.
Only updates task_states JSON, keeps markdown unchanged.
POST /link/{id}/toggle_task/
Body: {"task_hash": "abc123"}
"""
try:
link = get_object_or_404(Link, pk=pk)
# Parse JSON body
import json
data = json.loads(request.body)
task_hash = data.get('task_hash')
if not task_hash:
return JsonResponse(
{'error': 'task_hash is required'},
status=400
)
# Get current task states or initialize empty dict
task_states = link.task_states if link.task_states else {}
# Toggle the state (default to False if not set, then toggle)
current_state = task_states.get(task_hash, False)
new_state = not current_state
task_states[task_hash] = new_state
link.task_states = task_states
# Save only task_states, leave markdown text unchanged
link.save(update_fields=['task_states', 'updated_at'])
# Extract task text from markdown for logging
task_text = _extract_task_text_from_hash(link.text, task_hash)
# Log the task toggle with metadata
change_log = LinkChangeLog.objects.create(
link=link,
change_type=LinkChangeLog.ChangeType.TASK_TOGGLE,
metadata={
'task_hash': task_hash,
'task_text': task_text,
'old_state': current_state,
'new_state': new_state
}
)
return JsonResponse({
'task_hash': task_hash,
'checked': new_state
})
except json.JSONDecodeError:
return JsonResponse({'error': 'Invalid JSON'}, status=400)
except Exception as e:
logger.error(f"Error in toggle_link_task: {e}", exc_info=True)
return JsonResponse({'error': str(e)}, status=500)
def _extract_task_text_from_hash(markdown_text, target_hash):
"""
Extract the task text that corresponds to a given hash from markdown content.
Args:
markdown_text: The markdown content containing task lists
target_hash: The hash of the task to find
Returns:
The task text if found, otherwise the hash itself
"""
if not markdown_text:
return target_hash
import hashlib
# Pattern to match task list items: - [ ] or - [x] followed by text
task_pattern = re.compile(r'^[\s]*[-*]\s+\[([ xX])\]\s+(.+)$', re.MULTILINE)
for match in task_pattern.finditer(markdown_text):
task_text = match.group(2).strip()
# Compute hash the same way as in tasklist_markdown.py
normalized = ' '.join(task_text.split())
task_hash = hashlib.md5(normalized.encode('utf-8')).hexdigest()[:12]
if task_hash == target_hash:
return task_text
# If not found, return the hash
return target_hash