New card style frontend and alt auth token for api
This commit is contained in:
parent
aade25cd3a
commit
42369b6e1a
BIN
assets/pasted-20260209-182712-203381fe.png
Normal file
BIN
assets/pasted-20260209-182712-203381fe.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
12
celery.log
12
celery.log
@ -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'
|
||||
|
||||
Binary file not shown.
@ -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',
|
||||
@ -212,4 +213,4 @@ CELERY_TASK_EAGER_PROPAGATES = True
|
||||
|
||||
# Login/Logout Redirects
|
||||
LOGIN_REDIRECT_URL = 'home'
|
||||
LOGOUT_REDIRECT_URL = 'home'
|
||||
LOGOUT_REDIRECT_URL = 'home'
|
||||
|
||||
Binary file not shown.
BIN
core/__pycache__/authentication.cpython-311.pyc
Normal file
BIN
core/__pycache__/authentication.cpython-311.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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
|
||||
@ -27,4 +27,11 @@ class ExtractionAdmin(admin.ModelAdmin):
|
||||
|
||||
@admin.register(Summary)
|
||||
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
35
core/authentication.py
Normal 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)
|
||||
28
core/migrations/0002_apitoken.py
Normal file
28
core/migrations/0002_apitoken.py
Normal 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)),
|
||||
],
|
||||
),
|
||||
]
|
||||
BIN
core/migrations/__pycache__/0002_apitoken.cpython-311.pyc
Normal file
BIN
core/migrations/__pycache__/0002_apitoken.cpython-311.pyc
Normal file
Binary file not shown.
@ -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}"
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user