feat: admin panel enrichment

This commit is contained in:
mohamad
2026-07-19 14:31:28 +03:30
parent d8d7819eca
commit 1042fba121
77 changed files with 4616 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
+39
View File
@@ -0,0 +1,39 @@
import os
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.urls import reverse
from .models import ClientProfile
class TecvicoAccountAdapter(DefaultAccountAdapter):
def get_login_redirect_url(self, request):
return reverse("projects:dashboard")
def get_signup_redirect_url(self, request):
return reverse("projects:dashboard")
class TecvicoSocialAccountAdapter(DefaultSocialAccountAdapter):
def save_user(self, request, sociallogin, form=None):
user = super().save_user(request, sociallogin, form)
self._apply_profile_names(user, sociallogin)
user.save()
ClientProfile.objects.get_or_create(user=user)
return user
def _apply_profile_names(self, user, sociallogin):
extra_data = sociallogin.account.extra_data or {}
if sociallogin.account.provider == "google":
if not user.first_name:
user.first_name = extra_data.get("given_name", "")
if not user.last_name:
user.last_name = extra_data.get("family_name", "")
return
if sociallogin.account.provider == "github" and not user.first_name:
name = (extra_data.get("name") or "").strip()
if name:
parts = name.split(" ", 1)
user.first_name = parts[0]
user.last_name = parts[1] if len(parts) > 1 else ""
+10
View File
@@ -0,0 +1,10 @@
from django.contrib import admin
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",)
+26
View File
@@ -0,0 +1,26 @@
import os
from django.apps import AppConfig
from django.db.models.signals import post_migrate
def ensure_site(sender, **kwargs):
from django.conf import settings
from django.contrib.sites.models import Site
domain = os.environ.get("SITE_DOMAIN", "localhost:8000")
Site.objects.update_or_create(
id=getattr(settings, "SITE_ID", 1),
defaults={"domain": domain, "name": "Tecvico"},
)
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
label = "accounts"
def ready(self):
from . import signals # noqa: F401
post_migrate.connect(ensure_site, sender=self)
+12
View File
@@ -0,0 +1,12 @@
from django.conf import settings
from .social import enabled_social_providers
def social_auth(request):
providers = enabled_social_providers()
return {
"social_providers": providers,
"social_auth_enabled": bool(providers),
"site_domain": settings.SITE_DOMAIN,
}
+102
View File
@@ -0,0 +1,102 @@
from django import forms
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib.auth.models import User
from .models import ClientProfile
class SignUpForm(UserCreationForm):
first_name = forms.CharField(max_length=150, required=True)
last_name = forms.CharField(max_length=150, required=True)
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
fields = ("username", "first_name", "last_name", "email", "password1", "password2")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name in ("username", "first_name", "last_name", "email", "company", "phone", "timezone"):
if field_name in self.fields:
self.fields[field_name].widget.attrs.setdefault("class", "form-control")
for field_name in ("password1", "password2"):
self.fields[field_name].widget.attrs.setdefault("class", "form-control")
def clean_email(self):
email = self.cleaned_data["email"].strip().lower()
if User.objects.filter(email__iexact=email).exists():
raise forms.ValidationError("An account with this email already exists.")
return email
def save(self, commit=True):
user = super().save(commit=False)
user.email = self.cleaned_data["email"]
user.first_name = self.cleaned_data["first_name"]
user.last_name = self.cleaned_data["last_name"]
if commit:
user.save()
ClientProfile.objects.create(
user=user,
company=self.cleaned_data.get("company", ""),
phone=self.cleaned_data.get("phone", ""),
timezone=self.cleaned_data.get("timezone", "UTC"),
)
return user
class ClientLoginForm(AuthenticationForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["username"].widget.attrs.update(
{"placeholder": "Username or email", "class": "form-control"}
)
self.fields["password"].widget.attrs.update(
{"placeholder": "Password", "class": "form-control"}
)
class ClientProfileForm(forms.ModelForm):
first_name = forms.CharField(max_length=150, required=True)
last_name = forms.CharField(max_length=150, required=True)
email = forms.EmailField(required=True)
class Meta:
model = ClientProfile
fields = ("company", "phone", "timezone")
widgets = {
"company": forms.TextInput(attrs={"class": "form-control"}),
"phone": forms.TextInput(attrs={"class": "form-control"}),
"timezone": forms.Select(attrs={"class": "form-control"}),
}
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
self.fields["first_name"].initial = self.user.first_name
self.fields["last_name"].initial = self.user.last_name
self.fields["email"].initial = self.user.email
for name in ("first_name", "last_name", "email"):
self.fields[name].widget.attrs.setdefault("class", "form-control")
def clean_email(self):
email = self.cleaned_data["email"].strip().lower()
if (
User.objects.filter(email__iexact=email)
.exclude(pk=self.user.pk)
.exists()
):
raise forms.ValidationError("An account with this email already exists.")
return email
def save(self, commit=True):
profile = super().save(commit=False)
self.user.first_name = self.cleaned_data["first_name"]
self.user.last_name = self.cleaned_data["last_name"]
self.user.email = self.cleaned_data["email"]
if commit:
self.user.save()
profile.save()
return profile
+33
View File
@@ -0,0 +1,33 @@
# Generated by Django 5.2.15 on 2026-07-09 07:14
import django.db.models.deletion
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='ClientProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('company', models.CharField(blank=True, max_length=200)),
('phone', models.CharField(blank=True, max_length=50)),
('timezone', models.CharField(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')], default='UTC', max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='client_profile', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Client Profile',
'verbose_name_plural': 'Client Profiles',
},
),
]
+47
View File
@@ -0,0 +1,47 @@
from django.conf import settings
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"),
]
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="client_profile",
)
company = models.CharField(max_length=200, blank=True)
phone = models.CharField(max_length=50, blank=True)
timezone = models.CharField(
max_length=64,
choices=TIMEZONE_CHOICES,
default="UTC",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Client Profile"
verbose_name_plural = "Client Profiles"
def __str__(self):
return self.user.get_full_name() or self.user.email or str(self.user.pk)
@property
def display_name(self):
full_name = self.user.get_full_name().strip()
if full_name:
return full_name
return self.user.email
+10
View File
@@ -0,0 +1,10 @@
from django.dispatch import receiver
from allauth.account.signals import user_signed_up
from .models import ClientProfile
@receiver(user_signed_up)
def ensure_client_profile(sender, request, user, **kwargs):
ClientProfile.objects.get_or_create(user=user)
+15
View File
@@ -0,0 +1,15 @@
from django.conf import settings
def enabled_social_providers():
providers = []
configured = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {})
labels = {
"google": "Google",
"github": "GitHub",
}
for provider_id, label in labels.items():
app = configured.get(provider_id, {}).get("APP", {})
if app.get("client_id") and app.get("secret"):
providers.append({"id": provider_id, "label": label})
return providers
+1
View File
@@ -0,0 +1 @@
+72
View File
@@ -0,0 +1,72 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from apps.accounts.forms import SignUpForm
from apps.accounts.models import ClientProfile
class SignUpFormTest(TestCase):
def test_signup_creates_user_and_profile(self):
form = SignUpForm(
data={
"username": "client1",
"first_name": "Ada",
"last_name": "Lovelace",
"email": "ada@example.com",
"company": "Analytical Engines",
"phone": "+1 555 0100",
"timezone": "UTC",
"password1": "Str0ngPass!word",
"password2": "Str0ngPass!word",
}
)
self.assertTrue(form.is_valid(), form.errors)
user = form.save()
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):
def test_signup_page_renders(self):
response = self.client.get(reverse("accounts:signup"))
self.assertEqual(response.status_code, 200)
def test_signup_creates_account_and_logs_in(self):
response = self.client.post(
reverse("accounts:signup"),
data={
"username": "newclient",
"first_name": "Grace",
"last_name": "Hopper",
"email": "grace@example.com",
"company": "",
"phone": "",
"timezone": "UTC",
"password1": "Str0ngPass!word",
"password2": "Str0ngPass!word",
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("projects:dashboard"))
self.assertTrue(User.objects.filter(username="newclient").exists())
def test_login_required_for_profile(self):
response = self.client.get(reverse("accounts:profile"))
self.assertEqual(response.status_code, 302)
self.assertIn(reverse("accounts:login"), response.url)
def test_logout_requires_post_and_clears_session(self):
self.client.login(username="newclient", password="Str0ngPass!word")
response = self.client.get(reverse("accounts:logout"))
self.assertEqual(response.status_code, 405)
response = self.client.post(reverse("accounts:logout"))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("pages:home"))
response = self.client.get(reverse("projects:dashboard"))
self.assertEqual(response.status_code, 302)
self.assertIn(reverse("accounts:login"), response.url)
+99
View File
@@ -0,0 +1,99 @@
from unittest.mock import MagicMock
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from django.urls import reverse
from apps.accounts.adapters import TecvicoSocialAccountAdapter
from apps.accounts.social import enabled_social_providers
class SocialAuthHelpersTest(TestCase):
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"google": {"APP": {"client_id": "g-id", "secret": "g-secret"}},
"github": {"APP": {"client_id": "", "secret": ""}},
}
)
def test_enabled_social_providers_filters_missing_credentials(self):
providers = enabled_social_providers()
self.assertEqual(len(providers), 1)
self.assertEqual(providers[0]["id"], "google")
class SocialAuthViewsTest(TestCase):
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"google": {"APP": {"client_id": "g-id", "secret": "g-secret"}},
"github": {"APP": {"client_id": "gh-id", "secret": "gh-secret"}},
}
)
def test_login_page_renders_social_buttons(self):
response = self.client.get(reverse("accounts:login"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Continue with")
self.assertContains(response, "Google")
self.assertContains(response, "GitHub")
self.assertContains(response, "/accounts/google/login/")
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"google": {"APP": {"client_id": "", "secret": ""}},
"github": {"APP": {"client_id": "", "secret": ""}},
}
)
def test_login_page_hides_social_buttons_without_credentials(self):
response = self.client.get(reverse("accounts:login"))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "Continue with")
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"google": {"APP": {"client_id": "g-id", "secret": "g-secret"}},
"github": {"APP": {"client_id": "gh-id", "secret": "gh-secret"}},
}
)
def test_signup_page_renders_social_buttons(self):
response = self.client.get(reverse("accounts:signup"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Google")
self.assertContains(response, "GitHub")
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"google": {"APP": {"client_id": "g-id", "secret": "g-secret"}},
"github": {"APP": {"client_id": "gh-id", "secret": "gh-secret"}},
}
)
def test_google_login_page_uses_portal_styles(self):
response = self.client.get(reverse("google_login"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Sign in with Google")
self.assertContains(response, "social-oauth-card")
self.assertContains(response, "navbar")
self.assertContains(response, "Continue to Google")
class TecvicoSocialAccountAdapterTest(TestCase):
def test_apply_profile_names_from_google(self):
user = User(username="oauth-user", email="oauth@example.com")
sociallogin = MagicMock()
sociallogin.account.provider = "google"
sociallogin.account.extra_data = {
"given_name": "OAuth",
"family_name": "User",
}
adapter = TecvicoSocialAccountAdapter()
adapter._apply_profile_names(user, sociallogin)
self.assertEqual(user.first_name, "OAuth")
self.assertEqual(user.last_name, "User")
def test_apply_profile_names_from_github(self):
user = User(username="gh-user", email="gh@example.com")
sociallogin = MagicMock()
sociallogin.account.provider = "github"
sociallogin.account.extra_data = {"name": "Grace Hopper"}
adapter = TecvicoSocialAccountAdapter()
adapter._apply_profile_names(user, sociallogin)
self.assertEqual(user.first_name, "Grace")
self.assertEqual(user.last_name, "Hopper")
+45
View File
@@ -0,0 +1,45 @@
from django.contrib.auth import views as auth_views
from django.urls import path
from . import views
app_name = "accounts"
urlpatterns = [
path("signup/", views.SignUpView.as_view(), name="signup"),
path("login/", views.ClientLoginView.as_view(), name="login"),
path("logout/", views.ClientLogoutView.as_view(), name="logout"),
path("profile/", views.ProfileView.as_view(), name="profile"),
path(
"password-reset/",
auth_views.PasswordResetView.as_view(
template_name="accounts/password_reset.html",
email_template_name="accounts/emails/password_reset_email.txt",
subject_template_name="accounts/emails/password_reset_subject.txt",
success_url="/accounts/password-reset/done/",
),
name="password_reset",
),
path(
"password-reset/done/",
auth_views.PasswordResetDoneView.as_view(
template_name="accounts/password_reset_done.html",
),
name="password_reset_done",
),
path(
"password-reset/<uidb64>/<token>/",
auth_views.PasswordResetConfirmView.as_view(
template_name="accounts/password_reset_confirm.html",
success_url="/accounts/password-reset/complete/",
),
name="password_reset_confirm",
),
path(
"password-reset/complete/",
auth_views.PasswordResetCompleteView.as_view(
template_name="accounts/password_reset_complete.html",
),
name="password_reset_complete",
),
]
+75
View File
@@ -0,0 +1,75 @@
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import reverse_lazy
from django.views.generic import CreateView, FormView
from .forms import ClientLoginForm, ClientProfileForm, SignUpForm
from .models import ClientProfile
class SignUpView(CreateView):
form_class = SignUpForm
template_name = "accounts/signup.html"
success_url = reverse_lazy("projects:dashboard")
def dispatch(self, request, *args, **kwargs):
if request.user.is_authenticated:
return self.redirect_authenticated()
return super().dispatch(request, *args, **kwargs)
def redirect_authenticated(self):
from django.shortcuts import redirect
return redirect("projects:dashboard")
def form_valid(self, form):
response = super().form_valid(form)
login(self.request, self.object, backend="django.contrib.auth.backends.ModelBackend")
messages.success(self.request, "Welcome! Your client account is ready.")
return response
class ClientLoginView(LoginView):
form_class = ClientLoginForm
template_name = "accounts/login.html"
redirect_authenticated_user = True
def get_success_url(self):
return self.request.GET.get("next") or reverse_lazy("projects:dashboard")
class ClientLogoutView(LogoutView):
next_page = reverse_lazy("pages:home")
http_method_names = ["post", "options"]
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
if request.method.lower() == "post":
messages.success(request, "You have been logged out.")
return response
class ProfileView(LoginRequiredMixin, FormView):
form_class = ClientProfileForm
template_name = "accounts/profile.html"
success_url = reverse_lazy("accounts:profile")
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["user"] = self.request.user
profile, _ = ClientProfile.objects.get_or_create(user=self.request.user)
kwargs["instance"] = profile
return kwargs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile, _ = ClientProfile.objects.get_or_create(user=self.request.user)
ctx["profile"] = profile
return ctx
def form_valid(self, form):
form.save()
messages.success(self.request, "Profile updated.")
return super().form_valid(form)