New card style frontend and alt auth token for api

This commit is contained in:
Flatlogic Bot 2026-02-09 19:02:03 +00:00
parent aade25cd3a
commit 42369b6e1a
16 changed files with 204 additions and 56 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@ -152,3 +152,15 @@ you should set broker_connection_retry_on_startup to True.
[2026-02-09 04:08:51,673: INFO/ForkPoolWorker-1] Decoded JSON for 15: summary=True, tags=['marketing', 'company', 'productivity', 'sharing']
[2026-02-09 04:08:51,759: INFO/ForkPoolWorker-1] Successfully added tags ['marketing', 'company', 'productivity', 'sharing'] to bookmark 15
[2026-02-09 04:08:51,894: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[ba68ccad-d413-41cc-8b1c-65384d2792ab] succeeded in 6.7007439360022545s: 'Generated summary and tags for bookmark 15'
[2026-02-09 18:30:44,317: INFO/MainProcess] Task core.tasks.process_bookmark[db2f07fc-75d0-4a11-961e-0a34e1cde744] received
[2026-02-09 18:30:48,712: INFO/ForkPoolWorker-2] HTTP Request: GET https://github.com/pydantic/monty "HTTP/1.1 200 OK"
[2026-02-09 18:30:50,263: INFO/MainProcess] Task core.tasks.generate_summary[944ef664-f321-45b6-82f8-607291008ea6] received
[2026-02-09 18:30:50,374: INFO/ForkPoolWorker-2] Task core.tasks.process_bookmark[db2f07fc-75d0-4a11-961e-0a34e1cde744] succeeded in 5.521026417030953s: 'Processed bookmark 27'
[2026-02-09 18:30:51,857: INFO/ForkPoolWorker-1] Generating summary/tags for bookmark 27...
[2026-02-09 18:31:03,704: INFO/ForkPoolWorker-1] AI Raw Response for 27: {
"summary": "monty is a minimal, secure Python interpreter implemented in Rust, designed for safely executing Python code in AI contexts. The GitHub repository from pydantic provides the codebase and tooling to sandbox and integrate this interpreter for AI agents and applications.",
"tags": ["development", "research", "new", "company"]
}
[2026-02-09 18:31:03,713: INFO/ForkPoolWorker-1] Decoded JSON for 27: summary=True, tags=['development', 'research', 'new', 'company']
[2026-02-09 18:31:03,987: INFO/ForkPoolWorker-1] Successfully added tags ['development', 'research', 'new', 'company'] to bookmark 27
[2026-02-09 18:31:04,321: INFO/ForkPoolWorker-1] Task core.tasks.generate_summary[944ef664-f321-45b6-82f8-607291008ea6] succeeded in 13.666462198016234s: 'Generated summary and tags for bookmark 27'

View File

@ -192,6 +192,7 @@ REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.BasicAuthentication',
'core.authentication.APITokenAuthentication',
],
'DEFAULT_FILTER_BACKENDS': (
'django_filters.rest_framework.DjangoFilterBackend',

Binary file not shown.

View File

@ -1,5 +1,5 @@
from django.contrib import admin
from .models import Team, TeamMembership, Bookmark, BookmarkShare, Extraction, Summary
from .models import Team, TeamMembership, Bookmark, BookmarkShare, Extraction, Summary, APIToken
class TeamMembershipInline(admin.TabularInline):
model = TeamMembership
@ -28,3 +28,10 @@ class ExtractionAdmin(admin.ModelAdmin):
@admin.register(Summary)
class SummaryAdmin(admin.ModelAdmin):
list_display = ('bookmark', 'generated_at')
@admin.register(APIToken)
class APITokenAdmin(admin.ModelAdmin):
list_display = ('name', 'user', 'created_at', 'last_used_at', 'is_active')
list_filter = ('is_active', 'created_at', 'last_used_at')
search_fields = ('name', 'user__username', 'token')
readonly_fields = ('token', 'created_at', 'last_used_at')

35
core/authentication.py Normal file
View File

@ -0,0 +1,35 @@
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from django.utils import timezone
from .models import APIToken
class APITokenAuthentication(BaseAuthentication):
def authenticate(self, request):
auth_header = request.headers.get('Authorization')
if not auth_header:
return None
try:
# Support "Bearer <token>" and "Token <token>"
prefix, token = auth_header.split()
if prefix.lower() not in ['bearer', 'token']:
return None
except ValueError:
return None
return self._authenticate_credentials(token)
def _authenticate_credentials(self, key):
try:
token = APIToken.objects.select_related('user').get(token=key, is_active=True)
except APIToken.DoesNotExist:
raise AuthenticationFailed('Invalid or inactive API token')
if not token.user.is_active:
raise AuthenticationFailed('User is inactive or deleted')
# Update last used timestamp
token.last_used_at = timezone.now()
token.save(update_fields=['last_used_at'])
return (token.user, token)

View File

@ -0,0 +1,28 @@
# Generated by Django 5.2.7 on 2026-02-09 18:35
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='APIToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token', models.CharField(editable=False, max_length=64, unique=True)),
('name', models.CharField(default='Default Token', help_text="A recognizable name for this token (e.g. 'Mobile App')", max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('is_active', models.BooleanField(default=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='api_tokens', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@ -1,6 +1,7 @@
from django.db import models
from django.contrib.auth.models import User
from taggit.managers import TaggableManager
import secrets
class Team(models.Model):
name = models.CharField(max_length=255)
@ -66,3 +67,19 @@ class Summary(models.Model):
def __str__(self):
return f"Summary for {self.bookmark}"
class APIToken(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='api_tokens')
token = models.CharField(max_length=64, unique=True, editable=False)
name = models.CharField(max_length=255, help_text="A recognizable name for this token (e.g. 'Mobile App')", default="Default Token")
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(null=True, blank=True)
is_active = models.BooleanField(default=True)
def save(self, *args, **kwargs):
if not self.token:
self.token = secrets.token_hex(20)
super().save(*args, **kwargs)
def __str__(self):
return f"{self.name} - {self.user.username}"

View File

@ -1,4 +1,5 @@
from rest_framework import serializers
from django.db import transaction
from django.contrib.auth.models import User
from core.models import Bookmark, Team, TeamMembership, BookmarkShare, Extraction, Summary
from taggit.serializers import TagListSerializerField, TaggitSerializer
@ -35,10 +36,19 @@ class BookmarkSerializer(TaggitSerializer, serializers.ModelSerializer):
def create(self, validated_data):
user = self.context['request'].user
tags = validated_data.pop('tags', [])
bookmark = Bookmark.objects.create(user=user, **validated_data)
bookmark.tags.set(tags)
with transaction.atomic():
bookmark = Bookmark.objects.create(user=user, **validated_data)
bookmark.tags.set(tags)
return bookmark
def update(self, instance, validated_data):
tags = validated_data.pop('tags', None)
with transaction.atomic():
instance = super().update(instance, validated_data)
if tags is not None:
instance.tags.set(tags)
return instance
class BookmarkDetailSerializer(BookmarkSerializer):
extraction = ExtractionSerializer(read_only=True)

View File

@ -58,50 +58,71 @@
</div>
</form>
<div class="row" id="bookmarks-container">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4" id="bookmarks-container">
{% for bookmark in bookmarks %}
<div class="col-md-12 mb-3 bookmark-item" data-id="{{ bookmark.id }}">
<div class="card border-0 shadow-sm hover-elevate">
<div class="card-body p-4">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<div class="d-flex align-items-center mb-1">
<h5 class="card-title mb-0 me-2">
<a href="{% url 'bookmark-detail' bookmark.pk %}" class="text-decoration-none text-dark fw-bold bookmark-title-link">{{ bookmark.title|default:bookmark.url }}</a>
</h5>
{% if bookmark.is_favorite %}
<i class="bi bi-star-fill text-warning" title="Favorite"></i>
{% endif %}
</div>
<p class="text-muted small mb-3 text-break">{{ bookmark.url }}</p>
{% if bookmark.notes %}
<p class="card-text text-secondary mb-2">{{ bookmark.notes|truncatewords:40 }}</p>
{% endif %}
<div class="summary-preview mb-3">
{% if bookmark.summary %}
<p class="card-text small text-dark"><i class="bi bi-robot me-1"></i> {{ bookmark.summary.content|truncatewords:50 }}</p>
<div class="col bookmark-item" data-id="{{ bookmark.id }}">
<div class="card h-100 border-0 shadow-sm hover-elevate overflow-hidden">
<div class="card-body d-flex flex-column">
<div class="d-flex justify-content-between align-items-start mb-2">
<h5 class="card-title h6 fw-bold mb-0 text-truncate w-100" title="{{ bookmark.title|default:bookmark.url }}">
<a href="{% url 'bookmark-detail' bookmark.pk %}" class="text-decoration-none text-dark bookmark-title-link stretched-link">
{% if bookmark.title %}
{{ bookmark.title|truncatechars:50 }}
{% else %}
<div class="text-muted small loading-indicator">
<div class="spinner-border spinner-border-sm text-primary me-1" role="status"></div>
Generating AI summary...
</div>
{{ bookmark.url|truncatechars:50 }}
{% endif %}
</div>
</a>
</h5>
{% if bookmark.is_favorite %}
<i class="bi bi-star-fill text-warning ms-2 flex-shrink-0" title="Favorite"></i>
{% endif %}
</div>
<div class="d-flex flex-wrap gap-1 mt-2">
{% for tag in bookmark.tags.all %}
<span class="badge bg-light text-muted border-0 small">#{{ tag.name }}</span>
{% endfor %}
</div>
<div class="mb-3">
<small class="text-muted d-block text-truncate">
<i class="bi bi-link-45deg me-1"></i>{{ bookmark.url }}
</small>
</div>
<div class="mb-3 flex-grow-1">
{% if bookmark.notes %}
<p class="card-text small text-secondary mb-2">{{ bookmark.notes|truncatewords:20 }}</p>
{% endif %}
<div class="summary-preview">
{% if bookmark.summary %}
<p class="card-text small text-dark bg-light p-2 rounded border-start border-3 border-primary">
{{ bookmark.summary.content|truncatewords:20 }}
</p>
{% else %}
<div class="text-muted small loading-indicator">
<div class="spinner-border spinner-border-sm text-primary me-1" role="status"></div>
Generating...
</div>
{% endif %}
</div>
<div class="text-end ms-3">
<div class="dropdown">
<button class="btn btn-link text-muted p-0" type="button" data-bs-toggle="dropdown">
<i class="bi bi-three-dots-vertical"></i>
</div>
<div class="mt-auto">
<div class="d-flex flex-wrap gap-1 mb-3" style="max-height: 50px; overflow: hidden;">
{% for tag in bookmark.tags.all|slice:":3" %}
<span class="badge bg-light text-muted border small">#{{ tag.name }}</span>
{% endfor %}
{% if bookmark.tags.all.count > 3 %}
<span class="badge bg-light text-muted border small">+{{ bookmark.tags.all.count|add:"-3" }}</span>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center pt-2 border-top">
<small class="text-muted" style="font-size: 0.75rem;">
{{ bookmark.created_at|date:"M d, Y" }}
</small>
<div class="dropdown position-static">
<button class="btn btn-link text-muted p-0" type="button" data-bs-toggle="dropdown" style="z-index: 2; position: relative;">
<i class="bi bi-three-dots"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<ul class="dropdown-menu dropdown-menu-end shadow">
<li><a class="dropdown-item" href="{% url 'bookmark-detail' bookmark.pk %}">View Details</a></li>
<li><a class="dropdown-item" href="{% url 'bookmark-edit' bookmark.pk %}">Edit</a></li>
<li><hr class="dropdown-divider"></li>
@ -113,7 +134,6 @@
</li>
</ul>
</div>
<span class="text-muted small d-block mt-4">{{ bookmark.created_at|date:"M d" }}</span>
</div>
</div>
</div>
@ -134,7 +154,7 @@
</div>
{% if is_paginated %}
<nav class="mt-4">
<nav class="mt-5">
<ul class="pagination justify-content-center">
{% if page_obj.has_previous %}
<li class="page-item"><a class="page-link rounded-circle me-2 border-0 shadow-sm" href="?page={{ page_obj.previous_page_number }}{% if request.GET.q %}&q={{ request.GET.q }}{% endif %}{% if request.GET.tag %}&tag={{ request.GET.tag }}{% endif %}"><i class="bi bi-chevron-left"></i></a></li>
@ -163,6 +183,9 @@
box-shadow: 0 10px 20px rgba(0,0,0,0.08) !important;
transition: all 0.3s ease;
}
.bookmark-item .card {
transition: all 0.3s ease;
}
</style>
{% endblock %}
@ -201,14 +224,14 @@
// Update title if it was changed from URL to actual title
const titleLink = item.element.querySelector('.bookmark-title-link');
if (data.title && (titleLink.textContent.includes('://') || titleLink.textContent === '')) {
titleLink.textContent = data.title;
if (data.title && (titleLink.textContent.includes('://') || titleLink.textContent.trim().length === 0)) {
titleLink.textContent = data.title.length > 50 ? data.title.substring(0, 50) + '...' : data.title;
}
if (data.summary && data.summary.content) {
const summaryPreview = item.element.querySelector('.summary-preview');
const truncatedSummary = data.summary.content.split(' ').slice(0, 50).join(' ') + (data.summary.content.split(' ').length > 50 ? '...' : '');
summaryPreview.innerHTML = `<p class="card-text small text-dark"><i class="bi bi-robot me-1"></i> ${truncatedSummary}</p>`;
const truncatedSummary = data.summary.content.split(' ').slice(0, 20).join(' ') + (data.summary.content.split(' ').length > 20 ? '...' : '');
summaryPreview.innerHTML = `<p class="card-text small text-dark bg-light p-2 rounded border-start border-3 border-primary">${truncatedSummary}</p>`;
} else {
stillLoading = true;
}

View File

@ -8,7 +8,6 @@ django.setup()
from django.test import Client
from django.contrib.auth.models import User
from core.models import Bookmark
from django.urls import reverse
def run():
# Setup
@ -34,8 +33,10 @@ def run():
url = f'/api/bookmarks/{bookmark.id}/'
# Test 1: Update tags and title
data = {
'tags': []
'tags': ['new', 'tags'],
'title': 'Updated Title'
}
print(f"PATCHing to {url} with data: {data}")
@ -47,18 +48,32 @@ def run():
)
print(f"Response status: {response.status_code}")
print(f"Response content: {response.content}")
bookmark.refresh_from_db()
final_tags = list(bookmark.tags.names())
final_tags = sorted(list(bookmark.tags.names()))
final_title = bookmark.title
print(f"Final tags: {final_tags}")
print(f"Final title: {final_title}")
if final_tags == []:
print("SUCCESS: API Tags cleared correctly.")
success = True
if final_tags != ['new', 'tags']:
print("FAILURE: API Tags NOT updated correctly.")
success = False
else:
print("FAILURE: API Tags NOT cleared correctly.")
print("SUCCESS: API Tags updated correctly.")
if final_title != 'Updated Title':
print("FAILURE: API Title NOT updated correctly.")
success = False
else:
print("SUCCESS: API Title updated correctly.")
# Cleanup
if success:
print("All tests passed.")
else:
print("Some tests failed.")
bookmark.delete()
user.delete()