feat: finalize UI/UX

This commit is contained in:
mohamad
2026-08-01 16:30:24 +03:30
parent 3c50abf404
commit 28e0f7f8fe
26 changed files with 822 additions and 528 deletions
+9 -3
View File
@@ -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",
)
+9 -5
View File
@@ -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):
@@ -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
View File
@@ -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)
+50 -3
View File
@@ -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 &amp; 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"))
+2 -2
View File
@@ -51,7 +51,7 @@ class HomeViewTest(TestCase):
from apps.projects.models import ProjectBrief
user = User.objects.create_user(username="client", email="c@example.com", password="x")
ClientProfile.objects.create(user=user, timezone="UTC")
ClientProfile.objects.create(user=user)
HomepageSection.objects.create(
section_type=HomepageSection.TYPE_PROJECTS,
title="Showcase",
@@ -86,7 +86,7 @@ class HomeViewTest(TestCase):
from apps.projects.models import ProjectBrief
user = User.objects.create_user(username="c2", email="c2@example.com", password="x")
ClientProfile.objects.create(user=user, timezone="UTC")
ClientProfile.objects.create(user=user)
HomepageSection.objects.create(
section_type=HomepageSection.TYPE_PROJECTS,
title="Showcase",
@@ -19,7 +19,6 @@ CLIENTS = [
"password": "ClientDemo2026!",
"company": "Northside Imaging Lab",
"phone": "+1 604 555 0101",
"timezone": "America/Vancouver",
},
{
"username": "sarah.research",
@@ -29,7 +28,6 @@ CLIENTS = [
"password": "ClientDemo2026!",
"company": "Pacific Oncology Research",
"phone": "+1 604 555 0102",
"timezone": "America/Los_Angeles",
},
{
"username": "devteam",
@@ -39,7 +37,6 @@ CLIENTS = [
"password": "ClientDemo2026!",
"company": "BioFlow Analytics",
"phone": "+44 20 7946 0103",
"timezone": "Europe/London",
},
]
@@ -192,7 +189,6 @@ class Command(BaseCommand):
defaults={
"company": client["company"],
"phone": client["phone"],
"timezone": client["timezone"],
},
)
action = "Created" if created else "Updated"
+2 -2
View File
@@ -16,13 +16,13 @@ class ProjectPortalTest(TestCase):
first_name="Client",
last_name="User",
)
ClientProfile.objects.create(user=self.user, timezone="UTC")
ClientProfile.objects.create(user=self.user)
self.other = User.objects.create_user(
username="other",
email="other@example.com",
password="Str0ngPass!word",
)
ClientProfile.objects.create(user=self.other, timezone="UTC")
ClientProfile.objects.create(user=self.other)
def _brief_payload(self):
return {