working FileIndex and user Profile pages/settings

This commit is contained in:
2026-08-03 14:13:40 -05:00
parent 85e6241ad4
commit 39e31d3980
17 changed files with 999 additions and 25 deletions
+53
View File
@@ -0,0 +1,53 @@
from django.contrib.auth import get_user_model
from django.contrib.auth.forms import PasswordChangeForm
from django import forms
from .models import BIO_MAX_LENGTH, UserProfile
class UsernameForm(forms.ModelForm):
class Meta:
model = get_user_model()
fields = ['username']
def clean_username(self):
username = self.cleaned_data['username']
User = get_user_model()
if User.objects.exclude(pk=self.instance.pk).filter(username=username).exists():
raise forms.ValidationError('That username is already taken.')
return username
class EmailForm(forms.ModelForm):
email = forms.EmailField(required=False)
class Meta:
model = get_user_model()
fields = ['email']
def clean_email(self):
return self.cleaned_data.get('email', '').strip()
class AvatarForm(forms.Form):
avatar = forms.ImageField(
label='Profile picture',
widget=forms.ClearableFileInput(attrs={'accept': 'image/*'}),
)
class BioForm(forms.ModelForm):
bio = forms.CharField(
label='Bio',
required=False,
max_length=BIO_MAX_LENGTH,
widget=forms.Textarea(attrs={
'rows': 4,
'placeholder': 'Tell people about yourself…',
'maxlength': BIO_MAX_LENGTH,
}),
)
class Meta:
model = UserProfile
fields = ['bio']
@@ -0,0 +1,20 @@
# Generated by Django 6.0.3 on 2026-08-03 18:14
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('library', '0001_initial'),
('profiles', '0002_apitoken_profiles_ap_user_id_c2920d_idx'),
]
operations = [
migrations.AlterField(
model_name='userprofile',
name='avatar',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='profile', to='library.fileindex'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 6.0.3 on 2026-08-03 18:47
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('profiles', '0003_alter_userprofile_avatar'),
]
operations = [
migrations.AlterField(
model_name='userprofile',
name='bio',
field=models.CharField(blank=True, default='', max_length=1000),
),
]
+18 -3
View File
@@ -1,5 +1,8 @@
from django.conf import settings
from django.db import models
from django.urls import reverse
BIO_MAX_LENGTH = 1000
class UserProfile(models.Model):
@@ -8,14 +11,26 @@ class UserProfile(models.Model):
on_delete=models.CASCADE,
related_name='userprofile',
)
bio = models.TextField(blank=True, default='')
avatar = models.ImageField(
upload_to='profiles/%d/',
bio = models.CharField(
max_length=BIO_MAX_LENGTH,
blank=True,
default='',
)
avatar = models.OneToOneField(
'library.FileIndex',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='profile',
)
joined = models.DateTimeField(auto_now_add=True)
@property
def avatar_url(self):
if self.avatar_id is None:
return None
return reverse('library:file_request', args=[self.avatar.uuid])
def __str__(self):
return f'{self.user.username} profile'
+301 -2
View File
@@ -1,9 +1,49 @@
import io
import secrets
import shutil
import tempfile
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase, override_settings
from django.urls import reverse
from .models import ApiToken, UserProfile
from library.models import FileIndex
from .models import BIO_MAX_LENGTH, ApiToken, UserProfile
def _read_media(stored_path):
from django.core.files.storage import default_storage
with default_storage.open(stored_path, 'rb') as f:
return f.read()
class MediaTestCase(TestCase):
"""Base class that redirects uploaded media to a temp directory."""
@classmethod
def setUpClass(cls):
cls._media_root = tempfile.mkdtemp(prefix='packs_test_media_')
cls.override = override_settings(MEDIA_ROOT=cls._media_root)
cls.override.enable()
super().setUpClass()
@classmethod
def tearDownClass(cls):
super().tearDownClass()
cls.override.disable()
shutil.rmtree(cls._media_root, ignore_errors=True)
class GatedTestCase(TestCase):
"""Base class with a helper to authorize the client past the gate."""
def gate(self):
session = self.client.session
session['authorized'] = True
session.save()
class ApiTokenAuthTests(TestCase):
@@ -74,3 +114,262 @@ class ApiTokenAuthTests(TestCase):
)
token = ApiToken.objects.get(token=self.token)
self.assertIsNotNone(token.last_used)
class AccountPageTests(MediaTestCase, GatedTestCase):
def setUp(self):
User = get_user_model()
self.user = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.user)
def test_account_page_requires_login(self):
self.gate()
resp = self.client.get(reverse('profiles:account'))
self.assertEqual(resp.status_code, 302)
def test_account_page_renders_for_owner(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.get(reverse('profiles:account'))
self.assertEqual(resp.status_code, 200)
def test_logout_preserves_gate_access(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('profiles:logout'))
self.assertRedirects(resp, '/home/')
self.assertFalse(self.client.session.get('_auth_user_id'))
# The gate flag must survive logout.
self.assertTrue(self.client.session.get('authorized'))
resp = self.client.get('/home/')
self.assertEqual(resp.status_code, 200)
def test_username_change(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('profiles:account'), {
'action': 'username',
'username': 'Alicia',
})
self.assertRedirects(resp, reverse('profiles:account'))
self.assertTrue(get_user_model().objects.filter(username='Alicia').exists())
def test_username_taken_rejected(self):
User = get_user_model()
User.objects.create_user(username='Bob', password='pw')
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('profiles:account'), {
'action': 'username',
'username': 'Bob',
})
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'already taken')
def test_email_blank_voids_field(self):
self.gate()
self.client.login(username='Alice', password='pw')
User = get_user_model()
User.objects.filter(pk=self.user.pk).update(email='alice@example.com')
resp = self.client.post(reverse('profiles:account'), {
'action': 'email',
'email': '',
})
self.assertRedirects(resp, reverse('profiles:account'))
self.user.refresh_from_db()
self.assertEqual(self.user.email, '')
def test_password_change(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('profiles:account'), {
'action': 'password',
'old_password': 'pw',
'new_password1': 'new-secure-pass-123',
'new_password2': 'new-secure-pass-123',
})
self.assertRedirects(resp, reverse('profiles:account'))
self.assertTrue(self.client.login(username='Alice', password='new-secure-pass-123'))
def test_avatar_upload_creates_file_index(self):
import PIL.Image
self.gate()
self.client.login(username='Alice', password='pw')
buf = io.BytesIO()
PIL.Image.new('RGB', (8, 8), 'red').save(buf, format='PNG')
upload = SimpleUploadedFile('pic.png', buf.getvalue(), content_type='image/png')
resp = self.client.post(reverse('profiles:account'), {
'action': 'avatar',
'avatar': upload,
})
self.assertRedirects(resp, reverse('profiles:account'))
self.user.userprofile.refresh_from_db()
self.assertIsNotNone(self.user.userprofile.avatar)
fi = FileIndex.objects.get(pk=self.user.userprofile.avatar_id)
self.assertEqual(fi.kind, 'avatar')
self.assertTrue(fi.stored_path.endswith('picture.jpeg'))
self.assertEqual(fi.content_type, 'image/jpeg')
self.assertEqual(fi.owner, self.user)
# The indexed file must be reachable through FileRequest.
url = reverse('library:file_request', args=[fi.uuid])
self.client.get(url) # warm auth
resp = self.client.get(url)
self.assertEqual(resp.status_code, 200)
self.assertEqual(b''.join(resp.streaming_content), _read_media(fi.stored_path))
class ProfilePageTests(GatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
self.bob = User.objects.create_user(username='Bob', password='pw')
UserProfile.objects.get_or_create(user=self.bob)
def test_profile_page_renders(self):
self.gate()
resp = self.client.get(reverse('profiles:user_profile', args=['Alice']))
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'Alice')
def test_profile_unknown_user_404(self):
self.gate()
resp = self.client.get(reverse('profiles:user_profile', args=['Nobody']))
self.assertEqual(resp.status_code, 404)
def test_bio_owner_can_edit(self):
self.gate()
self.client.login(username='Alice', password='pw')
resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
'action': 'bio',
'bio': 'Hello there',
})
self.assertRedirects(resp, reverse('profiles:user_profile', args=['Alice']))
self.alice.userprofile.refresh_from_db()
self.assertEqual(self.alice.userprofile.bio, 'Hello there')
def test_bio_non_owner_cannot_edit(self):
self.gate()
self.client.login(username='Bob', password='pw')
resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
'action': 'bio',
'bio': 'Hacked',
})
# Non-owner must not be able to rewrite the bio.
self.alice.userprofile.refresh_from_db()
self.assertEqual(self.alice.userprofile.bio, '')
def test_bio_over_max_length_rejected(self):
self.gate()
self.client.login(username='Alice', password='pw')
oversized = 'x' * (BIO_MAX_LENGTH + 1)
resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
'action': 'bio',
'bio': oversized,
})
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, 'at most')
self.alice.userprofile.refresh_from_db()
self.assertEqual(self.alice.userprofile.bio, '')
def test_bio_at_max_length_accepted(self):
self.gate()
self.client.login(username='Alice', password='pw')
at_limit = 'y' * BIO_MAX_LENGTH
resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
'action': 'bio',
'bio': at_limit,
})
self.assertRedirects(resp, reverse('profiles:user_profile', args=['Alice']))
self.alice.userprofile.refresh_from_db()
self.assertEqual(len(self.alice.userprofile.bio), BIO_MAX_LENGTH)
def test_bio_special_symbols_round_trip(self):
self.gate()
self.client.login(username='Alice', password='pw')
# Emoji, quotes, ampersands, HTML, unicode — must all round-trip.
fancy = 'Café <b>&"\'</b> 😀 line\nbreak\ttab 100% +plus?'
resp = self.client.post(reverse('profiles:user_profile', args=['Alice']), {
'action': 'bio',
'bio': fancy,
})
self.assertRedirects(resp, reverse('profiles:user_profile', args=['Alice']))
self.alice.userprofile.refresh_from_db()
self.assertEqual(self.alice.userprofile.bio, fancy)
class FileRequestTests(MediaTestCase, GatedTestCase):
def setUp(self):
User = get_user_model()
self.alice = User.objects.create_user(username='Alice', password='pw')
UserProfile.objects.get_or_create(user=self.alice)
self.file_index = FileIndex.objects.create(
owner=self.alice,
kind='avatar',
stored_path='avatars/user_%d/picture.jpeg' % self.alice.pk,
original_filename='pic.jpeg',
content_type='image/jpeg',
size=4,
md5='abc',
)
self.url = reverse('library:file_request', args=[self.file_index.uuid])
def _write_media(self):
from django.core.files.storage import default_storage
default_storage.save(self.file_index.stored_path, io.BytesIO(b'ABCD'))
def test_full_request_200(self):
self._write_media()
self.gate()
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 200)
self.assertEqual(b''.join(resp.streaming_content), b'ABCD')
self.assertEqual(resp['Accept-Ranges'], 'bytes')
def test_byte_range_206(self):
self._write_media()
self.gate()
resp = self.client.get(self.url, HTTP_RANGE='bytes=0-1')
self.assertEqual(resp.status_code, 206)
self.assertEqual(b''.join(resp.streaming_content), b'AB')
self.assertEqual(resp['Content-Range'], 'bytes 0-1/4')
def test_suffix_range_206(self):
self._write_media()
self.gate()
resp = self.client.get(self.url, HTTP_RANGE='bytes=-2')
self.assertEqual(resp.status_code, 206)
self.assertEqual(b''.join(resp.streaming_content), b'CD')
def test_unsatisfiable_range_416(self):
self._write_media()
self.gate()
resp = self.client.get(self.url, HTTP_RANGE='bytes=99-100')
self.assertEqual(resp.status_code, 416)
self.assertEqual(resp['Content-Range'], 'bytes */4')
def test_media_requires_gate(self):
self._write_media()
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 401)
def test_media_accessible_with_token(self):
self._write_media()
token = secrets.token_urlsafe(48)
ApiToken.objects.create(
user=self.alice,
token=token,
key_prefix=token[:8],
label='test',
)
resp = self.client.get(
self.url,
HTTP_AUTHORIZATION=f'Bearer Alice {token}',
)
self.assertEqual(resp.status_code, 200)
def test_missing_file_404(self):
self.gate()
resp = self.client.get(self.url)
self.assertEqual(resp.status_code, 404)
+2
View File
@@ -9,6 +9,8 @@ urlpatterns = [
path('login/', views.user_login, name='login'),
path('logout/', views.user_logout, name='logout'),
path('settings/', views.settings_page, name='settings'),
path('settings/account/', views.account_page, name='account'),
path('settings/tokens/create/', views.token_create, name='token_create'),
path('settings/tokens/<int:token_id>/delete/', views.token_delete, name='token_delete'),
path('profiles/<str:username>/', views.user_profile, name='user_profile'),
]
+137 -4
View File
@@ -1,11 +1,18 @@
import hashlib
import io
import secrets
from django.contrib import messages
from django.contrib.auth import authenticate, login, logout
from django.contrib.auth import authenticate, get_user_model, login, logout, update_session_auth_hash
from django.contrib.auth.decorators import login_required
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.shortcuts import redirect, render
from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage
from django.shortcuts import get_object_or_404, redirect, render
from library.models import FileIndex
from .forms import AvatarForm, BioForm, EmailForm, UsernameForm
from .models import ApiToken, UserProfile
@@ -37,7 +44,12 @@ def user_login(request):
def user_logout(request):
if request.method == 'POST':
was_authorized = request.session.get('authorized', False)
logout(request)
# logout() flushes the session (wiping the gate flag). The gate is a
# site-wide master password, separate from user accounts — preserve it.
if was_authorized:
request.session['authorized'] = True
return redirect('landing:home')
return redirect('landing:home')
@@ -67,4 +79,125 @@ def token_create(request):
@login_required
def token_delete(request, token_id):
ApiToken.objects.filter(id=token_id, user=request.user).delete()
return redirect('profiles:settings')
return redirect('profiles:settings')
def _jpeg_bytes(uploaded):
"""Return JPEG bytes for an uploaded image.
Already-JPEG uploads are stored as-is; anything else is re-encoded
to JPEG via Pillow.
"""
import PIL.Image
image = PIL.Image.open(uploaded)
if image.format == 'JPEG':
uploaded.seek(0)
return uploaded.read()
if image.mode not in ('RGB', 'L'):
image = image.convert('RGB')
buffer = io.BytesIO()
image.save(buffer, format='JPEG', quality=88)
return buffer.getvalue()
def _store_avatar(user, uploaded):
"""Persist an avatar under /avatars/user_<id>/picture.jpeg, index it in
FileIndex, and return the FileIndex row (replacing any previous avatar)."""
profile, _ = UserProfile.objects.get_or_create(user=user)
old_index = profile.avatar
if old_index is not None:
if old_index.stored_path and default_storage.exists(old_index.stored_path):
default_storage.delete(old_index.stored_path)
FileIndex.objects.filter(pk=old_index.pk).delete()
profile.avatar = None
data = _jpeg_bytes(uploaded)
stored_path = f'avatars/user_{user.pk}/picture.jpeg'
actual_path = default_storage.save(stored_path, ContentFile(data))
file_index = FileIndex.objects.create(
owner=user,
kind='avatar',
stored_path=actual_path,
original_filename=getattr(uploaded, 'name', '') or 'picture.jpeg',
content_type='image/jpeg',
size=len(data),
md5=hashlib.md5(data).hexdigest(),
)
profile.avatar = file_index
profile.save(update_fields=['avatar'])
return file_index
@login_required
def account_page(request):
username_form = UsernameForm(instance=request.user)
email_form = EmailForm(instance=request.user)
password_form = PasswordChangeForm(request.user)
avatar_form = AvatarForm()
if request.method == 'POST':
action = request.POST.get('action')
if action == 'username':
username_form = UsernameForm(request.POST, instance=request.user)
if username_form.is_valid():
username_form.save()
messages.success(request, 'Username updated.')
return redirect('profiles:account')
messages.error(request, 'Could not update username.')
elif action == 'email':
email_form = EmailForm(request.POST, instance=request.user)
if email_form.is_valid():
email_form.save()
messages.success(request, 'Email updated.')
return redirect('profiles:account')
messages.error(request, 'Could not update email.')
elif action == 'password':
password_form = PasswordChangeForm(request.user, request.POST)
if password_form.is_valid():
user = password_form.save()
update_session_auth_hash(request, user)
messages.success(request, 'Password updated.')
return redirect('profiles:account')
messages.error(request, 'Could not update password.')
elif action == 'avatar':
avatar_form = AvatarForm(request.POST, request.FILES)
if avatar_form.is_valid():
_store_avatar(request.user, avatar_form.cleaned_data['avatar'])
messages.success(request, 'Profile picture updated.')
return redirect('profiles:account')
messages.error(request, 'Could not update profile picture.')
context = {
'username_form': username_form,
'email_form': email_form,
'password_form': password_form,
'avatar_form': avatar_form,
}
return render(request, 'profiles/account.html', context)
def user_profile(request, username):
User = get_user_model()
user = get_object_or_404(User, username__iexact=username)
profile, created = UserProfile.objects.get_or_create(user=user)
is_owner = request.user.is_authenticated and request.user.pk == user.pk
bio_form = None
if is_owner:
if request.method == 'POST' and request.POST.get('action') == 'bio':
bio_form = BioForm(request.POST, instance=profile)
if bio_form.is_valid():
bio_form.save()
messages.success(request, 'Bio updated.')
return redirect('profiles:user_profile', username=user.username)
messages.error(request, 'Could not update bio.')
else:
bio_form = BioForm(instance=profile)
return render(request, 'profiles/profile.html', {
'profile': profile,
'profile_user': user,
'is_owner': is_owner,
'bio_form': bio_form,
})