feat: finalize UI/UX
This commit is contained in:
@@ -5,6 +5,12 @@ from .models import ClientProfile
|
||||
|
||||
@admin.register(ClientProfile)
|
||||
class ClientProfileAdmin(admin.ModelAdmin):
|
||||
list_display = ("user", "company", "phone", "timezone", "updated_at")
|
||||
search_fields = ("user__username", "user__email", "user__first_name", "user__last_name", "company")
|
||||
list_filter = ("timezone",)
|
||||
list_display = ("user", "company", "job_title", "phone", "updated_at")
|
||||
search_fields = (
|
||||
"user__username",
|
||||
"user__email",
|
||||
"user__first_name",
|
||||
"user__last_name",
|
||||
"company",
|
||||
"job_title",
|
||||
)
|
||||
|
||||
@@ -11,7 +11,6 @@ class SignUpForm(UserCreationForm):
|
||||
email = forms.EmailField(required=True)
|
||||
company = forms.CharField(max_length=200, required=False)
|
||||
phone = forms.CharField(max_length=50, required=False)
|
||||
timezone = forms.ChoiceField(choices=ClientProfile.TIMEZONE_CHOICES, required=True)
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
@@ -19,7 +18,7 @@ class SignUpForm(UserCreationForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
for field_name in ("username", "first_name", "last_name", "email", "company", "phone", "timezone"):
|
||||
for field_name in ("username", "first_name", "last_name", "email", "company", "phone"):
|
||||
if field_name in self.fields:
|
||||
self.fields[field_name].widget.attrs.setdefault("class", "form-control")
|
||||
for field_name in ("password1", "password2"):
|
||||
@@ -42,7 +41,6 @@ class SignUpForm(UserCreationForm):
|
||||
user=user,
|
||||
company=self.cleaned_data.get("company", ""),
|
||||
phone=self.cleaned_data.get("phone", ""),
|
||||
timezone=self.cleaned_data.get("timezone", "UTC"),
|
||||
)
|
||||
return user
|
||||
|
||||
@@ -65,11 +63,17 @@ class ClientProfileForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = ClientProfile
|
||||
fields = ("company", "phone", "timezone")
|
||||
fields = ("avatar", "company", "job_title", "phone", "bio")
|
||||
widgets = {
|
||||
"avatar": forms.ClearableFileInput(
|
||||
attrs={"class": "profile-avatar-input", "accept": "image/jpeg,image/png,image/webp"}
|
||||
),
|
||||
"company": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"job_title": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"phone": forms.TextInput(attrs={"class": "form-control"}),
|
||||
"timezone": forms.Select(attrs={"class": "form-control"}),
|
||||
"bio": forms.Textarea(
|
||||
attrs={"class": "form-control", "rows": 4, "placeholder": "A short introduction to your work and research interests"}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 5.2.15 on 2026-08-01 12:31
|
||||
|
||||
import apps.accounts.models
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('accounts', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='clientprofile',
|
||||
name='timezone',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clientprofile',
|
||||
name='avatar',
|
||||
field=models.ImageField(blank=True, upload_to=apps.accounts.models.profile_avatar_upload_to, validators=[django.core.validators.FileExtensionValidator(['jpg', 'jpeg', 'png', 'webp']), apps.accounts.models.validate_avatar_size]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clientprofile',
|
||||
name='bio',
|
||||
field=models.TextField(blank=True, max_length=600),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='clientprofile',
|
||||
name='job_title',
|
||||
field=models.CharField(blank=True, max_length=150),
|
||||
),
|
||||
]
|
||||
+24
-18
@@ -1,33 +1,39 @@
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import FileExtensionValidator
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ClientProfile(models.Model):
|
||||
TIMEZONE_CHOICES = [
|
||||
("UTC", "UTC"),
|
||||
("America/New_York", "America/New York"),
|
||||
("America/Chicago", "America/Chicago"),
|
||||
("America/Denver", "America/Denver"),
|
||||
("America/Los_Angeles", "America/Los Angeles"),
|
||||
("Europe/London", "Europe/London"),
|
||||
("Europe/Berlin", "Europe/Berlin"),
|
||||
("Asia/Dubai", "Asia/Dubai"),
|
||||
("Asia/Tehran", "Asia/Tehran"),
|
||||
("Asia/Tokyo", "Asia/Tokyo"),
|
||||
("Australia/Sydney", "Australia/Sydney"),
|
||||
]
|
||||
def profile_avatar_upload_to(instance, filename):
|
||||
suffix = Path(filename).suffix.lower()
|
||||
return f"accounts/avatars/{instance.user_id}/{uuid4().hex}{suffix}"
|
||||
|
||||
|
||||
def validate_avatar_size(image):
|
||||
if image.size > 5 * 1024 * 1024:
|
||||
raise ValidationError("Profile pictures must be 5 MB or smaller.")
|
||||
|
||||
|
||||
class ClientProfile(models.Model):
|
||||
user = models.OneToOneField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="client_profile",
|
||||
)
|
||||
company = models.CharField(max_length=200, blank=True)
|
||||
job_title = models.CharField(max_length=150, blank=True)
|
||||
phone = models.CharField(max_length=50, blank=True)
|
||||
timezone = models.CharField(
|
||||
max_length=64,
|
||||
choices=TIMEZONE_CHOICES,
|
||||
default="UTC",
|
||||
bio = models.TextField(max_length=600, blank=True)
|
||||
avatar = models.ImageField(
|
||||
upload_to=profile_avatar_upload_to,
|
||||
blank=True,
|
||||
validators=[
|
||||
FileExtensionValidator(["jpg", "jpeg", "png", "webp"]),
|
||||
validate_avatar_size,
|
||||
],
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
@@ -16,7 +16,6 @@ class SignUpFormTest(TestCase):
|
||||
"email": "ada@example.com",
|
||||
"company": "Analytical Engines",
|
||||
"phone": "+1 555 0100",
|
||||
"timezone": "UTC",
|
||||
"password1": "Str0ngPass!word",
|
||||
"password2": "Str0ngPass!word",
|
||||
}
|
||||
@@ -26,7 +25,6 @@ class SignUpFormTest(TestCase):
|
||||
self.assertEqual(user.email, "ada@example.com")
|
||||
profile = ClientProfile.objects.get(user=user)
|
||||
self.assertEqual(profile.company, "Analytical Engines")
|
||||
self.assertEqual(profile.timezone, "UTC")
|
||||
|
||||
|
||||
class AccountViewsTest(TestCase):
|
||||
@@ -34,6 +32,15 @@ class AccountViewsTest(TestCase):
|
||||
response = self.client.get(reverse("accounts:signup"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_logged_out_nav_exposes_login_and_registration(self):
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
navbar = response.content.decode().split("</nav>", 1)[0]
|
||||
|
||||
self.assertIn("Login", navbar)
|
||||
self.assertIn("Start a Project", navbar)
|
||||
self.assertIn(reverse("accounts:login"), navbar)
|
||||
self.assertIn(reverse("accounts:signup"), navbar)
|
||||
|
||||
def test_signup_creates_account_and_logs_in(self):
|
||||
response = self.client.post(
|
||||
reverse("accounts:signup"),
|
||||
@@ -44,7 +51,6 @@ class AccountViewsTest(TestCase):
|
||||
"email": "grace@example.com",
|
||||
"company": "",
|
||||
"phone": "",
|
||||
"timezone": "UTC",
|
||||
"password1": "Str0ngPass!word",
|
||||
"password2": "Str0ngPass!word",
|
||||
},
|
||||
@@ -58,6 +64,47 @@ class AccountViewsTest(TestCase):
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn(reverse("accounts:login"), response.url)
|
||||
|
||||
def test_authenticated_nav_uses_sidebar_for_account_actions(self):
|
||||
user = User.objects.create_user(
|
||||
username="workspace-user",
|
||||
email="workspace@example.com",
|
||||
password="Str0ngPass!word",
|
||||
)
|
||||
self.client.force_login(user)
|
||||
|
||||
response = self.client.get(reverse("projects:dashboard"))
|
||||
|
||||
self.assertContains(response, "Profile & account")
|
||||
self.assertContains(response, "Project briefs")
|
||||
navbar = response.content.decode().split("</nav>", 1)[0]
|
||||
self.assertNotIn("My Projects", navbar)
|
||||
self.assertNotIn("Logout", navbar)
|
||||
self.assertIn("navbar-account", navbar)
|
||||
self.assertIn("navbar-account-dropdown", navbar)
|
||||
self.assertIn("Your profile", navbar)
|
||||
self.assertIn("New project brief", navbar)
|
||||
self.assertIn("Contact support", navbar)
|
||||
self.assertIn("Sign out", navbar)
|
||||
self.assertIn("default-avatar.png", navbar)
|
||||
|
||||
def test_profile_exposes_professional_fields_and_avatar_upload(self):
|
||||
user = User.objects.create_user(
|
||||
username="researcher",
|
||||
first_name="Marie",
|
||||
last_name="Curie",
|
||||
email="marie@example.com",
|
||||
password="Str0ngPass!word",
|
||||
)
|
||||
self.client.force_login(user)
|
||||
|
||||
response = self.client.get(reverse("accounts:profile"))
|
||||
|
||||
self.assertContains(response, 'enctype="multipart/form-data"')
|
||||
self.assertContains(response, 'name="avatar"')
|
||||
self.assertContains(response, 'name="job_title"')
|
||||
self.assertContains(response, 'name="bio"')
|
||||
self.assertNotContains(response, 'name="timezone"')
|
||||
|
||||
def test_logout_requires_post_and_clears_session(self):
|
||||
self.client.login(username="newclient", password="Str0ngPass!word")
|
||||
response = self.client.get(reverse("accounts:logout"))
|
||||
|
||||
Reference in New Issue
Block a user