working FileIndex and user Profile pages/settings

This commit is contained in:
JakeBreath
2026-08-03 14:13:40 -05:00
parent 85e6241ad4
commit 39e31d3980
17 changed files with 999 additions and 25 deletions
+1 -5
View File
@@ -1,16 +1,12 @@
""" """
URL configuration for common project. URL configuration for common project.
""" """
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
urlpatterns = [ urlpatterns = [
path('', include('landing.urls')), path('', include('landing.urls')),
path('', include('profiles.urls')), path('', include('profiles.urls')),
path('api/', include('library.urls')),
path('admin/', admin.site.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')],
},
),
]
+35 -1
View File
@@ -1,3 +1,37 @@
import uuid
from django.conf import settings
from django.db import models 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
+9
View File
@@ -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
View File
@@ -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
+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.conf import settings
from django.db import models from django.db import models
from django.urls import reverse
BIO_MAX_LENGTH = 1000
class UserProfile(models.Model): class UserProfile(models.Model):
@@ -8,14 +11,26 @@ class UserProfile(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='userprofile', related_name='userprofile',
) )
bio = models.TextField(blank=True, default='') bio = models.CharField(
avatar = models.ImageField( max_length=BIO_MAX_LENGTH,
upload_to='profiles/%d/',
blank=True, blank=True,
default='',
)
avatar = models.OneToOneField(
'library.FileIndex',
on_delete=models.SET_NULL,
null=True, null=True,
blank=True,
related_name='profile',
) )
joined = models.DateTimeField(auto_now_add=True) 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): def __str__(self):
return f'{self.user.username} profile' return f'{self.user.username} profile'
+301 -2
View File
@@ -1,9 +1,49 @@
import io
import secrets import secrets
import shutil
import tempfile
from django.contrib.auth import get_user_model 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): class ApiTokenAuthTests(TestCase):
@@ -74,3 +114,262 @@ class ApiTokenAuthTests(TestCase):
) )
token = ApiToken.objects.get(token=self.token) token = ApiToken.objects.get(token=self.token)
self.assertIsNotNone(token.last_used) 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('login/', views.user_login, name='login'),
path('logout/', views.user_logout, name='logout'), path('logout/', views.user_logout, name='logout'),
path('settings/', views.settings_page, name='settings'), 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/create/', views.token_create, name='token_create'),
path('settings/tokens/<int:token_id>/delete/', views.token_delete, name='token_delete'), 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
View File
@@ -1,11 +1,18 @@
import hashlib
import io
import secrets import secrets
from django.contrib import messages 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.decorators import login_required
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm, UserCreationForm
from django.shortcuts import redirect, render 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 from .models import ApiToken, UserProfile
@@ -37,7 +44,12 @@ def user_login(request):
def user_logout(request): def user_logout(request):
if request.method == 'POST': if request.method == 'POST':
was_authorized = request.session.get('authorized', False)
logout(request) 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')
return redirect('landing:home') return redirect('landing:home')
@@ -68,3 +80,124 @@ def token_create(request):
def token_delete(request, token_id): def token_delete(request, token_id):
ApiToken.objects.filter(id=token_id, user=request.user).delete() 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,
})
+102
View File
@@ -1758,3 +1758,105 @@ a.deletelink {
border-radius: 4px; border-radius: 4px;
color: var(--md-sys-color-on-surface); 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);
}
+6 -5
View File
@@ -48,20 +48,21 @@
<div class="user-bar"> <div class="user-bar">
<div class="user-avatar" onclick="toggleUserMenu()"> <div class="user-avatar" onclick="toggleUserMenu()">
{% if user.userprofile.avatar %} {% if user.userprofile.avatar %}
<img src="{{ user.userprofile.avatar.url }}" alt="Avatar"> <img src="{{ user.userprofile.avatar_url }}" alt="Avatar">
{% else %} {% else %}
<div class="default-avatar">{{ user.username|first|upper }}</div> <div class="default-avatar">{{ user.username|first|upper }}</div>
{% endif %} {% endif %}
</div> </div>
<div class="user-menu" id="user-menu"> <div class="user-menu" id="user-menu">
{# TODO: wire edit profile page when it exists #} <a href="{% url 'profiles:user_profile' request.user.username %}" class="user-edit">
<a href="{% url 'profiles:settings' %}" class="user-edit">
<i class="fas fa-user-circle"></i> {{ user.username }} <i class="fas fa-user-circle"></i> {{ user.username }}
</a> </a>
<div class="user-info"></div> <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' %}"> <a href="{% url 'profiles:settings' %}">
<i class="fas fa-cog"></i> Settings <i class="fas fa-key"></i> API Tokens
</a> </a>
<form method="post" action="{% url 'profiles:logout' %}" style="display: inline;"> <form method="post" action="{% url 'profiles:logout' %}" style="display: inline;">
{% csrf_token %} {% csrf_token %}
+86
View File
@@ -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 %}
+69
View File
@@ -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 %}
+4 -3
View File
@@ -1,9 +1,10 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Settings - Packs{% endblock %} {% block title %}API Tokens - Packs{% endblock %}
{% block content %} {% 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 %} {% if messages %}
<ul class="messages"> <ul class="messages">
@@ -13,7 +14,7 @@
</ul> </ul>
{% endif %} {% 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>Tokens let non-browser clients (like a Fabric mod) access the API and download packs.</p>
<p class="token-hint"> <p class="token-hint">
Send credentials as <code>Authorization: Bearer &lt;username&gt; &lt;token&gt;</code>, Send credentials as <code>Authorization: Bearer &lt;username&gt; &lt;token&gt;</code>,