working FileIndex and user Profile pages/settings
This commit is contained in:
@@ -1,16 +1,12 @@
|
||||
"""
|
||||
URL configuration for common project.
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.urls import include, path
|
||||
|
||||
urlpatterns = [
|
||||
path('', include('landing.urls')),
|
||||
path('', include('profiles.urls')),
|
||||
path('api/', include('library.urls')),
|
||||
path('admin/', admin.site.urls),
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 6.0.3 on 2026-08-03 18:14
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='FileIndex',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
('kind', models.CharField(db_index=True, default='file', max_length=64)),
|
||||
('stored_path', models.CharField(max_length=1024)),
|
||||
('original_filename', models.CharField(blank=True, default='', max_length=255)),
|
||||
('content_type', models.CharField(blank=True, default='', max_length=128)),
|
||||
('size', models.IntegerField(default=0)),
|
||||
('md5', models.CharField(db_index=True, max_length=32)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='files', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['owner', 'kind'], name='library_fil_owner_i_c2b465_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,3 +1,37 @@
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
|
||||
class FileIndex(models.Model):
|
||||
"""Tracks every user file so media stays organized and served only
|
||||
through the gated FileRequest endpoint (never direct /media/ access)."""
|
||||
|
||||
uuid = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='files',
|
||||
)
|
||||
kind = models.CharField(max_length=64, default='file', db_index=True)
|
||||
stored_path = models.CharField(max_length=1024)
|
||||
original_filename = models.CharField(max_length=255, blank=True, default='')
|
||||
content_type = models.CharField(max_length=128, blank=True, default='')
|
||||
size = models.IntegerField(default=0)
|
||||
md5 = models.CharField(max_length=32, db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
indexes = [
|
||||
models.Index(fields=['owner', 'kind']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.kind} {self.uuid} ({self.owner})'
|
||||
|
||||
@property
|
||||
def file_path(self):
|
||||
return self.stored_path
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = 'library'
|
||||
|
||||
urlpatterns = [
|
||||
path('files/<uuid:file_id>/', views.file_request, name='file_request'),
|
||||
]
|
||||
+102
-2
@@ -1,3 +1,103 @@
|
||||
from django.shortcuts import render
|
||||
import posixpath
|
||||
from pathlib import Path
|
||||
|
||||
# Create your views here.
|
||||
from django.conf import settings
|
||||
from django.http import FileResponse, Http404, HttpResponse, StreamingHttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from .models import FileIndex
|
||||
|
||||
|
||||
def _open_indexed_file(file_index):
|
||||
"""Return an open binary file handle for the indexed file (404-safe)."""
|
||||
stored = file_index.stored_path
|
||||
if not stored or posixpath.isabs(stored) or '..' in stored.split('/'):
|
||||
raise Http404
|
||||
file_path = (Path(settings.MEDIA_ROOT) / stored).resolve()
|
||||
if not file_path.is_relative_to(Path(settings.MEDIA_ROOT).resolve()):
|
||||
raise Http404
|
||||
if not file_path.is_file():
|
||||
raise Http404
|
||||
return open(file_path, 'rb')
|
||||
|
||||
|
||||
def _unsatisfiable(content_length):
|
||||
resp = HttpResponse(status=416)
|
||||
resp['Content-Range'] = f'bytes */{content_length}'
|
||||
return resp
|
||||
|
||||
|
||||
def file_request(request, file_id):
|
||||
"""Serve an indexed file behind the gate, honouring HTTP byte ranges.
|
||||
|
||||
GET /api/files/<uuid>/ → full file (200)
|
||||
Range: bytes=0-99 → partial content (206)
|
||||
Range: bytes=<start>- → tail from <start>
|
||||
Range: bytes=-<suffix> → last <suffix> bytes
|
||||
?download=1 → Content-Disposition: attachment
|
||||
"""
|
||||
file_index = get_object_or_404(FileIndex, uuid=file_id)
|
||||
fh = _open_indexed_file(file_index)
|
||||
|
||||
content_type = file_index.content_type or 'application/octet-stream'
|
||||
content_length = file_index.size or 0
|
||||
disposition = ''
|
||||
if request.GET.get('download') == '1':
|
||||
filename = file_index.original_filename or posixpath.basename(file_index.stored_path)
|
||||
disposition = f'attachment; filename="{filename}"'
|
||||
|
||||
range_header = request.META.get('HTTP_RANGE', '').strip()
|
||||
if not range_header or not range_header.lower().startswith('bytes='):
|
||||
response = FileResponse(fh, content_type=content_type)
|
||||
response['Accept-Ranges'] = 'bytes'
|
||||
response['Content-Length'] = str(content_length)
|
||||
if disposition:
|
||||
response['Content-Disposition'] = disposition
|
||||
return response
|
||||
|
||||
try:
|
||||
start, _, end = range_header[6:].partition('-')
|
||||
start = int(start) if start else None
|
||||
end = int(end) if end else None
|
||||
except ValueError:
|
||||
return _unsatisfiable(content_length)
|
||||
|
||||
if start is None and end is None:
|
||||
return _unsatisfiable(content_length)
|
||||
|
||||
if start is None:
|
||||
suffix = end
|
||||
if suffix <= 0:
|
||||
return _unsatisfiable(content_length)
|
||||
start = max(content_length - suffix, 0)
|
||||
end = content_length - 1
|
||||
elif end is None:
|
||||
end = content_length - 1
|
||||
|
||||
if start < 0 or start >= content_length or end < start:
|
||||
return _unsatisfiable(content_length)
|
||||
|
||||
end = min(end, content_length - 1)
|
||||
length = end - start + 1
|
||||
fh.seek(start)
|
||||
|
||||
def chunk_generator():
|
||||
try:
|
||||
remaining = length
|
||||
while remaining > 0:
|
||||
data = fh.read(min(8192, remaining))
|
||||
if not data:
|
||||
break
|
||||
remaining -= len(data)
|
||||
yield data
|
||||
finally:
|
||||
fh.close()
|
||||
|
||||
part = StreamingHttpResponse(chunk_generator(), content_type=content_type)
|
||||
part['Content-Range'] = f'bytes {start}-{end}/{content_length}'
|
||||
part['Accept-Ranges'] = 'bytes'
|
||||
part['Content-Length'] = str(length)
|
||||
part.status_code = 206
|
||||
if disposition:
|
||||
part['Content-Disposition'] = disposition
|
||||
return part
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
+136
-3
@@ -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')
|
||||
|
||||
@@ -68,3 +80,124 @@ def token_create(request):
|
||||
def token_delete(request, token_id):
|
||||
ApiToken.objects.filter(id=token_id, user=request.user).delete()
|
||||
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,
|
||||
})
|
||||
@@ -1758,3 +1758,105 @@ a.deletelink {
|
||||
border-radius: 4px;
|
||||
color: var(--md-sys-color-on-surface);
|
||||
}
|
||||
|
||||
/* ========== Account Settings ========== */
|
||||
.account-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.account-card h2 {
|
||||
font-size: 1.05rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.account-current {
|
||||
font-size: 0.85rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.account-current code {
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.avatar-preview {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.avatar-preview img {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--md-sys-color-outline);
|
||||
}
|
||||
.avatar-preview .default-avatar {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.default-avatar.large {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
font-size: 2.5rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* ========== Public Profile ========== */
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.profile-avatar img {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--md-sys-color-outline);
|
||||
}
|
||||
.profile-avatar .default-avatar {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
font-size: 3rem;
|
||||
}
|
||||
.profile-info h1 {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
.profile-joined {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
font-size: 0.9rem;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.profile-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.profile-section h2 {
|
||||
margin-top: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.profile-bio {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
.profile-bio-empty {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
margin: 0;
|
||||
}
|
||||
.pack-card-empty {
|
||||
opacity: 0.75;
|
||||
pointer-events: none;
|
||||
}
|
||||
.pack-card-empty .pack-card-thumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
background: var(--md-sys-color-surface-variant);
|
||||
}
|
||||
|
||||
@@ -48,20 +48,21 @@
|
||||
<div class="user-bar">
|
||||
<div class="user-avatar" onclick="toggleUserMenu()">
|
||||
{% if user.userprofile.avatar %}
|
||||
<img src="{{ user.userprofile.avatar.url }}" alt="Avatar">
|
||||
<img src="{{ user.userprofile.avatar_url }}" alt="Avatar">
|
||||
{% else %}
|
||||
<div class="default-avatar">{{ user.username|first|upper }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="user-menu" id="user-menu">
|
||||
{# TODO: wire edit profile page when it exists #}
|
||||
<a href="{% url 'profiles:settings' %}" class="user-edit">
|
||||
<a href="{% url 'profiles:user_profile' request.user.username %}" class="user-edit">
|
||||
<i class="fas fa-user-circle"></i> {{ user.username }}
|
||||
</a>
|
||||
<div class="user-info"></div>
|
||||
{# TODO: wire upload / library pages when they exist #}
|
||||
<a href="{% url 'profiles:account' %}">
|
||||
<i class="fas fa-user-cog"></i> Account Settings
|
||||
</a>
|
||||
<a href="{% url 'profiles:settings' %}">
|
||||
<i class="fas fa-cog"></i> Settings
|
||||
<i class="fas fa-key"></i> API Tokens
|
||||
</a>
|
||||
<form method="post" action="{% url 'profiles:logout' %}" style="display: inline;">
|
||||
{% csrf_token %}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Account Settings - Packs{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Account Settings</h1>
|
||||
<p><a href="{% url 'profiles:settings' %}"><i class="fas fa-key"></i> Manage API tokens</a></p>
|
||||
|
||||
{% if messages %}
|
||||
<ul class="messages">
|
||||
{% for message in messages %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<div class="account-grid">
|
||||
<section class="card account-card">
|
||||
<h2><i class="fas fa-user"></i> Username</h2>
|
||||
<p class="account-current">Current: <strong>{{ request.user.username }}</strong></p>
|
||||
<form method="post" action="{% url 'profiles:account' %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="username">
|
||||
<div class="form-group">
|
||||
{{ username_form.username.errors }}
|
||||
<label for="{{ username_form.username.id_for_label }}">New username</label>
|
||||
{{ username_form.username }}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Change username</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card account-card">
|
||||
<h2><i class="fas fa-envelope"></i> Email</h2>
|
||||
<p class="account-current">Current: <strong>{{ request.user.email|default:"none" }}</strong></p>
|
||||
<form method="post" action="{% url 'profiles:account' %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="email">
|
||||
<div class="form-group">
|
||||
{{ email_form.email.errors }}
|
||||
<label for="{{ email_form.email.id_for_label }}">Email (optional — leave empty to clear)</label>
|
||||
{{ email_form.email }}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Update email</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card account-card">
|
||||
<h2><i class="fas fa-lock"></i> Password</h2>
|
||||
<form method="post" action="{% url 'profiles:account' %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="password">
|
||||
{% for field in password_form %}
|
||||
<div class="form-group">
|
||||
{{ field.errors }}
|
||||
<label for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
<button type="submit" class="btn btn-primary">Change password</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card account-card">
|
||||
<h2><i class="fas fa-image"></i> Profile picture</h2>
|
||||
<div class="avatar-preview">
|
||||
{% if request.user.userprofile.avatar %}
|
||||
<img src="{{ request.user.userprofile.avatar_url }}" alt="Current avatar">
|
||||
{% else %}
|
||||
<div class="default-avatar large">{{ request.user.username|first|upper }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="post" action="{% url 'profiles:account' %}" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="avatar">
|
||||
<div class="form-group">
|
||||
{{ avatar_form.avatar.errors }}
|
||||
<label for="{{ avatar_form.avatar.id_for_label }}">{{ avatar_form.avatar.label }}</label>
|
||||
{{ avatar_form.avatar }}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Upload picture</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ profile_user.username }} - Packs{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="profile-header card">
|
||||
<div class="profile-avatar">
|
||||
{% if profile.avatar %}
|
||||
<img src="{{ profile.avatar_url }}" alt="{{ profile_user.username }} avatar">
|
||||
{% else %}
|
||||
<div class="default-avatar large">{{ profile_user.username|first|upper }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="profile-info">
|
||||
<h1>{{ profile_user.username }}</h1>
|
||||
<p class="profile-joined"><i class="fas fa-calendar"></i> Joined {{ profile.joined|date:"F j, Y" }}</p>
|
||||
{% if is_owner %}
|
||||
<p><a href="{% url 'profiles:account' %}" class="btn btn-secondary"><i class="fas fa-cog"></i> Account Settings</a></p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="card profile-section">
|
||||
<h2><i class="fas fa-comment"></i> Bio</h2>
|
||||
{% if is_owner %}
|
||||
{% if messages %}
|
||||
<ul class="messages">
|
||||
{% for message in messages %}
|
||||
<li>{{ message }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<form method="post" action="{% url 'profiles:user_profile' profile_user.username %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="action" value="bio">
|
||||
<div class="form-group">
|
||||
{{ bio_form.bio.errors }}
|
||||
{{ bio_form.bio }}
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Save bio</button>
|
||||
</form>
|
||||
{% else %}
|
||||
{% if profile.bio %}
|
||||
<p class="profile-bio">{{ profile.bio }}</p>
|
||||
{% else %}
|
||||
<p class="profile-bio-empty"><i class="fas fa-user"></i> {{ profile_user.username }} hasn't written a bio yet.</p>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="home-section">
|
||||
<div class="home-section-header">
|
||||
<h2>Packs by {{ profile_user.username }}</h2>
|
||||
</div>
|
||||
<div class="pack-grid">
|
||||
{% comment %} Placeholder — replace with real Pack objects when the library model lands. {% endcomment %}
|
||||
<div class="pack-card pack-card-empty">
|
||||
<div class="pack-card-thumb">
|
||||
<i class="fas fa-box-open"></i>
|
||||
</div>
|
||||
<div class="pack-card-body">
|
||||
<h3>No packs yet</h3>
|
||||
<p class="pack-card-desc">This creator hasn't published any datapacks.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -1,9 +1,10 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Settings - Packs{% endblock %}
|
||||
{% block title %}API Tokens - Packs{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Settings</h1>
|
||||
<h1>API Tokens</h1>
|
||||
<p><a href="{% url 'profiles:account' %}"><i class="fas fa-user-cog"></i> Account Settings</a></p>
|
||||
|
||||
{% if messages %}
|
||||
<ul class="messages">
|
||||
@@ -13,7 +14,7 @@
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
<h2>API Tokens</h2>
|
||||
<h2>Tokens</h2>
|
||||
<p>Tokens let non-browser clients (like a Fabric mod) access the API and download packs.</p>
|
||||
<p class="token-hint">
|
||||
Send credentials as <code>Authorization: Bearer <username> <token></code>,
|
||||
|
||||
Reference in New Issue
Block a user