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,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,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 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': [ 'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.BasicAuthentication', 'rest_framework.authentication.BasicAuthentication',
'core.authentication.APITokenAuthentication',
], ],
'DEFAULT_FILTER_BACKENDS': ( 'DEFAULT_FILTER_BACKENDS': (
'django_filters.rest_framework.DjangoFilterBackend', 'django_filters.rest_framework.DjangoFilterBackend',
@ -212,4 +213,4 @@ CELERY_TASK_EAGER_PROPAGATES = True
# Login/Logout Redirects # Login/Logout Redirects
LOGIN_REDIRECT_URL = 'home' LOGIN_REDIRECT_URL = 'home'
LOGOUT_REDIRECT_URL = 'home' LOGOUT_REDIRECT_URL = 'home'

Binary file not shown.

View File

@ -1,5 +1,5 @@
from django.contrib import admin 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): class TeamMembershipInline(admin.TabularInline):
model = TeamMembership model = TeamMembership
@ -27,4 +27,11 @@ class ExtractionAdmin(admin.ModelAdmin):
@admin.register(Summary) @admin.register(Summary)
class SummaryAdmin(admin.ModelAdmin): class SummaryAdmin(admin.ModelAdmin):
list_display = ('bookmark', 'generated_at') 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.db import models
from django.contrib.auth.models import User from django.contrib.auth.models import User
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
import secrets
class Team(models.Model): class Team(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
@ -66,3 +67,19 @@ class Summary(models.Model):
def __str__(self): def __str__(self):
return f"Summary for {self.bookmark}" 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 rest_framework import serializers
from django.db import transaction
from django.contrib.auth.models import User from django.contrib.auth.models import User
from core.models import Bookmark, Team, TeamMembership, BookmarkShare, Extraction, Summary from core.models import Bookmark, Team, TeamMembership, BookmarkShare, Extraction, Summary
from taggit.serializers import TagListSerializerField, TaggitSerializer from taggit.serializers import TagListSerializerField, TaggitSerializer
@ -35,10 +36,19 @@ class BookmarkSerializer(TaggitSerializer, serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
user = self.context['request'].user user = self.context['request'].user
tags = validated_data.pop('tags', []) tags = validated_data.pop('tags', [])
bookmark = Bookmark.objects.create(user=user, **validated_data) with transaction.atomic():
bookmark.tags.set(tags) bookmark = Bookmark.objects.create(user=user, **validated_data)
bookmark.tags.set(tags)
return bookmark 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): class BookmarkDetailSerializer(BookmarkSerializer):
extraction = ExtractionSerializer(read_only=True) extraction = ExtractionSerializer(read_only=True)

View File

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

View File

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