feat: admin panel enrichment
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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 ""
|
||||
@@ -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",)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-18 04:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_site_contact'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='sitebranding',
|
||||
name='border_color',
|
||||
field=models.CharField(default='#3051ff', help_text='Border color as hex (e.g. #c4b5fd).', max_length=7),
|
||||
),
|
||||
]
|
||||
@@ -16,6 +16,8 @@ RESERVED_PAGE_SLUGS = frozenset({
|
||||
"faq",
|
||||
"home",
|
||||
"products",
|
||||
"projects",
|
||||
"accounts",
|
||||
"static",
|
||||
"media",
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from datetime import date
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
@@ -41,6 +43,80 @@ class HomeViewTest(TestCase):
|
||||
self.assertIn("the Way in Development", content)
|
||||
self.assertIn('data-project-filter="all"', content)
|
||||
|
||||
def test_home_renders_published_project_briefs(self):
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from apps.accounts.models import ClientProfile
|
||||
from apps.pages.models import HomepageSection
|
||||
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")
|
||||
HomepageSection.objects.create(
|
||||
section_type=HomepageSection.TYPE_PROJECTS,
|
||||
title="Showcase",
|
||||
order=1,
|
||||
is_active=True,
|
||||
)
|
||||
ProjectBrief.objects.create(
|
||||
client=user,
|
||||
title="Private title",
|
||||
category=ProjectBrief.CATEGORY_WEB,
|
||||
description="Full description for the project.",
|
||||
budget_range=ProjectBrief.BUDGET_5K_15K,
|
||||
show_on_website=True,
|
||||
public_title="Public showcase title",
|
||||
public_summary="A curated summary for the website.",
|
||||
public_project_status="new",
|
||||
public_tags="Django, Portal",
|
||||
show_category_on_website=False,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
self.assertContains(response, "Public showcase title")
|
||||
self.assertContains(response, "A curated summary for the website.")
|
||||
self.assertContains(response, "Django")
|
||||
self.assertNotContains(response, "Private title")
|
||||
|
||||
def test_home_respects_per_field_website_visibility(self):
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from apps.accounts.models import ClientProfile
|
||||
from apps.pages.models import HomepageSection
|
||||
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")
|
||||
HomepageSection.objects.create(
|
||||
section_type=HomepageSection.TYPE_PROJECTS,
|
||||
title="Showcase",
|
||||
order=1,
|
||||
is_active=True,
|
||||
)
|
||||
ProjectBrief.objects.create(
|
||||
client=user,
|
||||
title="Visible project",
|
||||
category=ProjectBrief.CATEGORY_DATA,
|
||||
description="Secret internal description.",
|
||||
budget_range=ProjectBrief.BUDGET_15K_50K,
|
||||
desired_deadline=date(2026, 12, 1),
|
||||
reference_links="https://example.com/spec",
|
||||
show_on_website=True,
|
||||
public_summary="Public summary.",
|
||||
show_budget_on_website=True,
|
||||
show_deadline_on_website=True,
|
||||
show_references_on_website=True,
|
||||
show_description_on_website=False,
|
||||
show_category_on_website=False,
|
||||
)
|
||||
|
||||
response = self.client.get(reverse("pages:home"))
|
||||
content = response.content.decode()
|
||||
self.assertIn("$15,000", content)
|
||||
self.assertIn("Dec 1, 2026", content)
|
||||
self.assertIn("https://example.com/spec", content)
|
||||
self.assertNotIn("Secret internal description.", content)
|
||||
|
||||
|
||||
class AboutViewTest(TestCase):
|
||||
def test_about_returns_200(self):
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.views import View
|
||||
from django.views.generic import DetailView, ListView, TemplateView
|
||||
|
||||
from apps.products.models import MainProduct, SubProduct
|
||||
from apps.projects.models import ProjectBrief
|
||||
|
||||
from .forms import ContactForm
|
||||
from .models import (
|
||||
@@ -50,6 +51,10 @@ class HomeView(TemplateView):
|
||||
.order_by("homepage_order", "order")
|
||||
)
|
||||
ctx["homepage_products_catalog"] = _homepage_products_catalog_queryset()
|
||||
ctx["website_project_briefs"] = (
|
||||
ProjectBrief.objects.filter(show_on_website=True)
|
||||
.order_by("-submitted_at")
|
||||
)
|
||||
return ctx
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
|
||||
from .models import ProjectBrief, ProjectBriefAttachment, ProjectInternalNote
|
||||
|
||||
|
||||
class ProjectBriefAttachmentInline(admin.TabularInline):
|
||||
model = ProjectBriefAttachment
|
||||
extra = 0
|
||||
readonly_fields = ("original_filename", "file", "uploaded_at")
|
||||
can_delete = True
|
||||
|
||||
|
||||
class ProjectInternalNoteInline(admin.TabularInline):
|
||||
model = ProjectInternalNote
|
||||
extra = 1
|
||||
fields = ("author", "note", "created_at")
|
||||
readonly_fields = ("created_at",)
|
||||
|
||||
|
||||
@admin.register(ProjectBrief)
|
||||
class ProjectBriefAdmin(admin.ModelAdmin):
|
||||
list_display = (
|
||||
"title",
|
||||
"client",
|
||||
"category",
|
||||
"status_badge",
|
||||
"locked",
|
||||
"show_on_website",
|
||||
"budget_range",
|
||||
"submitted_at",
|
||||
)
|
||||
list_filter = (
|
||||
"status",
|
||||
"category",
|
||||
"budget_range",
|
||||
"locked",
|
||||
"show_on_website",
|
||||
"submitted_at",
|
||||
)
|
||||
search_fields = (
|
||||
"title",
|
||||
"description",
|
||||
"client__username",
|
||||
"client__email",
|
||||
"client__first_name",
|
||||
"client__last_name",
|
||||
)
|
||||
readonly_fields = ("client", "submitted_at", "updated_at")
|
||||
fieldsets = (
|
||||
(
|
||||
"Brief",
|
||||
{
|
||||
"fields": (
|
||||
"client",
|
||||
"title",
|
||||
"category",
|
||||
"description",
|
||||
"budget_range",
|
||||
"desired_deadline",
|
||||
"reference_links",
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Workflow",
|
||||
{
|
||||
"fields": (
|
||||
"status",
|
||||
"quote_text",
|
||||
"website_notes",
|
||||
"submitted_at",
|
||||
"updated_at",
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Client editing locks",
|
||||
{
|
||||
"description": (
|
||||
"Lock individual fields so the client cannot change them while "
|
||||
"the brief is still editable. 'Lock entire project' blocks all "
|
||||
"client edits regardless of status."
|
||||
),
|
||||
"fields": (
|
||||
"locked",
|
||||
"lock_title",
|
||||
"lock_category",
|
||||
"lock_description",
|
||||
"lock_budget_range",
|
||||
"lock_desired_deadline",
|
||||
"lock_reference_links",
|
||||
"lock_attachments",
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Website showcase",
|
||||
{
|
||||
"description": (
|
||||
"Control whether this project appears on the public website and "
|
||||
"which fields are visible on the website card."
|
||||
),
|
||||
"fields": (
|
||||
"show_on_website",
|
||||
"public_title",
|
||||
"public_summary",
|
||||
"public_tags",
|
||||
"public_image",
|
||||
"public_project_status",
|
||||
"public_url",
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"Website field visibility",
|
||||
{
|
||||
"description": (
|
||||
"Each field can be independently shown or hidden on the website card."
|
||||
),
|
||||
"fields": (
|
||||
"show_title_on_website",
|
||||
"show_category_on_website",
|
||||
"show_description_on_website",
|
||||
"show_budget_on_website",
|
||||
"show_deadline_on_website",
|
||||
"show_references_on_website",
|
||||
"show_attachments_on_website",
|
||||
),
|
||||
"classes": ("collapse",),
|
||||
},
|
||||
),
|
||||
)
|
||||
inlines = [ProjectBriefAttachmentInline, ProjectInternalNoteInline]
|
||||
date_hierarchy = "submitted_at"
|
||||
|
||||
def save_formset(self, request, form, formset, change):
|
||||
instances = formset.save(commit=False)
|
||||
for instance in instances:
|
||||
if isinstance(instance, ProjectInternalNote) and not instance.author_id:
|
||||
instance.author = request.user
|
||||
instance.save()
|
||||
formset.save_m2m()
|
||||
for obj in formset.deleted_objects:
|
||||
obj.delete()
|
||||
|
||||
@admin.display(description="Status", boolean=False)
|
||||
def status_badge(self, obj):
|
||||
colors = {
|
||||
ProjectBrief.STATUS_SUBMITTED: "#3b82f6",
|
||||
ProjectBrief.STATUS_UNDER_REVIEW: "#8b5cf6",
|
||||
ProjectBrief.STATUS_QUOTED: "#f59e0b",
|
||||
ProjectBrief.STATUS_ACCEPTED: "#10b981",
|
||||
ProjectBrief.STATUS_DECLINED: "#ef4444",
|
||||
ProjectBrief.STATUS_IN_PROGRESS: "#06b6d4",
|
||||
ProjectBrief.STATUS_DELIVERED: "#22c55e",
|
||||
ProjectBrief.STATUS_CLOSED: "#6b7280",
|
||||
}
|
||||
color = colors.get(obj.status, "#6b7280")
|
||||
return format_html(
|
||||
'<span style="padding:2px 8px;border-radius:999px;background:{};color:#fff;font-size:12px;">{}</span>',
|
||||
color,
|
||||
obj.get_status_display(),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(ProjectInternalNote)
|
||||
class ProjectInternalNoteAdmin(admin.ModelAdmin):
|
||||
list_display = ("brief", "author", "created_at")
|
||||
search_fields = ("brief__title", "note", "author__username")
|
||||
readonly_fields = ("created_at",)
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ProjectsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.projects"
|
||||
label = "projects"
|
||||
|
||||
def ready(self):
|
||||
from . import signals # noqa: F401
|
||||
@@ -0,0 +1,114 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from apps.pages.contact_uploads import validate_contact_attachments
|
||||
|
||||
from .models import ProjectBrief
|
||||
|
||||
WIZARD_STEPS = [
|
||||
{
|
||||
"id": "basics",
|
||||
"title": "Project basics",
|
||||
"subtitle": "Give your project a name and category.",
|
||||
"fields": ["title", "category"],
|
||||
},
|
||||
{
|
||||
"id": "scope",
|
||||
"title": "Description & goals",
|
||||
"subtitle": "Describe the project scope, context, and the outcomes you expect.",
|
||||
"fields": ["description"],
|
||||
},
|
||||
{
|
||||
"id": "timeline",
|
||||
"title": "Budget & timeline",
|
||||
"subtitle": "Share your budget range and target deadline.",
|
||||
"fields": ["budget_range", "desired_deadline"],
|
||||
},
|
||||
{
|
||||
"id": "references",
|
||||
"title": "References & files",
|
||||
"subtitle": "Add links and supporting files, then review your brief.",
|
||||
"fields": ["reference_links", "attachments"],
|
||||
},
|
||||
]
|
||||
|
||||
REQUIRED_FIELDS = ("title",)
|
||||
|
||||
|
||||
class ProjectBriefForm(forms.ModelForm):
|
||||
attachments = forms.Field(required=False)
|
||||
|
||||
class Meta:
|
||||
model = ProjectBrief
|
||||
fields = [
|
||||
"title",
|
||||
"category",
|
||||
"description",
|
||||
"budget_range",
|
||||
"desired_deadline",
|
||||
"reference_links",
|
||||
]
|
||||
widgets = {
|
||||
"title": forms.TextInput(attrs={"placeholder": "Project title"}),
|
||||
"category": forms.Select(),
|
||||
"description": forms.Textarea(
|
||||
attrs={
|
||||
"placeholder": "Describe the project scope, context, and goals",
|
||||
"rows": 7,
|
||||
}
|
||||
),
|
||||
"budget_range": forms.Select(),
|
||||
"desired_deadline": forms.DateInput(attrs={"type": "date"}),
|
||||
"reference_links": forms.Textarea(
|
||||
attrs={
|
||||
"placeholder": "Links to docs, mockups, repos (one per line)",
|
||||
"rows": 3,
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self, *args, file_list=None, **kwargs):
|
||||
self.file_list = file_list
|
||||
super().__init__(*args, **kwargs)
|
||||
optional_choice_fields = {
|
||||
"category": "Select a category (optional)",
|
||||
"budget_range": "Select a budget range (optional)",
|
||||
}
|
||||
for field_name, empty_label in optional_choice_fields.items():
|
||||
field = self.fields[field_name]
|
||||
field.required = False
|
||||
field.choices = [("", empty_label)] + list(field.choices)
|
||||
for field_name in self.fields:
|
||||
self.fields[field_name].required = field_name in REQUIRED_FIELDS
|
||||
for field in self.fields.values():
|
||||
if isinstance(
|
||||
field.widget,
|
||||
(forms.TextInput, forms.Textarea, forms.Select, forms.DateInput),
|
||||
):
|
||||
field.widget.attrs.setdefault("class", "form-control")
|
||||
if self.instance and self.instance.pk:
|
||||
for field_name in self.fields:
|
||||
if self.instance.is_field_locked(field_name):
|
||||
self.fields[field_name].disabled = True
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
try:
|
||||
cleaned_data["attachments"] = validate_contact_attachments(self.file_list)
|
||||
except ValidationError as exc:
|
||||
self.add_error("attachments", exc)
|
||||
cleaned_data["attachments"] = []
|
||||
return cleaned_data
|
||||
|
||||
def first_error_step(self):
|
||||
for index, step in enumerate(WIZARD_STEPS, start=1):
|
||||
for field_name in step["fields"]:
|
||||
if field_name in self.errors:
|
||||
return index
|
||||
return 1
|
||||
|
||||
def step_for_field(self, field_name):
|
||||
for index, step in enumerate(WIZARD_STEPS, start=1):
|
||||
if field_name in step["fields"]:
|
||||
return index
|
||||
return 1
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
from datetime import date
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db import transaction
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
|
||||
from apps.accounts.models import ClientProfile
|
||||
from apps.projects.models import ProjectBrief, ProjectInternalNote
|
||||
from apps.projects.signals import capture_previous_status, handle_brief_notifications
|
||||
|
||||
|
||||
CLIENTS = [
|
||||
{
|
||||
"username": "client1",
|
||||
"email": "client1@example.com",
|
||||
"first_name": "Alex",
|
||||
"last_name": "Morgan",
|
||||
"password": "ClientDemo2026!",
|
||||
"company": "Northside Imaging Lab",
|
||||
"phone": "+1 604 555 0101",
|
||||
"timezone": "America/Vancouver",
|
||||
},
|
||||
{
|
||||
"username": "sarah.research",
|
||||
"email": "sarah.research@example.com",
|
||||
"first_name": "Sarah",
|
||||
"last_name": "Chen",
|
||||
"password": "ClientDemo2026!",
|
||||
"company": "Pacific Oncology Research",
|
||||
"phone": "+1 604 555 0102",
|
||||
"timezone": "America/Los_Angeles",
|
||||
},
|
||||
{
|
||||
"username": "devteam",
|
||||
"email": "devteam@example.com",
|
||||
"first_name": "Jordan",
|
||||
"last_name": "Lee",
|
||||
"password": "ClientDemo2026!",
|
||||
"company": "BioFlow Analytics",
|
||||
"phone": "+44 20 7946 0103",
|
||||
"timezone": "Europe/London",
|
||||
},
|
||||
]
|
||||
|
||||
PROJECTS = [
|
||||
{
|
||||
"username": "client1",
|
||||
"title": "Radiomics pipeline audit",
|
||||
"category": ProjectBrief.CATEGORY_MEDICAL_IMAGING,
|
||||
"description": (
|
||||
"We need Tecvico to review our current radiomics extraction workflow "
|
||||
"and align it with IBSI 2.0 standards across PET/CT datasets.\n\n"
|
||||
"Goals: Standardize preprocessing, document gaps, and deliver a "
|
||||
"remediation plan."
|
||||
),
|
||||
"budget_range": ProjectBrief.BUDGET_5K_15K,
|
||||
"desired_deadline": date(2026, 9, 30),
|
||||
"reference_links": "https://example.com/radiomics-spec\nhttps://example.com/sample-dataset",
|
||||
"status": ProjectBrief.STATUS_UNDER_REVIEW,
|
||||
"quote_text": "",
|
||||
"internal_notes": ["Client has 120-case retrospective cohort ready."],
|
||||
},
|
||||
{
|
||||
"username": "client1",
|
||||
"title": "Clinical trial dashboard",
|
||||
"category": ProjectBrief.CATEGORY_WEB,
|
||||
"description": (
|
||||
"Build a secure web dashboard for monitoring recruitment, imaging QC, "
|
||||
"and milestone completion across two active trials.\n\n"
|
||||
"Goals: Role-based views for coordinators and PI weekly status exports."
|
||||
),
|
||||
"budget_range": ProjectBrief.BUDGET_15K_50K,
|
||||
"desired_deadline": date(2026, 11, 15),
|
||||
"reference_links": "https://example.com/wireframes",
|
||||
"status": ProjectBrief.STATUS_QUOTED,
|
||||
"quote_text": (
|
||||
"Phase 1 discovery and UX: $8,500\n"
|
||||
"Phase 2 build and QA: $22,000\n"
|
||||
"Estimated timeline: 10 weeks."
|
||||
),
|
||||
"internal_notes": ["Needs SSO discussion on kickoff call."],
|
||||
},
|
||||
{
|
||||
"username": "sarah.research",
|
||||
"title": "Workflow automation for cohort exports",
|
||||
"category": ProjectBrief.CATEGORY_WORKFLOW,
|
||||
"description": (
|
||||
"Automate export of segmented lesions and feature tables from Tecvico "
|
||||
"into our downstream R analysis environment.\n\n"
|
||||
"Goals: One-click export with audit log and reproducible config files."
|
||||
),
|
||||
"budget_range": ProjectBrief.BUDGET_5K_15K,
|
||||
"desired_deadline": date(2026, 8, 1),
|
||||
"reference_links": "",
|
||||
"status": ProjectBrief.STATUS_IN_PROGRESS,
|
||||
"quote_text": "Fixed fee $11,500 — delivery in 6 weeks with two revision rounds.",
|
||||
"internal_notes": ["Milestone 1 approved.", "Waiting on sample DICOM push."],
|
||||
},
|
||||
{
|
||||
"username": "sarah.research",
|
||||
"title": "Publication figure package",
|
||||
"category": ProjectBrief.CATEGORY_RESEARCH,
|
||||
"description": (
|
||||
"Prepare publication-ready figures and methods appendix for a radiomics "
|
||||
"paper.\n\n"
|
||||
"Goals: Journal-compliant figures, caption draft, and reproducibility "
|
||||
"checklist."
|
||||
),
|
||||
"budget_range": ProjectBrief.BUDGET_UNDER_5K,
|
||||
"desired_deadline": date(2026, 7, 20),
|
||||
"reference_links": "https://example.com/journal-guidelines",
|
||||
"status": ProjectBrief.STATUS_DELIVERED,
|
||||
"quote_text": "",
|
||||
"internal_notes": ["Delivered v2 figures on June 12."],
|
||||
},
|
||||
{
|
||||
"username": "devteam",
|
||||
"title": "Multi-site data harmonization study",
|
||||
"category": ProjectBrief.CATEGORY_DATA,
|
||||
"description": (
|
||||
"Assess batch effects across three hospital sites and propose "
|
||||
"harmonization strategy before model training.\n\n"
|
||||
"Goals: Site effect report, recommended normalization approach, and "
|
||||
"pilot notebook."
|
||||
),
|
||||
"budget_range": ProjectBrief.BUDGET_50K_PLUS,
|
||||
"desired_deadline": date(2027, 1, 31),
|
||||
"reference_links": "https://example.com/data-dictionary",
|
||||
"status": ProjectBrief.STATUS_SUBMITTED,
|
||||
"quote_text": "",
|
||||
"internal_notes": [],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Seed demo client accounts and project briefs for the managed projects portal."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--flush",
|
||||
action="store_true",
|
||||
help="Delete seeded demo users and their project briefs before re-seeding.",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
post_save.disconnect(handle_brief_notifications, sender=ProjectBrief)
|
||||
pre_save.disconnect(capture_previous_status, sender=ProjectBrief)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if options["flush"]:
|
||||
self._flush()
|
||||
users = self._seed_clients()
|
||||
count = self._seed_projects(users)
|
||||
finally:
|
||||
post_save.connect(handle_brief_notifications, sender=ProjectBrief)
|
||||
pre_save.connect(capture_previous_status, sender=ProjectBrief)
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f"Seeded {len(users)} clients and {count} project briefs."))
|
||||
self.stdout.write("Demo login: client1 / ClientDemo2026!")
|
||||
|
||||
def _flush(self):
|
||||
User = get_user_model()
|
||||
usernames = [client["username"] for client in CLIENTS]
|
||||
users = User.objects.filter(username__in=usernames)
|
||||
ProjectBrief.objects.filter(client__in=users).delete()
|
||||
ClientProfile.objects.filter(user__in=users).delete()
|
||||
deleted, _ = users.delete()
|
||||
self.stdout.write(f" Removed {deleted} demo user records")
|
||||
|
||||
def _seed_clients(self):
|
||||
User = get_user_model()
|
||||
users = {}
|
||||
for client in CLIENTS:
|
||||
user, created = User.objects.get_or_create(
|
||||
username=client["username"],
|
||||
defaults={
|
||||
"email": client["email"],
|
||||
"first_name": client["first_name"],
|
||||
"last_name": client["last_name"],
|
||||
},
|
||||
)
|
||||
user.email = client["email"]
|
||||
user.first_name = client["first_name"]
|
||||
user.last_name = client["last_name"]
|
||||
user.set_password(client["password"])
|
||||
user.save()
|
||||
|
||||
ClientProfile.objects.update_or_create(
|
||||
user=user,
|
||||
defaults={
|
||||
"company": client["company"],
|
||||
"phone": client["phone"],
|
||||
"timezone": client["timezone"],
|
||||
},
|
||||
)
|
||||
action = "Created" if created else "Updated"
|
||||
self.stdout.write(f" {action} client: {user.username}")
|
||||
users[user.username] = user
|
||||
return users
|
||||
|
||||
def _seed_projects(self, users):
|
||||
admin = get_user_model().objects.filter(is_superuser=True).first()
|
||||
count = 0
|
||||
for project in PROJECTS:
|
||||
client = users[project["username"]]
|
||||
brief, created = ProjectBrief.objects.update_or_create(
|
||||
client=client,
|
||||
title=project["title"],
|
||||
defaults={
|
||||
"category": project["category"],
|
||||
"description": project["description"],
|
||||
"budget_range": project["budget_range"],
|
||||
"desired_deadline": project["desired_deadline"],
|
||||
"reference_links": project["reference_links"],
|
||||
"status": project["status"],
|
||||
"quote_text": project["quote_text"],
|
||||
},
|
||||
)
|
||||
if created:
|
||||
count += 1
|
||||
action = "Created"
|
||||
else:
|
||||
action = "Updated"
|
||||
self.stdout.write(f" {action} project: {brief.title}")
|
||||
|
||||
if project["internal_notes"] and admin:
|
||||
for note in project["internal_notes"]:
|
||||
ProjectInternalNote.objects.get_or_create(
|
||||
brief=brief,
|
||||
note=note,
|
||||
defaults={"author": admin},
|
||||
)
|
||||
return count
|
||||
@@ -0,0 +1,71 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-09 07:14
|
||||
|
||||
import apps.projects.uploads
|
||||
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='ProjectBrief',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=300)),
|
||||
('category', models.CharField(choices=[('web', 'Web development'), ('data', 'Data & analytics'), ('research', 'Research'), ('medical_imaging', 'Medical imaging'), ('workflow', 'Workflow automation'), ('other', 'Other')], max_length=32)),
|
||||
('description', models.TextField()),
|
||||
('goals', models.TextField()),
|
||||
('budget_range', models.CharField(choices=[('under_5k', 'Under $5,000'), ('5k_15k', '$5,000 – $15,000'), ('15k_50k', '$15,000 – $50,000'), ('50k_plus', '$50,000+'), ('not_sure', 'Not sure yet')], max_length=32)),
|
||||
('desired_deadline', models.DateField(blank=True, null=True)),
|
||||
('reference_links', models.TextField(blank=True)),
|
||||
('status', models.CharField(choices=[('submitted', 'Submitted'), ('under_review', 'Under review'), ('quoted', 'Quoted'), ('accepted', 'Accepted'), ('declined', 'Declined'), ('in_progress', 'In progress'), ('delivered', 'Delivered'), ('closed', 'Closed')], default='submitted', max_length=32)),
|
||||
('quote_text', models.TextField(blank=True)),
|
||||
('submitted_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('client', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_briefs', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Project Brief',
|
||||
'verbose_name_plural': 'Project Briefs',
|
||||
'ordering': ['-submitted_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProjectBriefAttachment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(storage=apps.projects.uploads.ProjectAttachmentStorage(), upload_to=apps.projects.uploads.project_attachment_upload_to)),
|
||||
('original_filename', models.CharField(max_length=255)),
|
||||
('uploaded_at', models.DateTimeField(auto_now_add=True)),
|
||||
('brief', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attachments', to='projects.projectbrief')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Project Attachment',
|
||||
'verbose_name_plural': 'Project Attachments',
|
||||
'ordering': ['uploaded_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProjectInternalNote',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('note', models.TextField()),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('author', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='project_internal_notes', to=settings.AUTH_USER_MODEL)),
|
||||
('brief', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='internal_notes', to='projects.projectbrief')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Internal Note',
|
||||
'verbose_name_plural': 'Internal Notes',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-14 04:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='public_image',
|
||||
field=models.ImageField(blank=True, help_text='Image for the website project card.', null=True, upload_to='projects/showcase/'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='public_project_status',
|
||||
field=models.CharField(blank=True, choices=[('', '—'), ('new', 'New'), ('ongoing', 'Ongoing'), ('done', 'Done')], help_text='Filter category for the website showcase.', max_length=10),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='public_summary',
|
||||
field=models.TextField(blank=True, help_text='Short summary for the website project card.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='public_tags',
|
||||
field=models.CharField(blank=True, help_text='Comma-separated tags for the website card.', max_length=300),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='public_title',
|
||||
field=models.CharField(blank=True, help_text='Optional showcase title. Defaults to the brief title.', max_length=300),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='public_url',
|
||||
field=models.CharField(blank=True, help_text='Optional link for the website project card.', max_length=300),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='show_category_on_website',
|
||||
field=models.BooleanField(default=True, help_text='Show category as a tag on the website card.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='show_description_on_website',
|
||||
field=models.BooleanField(default=True, help_text='Include the brief description on the website card when no summary is set.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='show_goals_on_website',
|
||||
field=models.BooleanField(default=False, help_text='Include goals on the website card.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='show_on_website',
|
||||
field=models.BooleanField(default=False, help_text='Display this project in the public website showcase.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='website_notes',
|
||||
field=models.TextField(blank=True, help_text='Notes from Tecvico shown to the client in the portal.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,96 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-18 04:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0002_website_showcase_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='projectbrief',
|
||||
name='goals',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='projectbrief',
|
||||
name='show_goals_on_website',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='lock_attachments',
|
||||
field=models.BooleanField(default=False, help_text='Lock attachments for the client.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='lock_budget_range',
|
||||
field=models.BooleanField(default=False, help_text='Lock budget range for the client.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='lock_category',
|
||||
field=models.BooleanField(default=False, help_text='Lock category for the client.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='lock_description',
|
||||
field=models.BooleanField(default=False, help_text='Lock description for the client.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='lock_desired_deadline',
|
||||
field=models.BooleanField(default=False, help_text='Lock desired deadline for the client.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='lock_reference_links',
|
||||
field=models.BooleanField(default=False, help_text='Lock reference links for the client.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='lock_title',
|
||||
field=models.BooleanField(default=False, help_text='Lock title for the client.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='locked',
|
||||
field=models.BooleanField(default=False, help_text='Lock the whole project so the client cannot edit it at all.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='show_attachments_on_website',
|
||||
field=models.BooleanField(default=False, help_text='Show attachment filenames on the website card.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='show_budget_on_website',
|
||||
field=models.BooleanField(default=False, help_text='Show the budget range on the website card.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='show_deadline_on_website',
|
||||
field=models.BooleanField(default=False, help_text='Show the desired deadline on the website card.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='show_references_on_website',
|
||||
field=models.BooleanField(default=False, help_text='Show reference links on the website card.'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='projectbrief',
|
||||
name='show_title_on_website',
|
||||
field=models.BooleanField(default=True, help_text='Show the title on the website card.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='projectbrief',
|
||||
name='description',
|
||||
field=models.TextField(help_text='Project scope, context, and goals combined.'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='projectbrief',
|
||||
name='show_description_on_website',
|
||||
field=models.BooleanField(default=True, help_text='Use the description as the website card summary when no summary is set.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.15 on 2026-07-19 04:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('projects', '0003_merge_goals_and_lock_visibility'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='projectbrief',
|
||||
name='budget_range',
|
||||
field=models.CharField(blank=True, choices=[('under_5k', 'Under $5,000'), ('5k_15k', '$5,000 – $15,000'), ('15k_50k', '$15,000 – $50,000'), ('50k_plus', '$50,000+'), ('not_sure', 'Not sure yet')], default='', max_length=32),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='projectbrief',
|
||||
name='category',
|
||||
field=models.CharField(blank=True, choices=[('web', 'Web development'), ('data', 'Data & analytics'), ('research', 'Research'), ('medical_imaging', 'Medical imaging'), ('workflow', 'Workflow automation'), ('other', 'Other')], default='', max_length=32),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='projectbrief',
|
||||
name='description',
|
||||
field=models.TextField(blank=True, default='', help_text='Project scope, context, and goals combined.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,329 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from .uploads import project_attachment_storage, project_attachment_upload_to
|
||||
|
||||
|
||||
class ProjectBrief(models.Model):
|
||||
STATUS_SUBMITTED = "submitted"
|
||||
STATUS_UNDER_REVIEW = "under_review"
|
||||
STATUS_QUOTED = "quoted"
|
||||
STATUS_ACCEPTED = "accepted"
|
||||
STATUS_DECLINED = "declined"
|
||||
STATUS_IN_PROGRESS = "in_progress"
|
||||
STATUS_DELIVERED = "delivered"
|
||||
STATUS_CLOSED = "closed"
|
||||
|
||||
STATUS_CHOICES = [
|
||||
(STATUS_SUBMITTED, "Submitted"),
|
||||
(STATUS_UNDER_REVIEW, "Under review"),
|
||||
(STATUS_QUOTED, "Quoted"),
|
||||
(STATUS_ACCEPTED, "Accepted"),
|
||||
(STATUS_DECLINED, "Declined"),
|
||||
(STATUS_IN_PROGRESS, "In progress"),
|
||||
(STATUS_DELIVERED, "Delivered"),
|
||||
(STATUS_CLOSED, "Closed"),
|
||||
]
|
||||
|
||||
CATEGORY_WEB = "web"
|
||||
CATEGORY_DATA = "data"
|
||||
CATEGORY_RESEARCH = "research"
|
||||
CATEGORY_MEDICAL_IMAGING = "medical_imaging"
|
||||
CATEGORY_WORKFLOW = "workflow"
|
||||
CATEGORY_OTHER = "other"
|
||||
|
||||
CATEGORY_CHOICES = [
|
||||
(CATEGORY_WEB, "Web development"),
|
||||
(CATEGORY_DATA, "Data & analytics"),
|
||||
(CATEGORY_RESEARCH, "Research"),
|
||||
(CATEGORY_MEDICAL_IMAGING, "Medical imaging"),
|
||||
(CATEGORY_WORKFLOW, "Workflow automation"),
|
||||
(CATEGORY_OTHER, "Other"),
|
||||
]
|
||||
|
||||
BUDGET_UNDER_5K = "under_5k"
|
||||
BUDGET_5K_15K = "5k_15k"
|
||||
BUDGET_15K_50K = "15k_50k"
|
||||
BUDGET_50K_PLUS = "50k_plus"
|
||||
BUDGET_NOT_SURE = "not_sure"
|
||||
|
||||
BUDGET_CHOICES = [
|
||||
(BUDGET_UNDER_5K, "Under $5,000"),
|
||||
(BUDGET_5K_15K, "$5,000 – $15,000"),
|
||||
(BUDGET_15K_50K, "$15,000 – $50,000"),
|
||||
(BUDGET_50K_PLUS, "$50,000+"),
|
||||
(BUDGET_NOT_SURE, "Not sure yet"),
|
||||
]
|
||||
|
||||
LOCK_FIELD_MAP = {
|
||||
"title": "lock_title",
|
||||
"category": "lock_category",
|
||||
"description": "lock_description",
|
||||
"budget_range": "lock_budget_range",
|
||||
"desired_deadline": "lock_desired_deadline",
|
||||
"reference_links": "lock_reference_links",
|
||||
"attachments": "lock_attachments",
|
||||
}
|
||||
|
||||
client = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="project_briefs",
|
||||
)
|
||||
title = models.CharField(max_length=300)
|
||||
category = models.CharField(max_length=32, choices=CATEGORY_CHOICES, blank=True, default="")
|
||||
description = models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="Project scope, context, and goals combined.",
|
||||
)
|
||||
budget_range = models.CharField(max_length=32, choices=BUDGET_CHOICES, blank=True, default="")
|
||||
desired_deadline = models.DateField(blank=True, null=True)
|
||||
reference_links = models.TextField(blank=True)
|
||||
status = models.CharField(
|
||||
max_length=32,
|
||||
choices=STATUS_CHOICES,
|
||||
default=STATUS_SUBMITTED,
|
||||
)
|
||||
quote_text = models.TextField(blank=True)
|
||||
website_notes = models.TextField(
|
||||
blank=True,
|
||||
help_text="Notes from Tecvico shown to the client in the portal.",
|
||||
)
|
||||
locked = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Lock the whole project so the client cannot edit it at all.",
|
||||
)
|
||||
lock_title = models.BooleanField(default=False, help_text="Lock title for the client.")
|
||||
lock_category = models.BooleanField(default=False, help_text="Lock category for the client.")
|
||||
lock_description = models.BooleanField(
|
||||
default=False, help_text="Lock description for the client."
|
||||
)
|
||||
lock_budget_range = models.BooleanField(
|
||||
default=False, help_text="Lock budget range for the client."
|
||||
)
|
||||
lock_desired_deadline = models.BooleanField(
|
||||
default=False, help_text="Lock desired deadline for the client."
|
||||
)
|
||||
lock_reference_links = models.BooleanField(
|
||||
default=False, help_text="Lock reference links for the client."
|
||||
)
|
||||
lock_attachments = models.BooleanField(
|
||||
default=False, help_text="Lock attachments for the client."
|
||||
)
|
||||
show_on_website = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Display this project in the public website showcase.",
|
||||
)
|
||||
show_title_on_website = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Show the title on the website card.",
|
||||
)
|
||||
show_category_on_website = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Show category as a tag on the website card.",
|
||||
)
|
||||
show_description_on_website = models.BooleanField(
|
||||
default=True,
|
||||
help_text="Use the description as the website card summary when no summary is set.",
|
||||
)
|
||||
show_budget_on_website = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Show the budget range on the website card.",
|
||||
)
|
||||
show_deadline_on_website = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Show the desired deadline on the website card.",
|
||||
)
|
||||
show_references_on_website = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Show reference links on the website card.",
|
||||
)
|
||||
show_attachments_on_website = models.BooleanField(
|
||||
default=False,
|
||||
help_text="Show attachment filenames on the website card.",
|
||||
)
|
||||
public_title = models.CharField(
|
||||
max_length=300,
|
||||
blank=True,
|
||||
help_text="Optional showcase title. Defaults to the brief title.",
|
||||
)
|
||||
public_summary = models.TextField(
|
||||
blank=True,
|
||||
help_text="Short summary for the website project card.",
|
||||
)
|
||||
public_tags = models.CharField(
|
||||
max_length=300,
|
||||
blank=True,
|
||||
help_text="Comma-separated tags for the website card.",
|
||||
)
|
||||
public_image = models.ImageField(
|
||||
upload_to="projects/showcase/",
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Image for the website project card.",
|
||||
)
|
||||
public_project_status = models.CharField(
|
||||
max_length=10,
|
||||
choices=[
|
||||
("", "—"),
|
||||
("new", "New"),
|
||||
("ongoing", "Ongoing"),
|
||||
("done", "Done"),
|
||||
],
|
||||
blank=True,
|
||||
help_text="Filter category for the website showcase.",
|
||||
)
|
||||
public_url = models.CharField(
|
||||
max_length=300,
|
||||
blank=True,
|
||||
help_text="Optional link for the website project card.",
|
||||
)
|
||||
submitted_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-submitted_at"]
|
||||
verbose_name = "Project Brief"
|
||||
verbose_name_plural = "Project Briefs"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.title} ({self.get_status_display()})"
|
||||
|
||||
@property
|
||||
def is_editable_by_client(self):
|
||||
return self.status == self.STATUS_SUBMITTED and not self.locked
|
||||
|
||||
def is_field_locked(self, field_name):
|
||||
if self.locked:
|
||||
return True
|
||||
attr = self.LOCK_FIELD_MAP.get(field_name)
|
||||
if attr is None:
|
||||
return False
|
||||
return getattr(self, attr)
|
||||
|
||||
@property
|
||||
def attachments_locked(self):
|
||||
return self.is_field_locked("attachments")
|
||||
|
||||
@property
|
||||
def shows_quote_to_client(self):
|
||||
return self.status in {
|
||||
self.STATUS_QUOTED,
|
||||
self.STATUS_ACCEPTED,
|
||||
self.STATUS_DECLINED,
|
||||
self.STATUS_IN_PROGRESS,
|
||||
self.STATUS_DELIVERED,
|
||||
self.STATUS_CLOSED,
|
||||
} and bool(self.quote_text.strip())
|
||||
|
||||
@property
|
||||
def has_website_notes(self):
|
||||
return bool(self.website_notes.strip())
|
||||
|
||||
@property
|
||||
def public_display_title(self):
|
||||
if not self.show_title_on_website:
|
||||
return ""
|
||||
return self.public_title.strip() or self.title
|
||||
|
||||
@property
|
||||
def public_tag_list(self):
|
||||
tags = []
|
||||
if self.show_category_on_website and self.category:
|
||||
tags.append(self.get_category_display())
|
||||
if self.public_tags.strip():
|
||||
tags.extend(
|
||||
tag.strip()
|
||||
for tag in self.public_tags.split(",")
|
||||
if tag.strip()
|
||||
)
|
||||
return tags
|
||||
|
||||
@property
|
||||
def public_display_summary(self):
|
||||
if self.public_summary.strip():
|
||||
return self.public_summary.strip()
|
||||
if self.show_description_on_website and self.description.strip():
|
||||
return self.description.strip()
|
||||
return ""
|
||||
|
||||
@property
|
||||
def public_budget_display(self):
|
||||
if not self.show_budget_on_website or not self.budget_range:
|
||||
return ""
|
||||
return self.get_budget_range_display()
|
||||
|
||||
@property
|
||||
def public_deadline_display(self):
|
||||
if not self.show_deadline_on_website or not self.desired_deadline:
|
||||
return ""
|
||||
return self.desired_deadline.strftime("%b %-d, %Y")
|
||||
|
||||
@property
|
||||
def public_reference_link_list(self):
|
||||
if not self.show_references_on_website or not self.reference_links.strip():
|
||||
return []
|
||||
return [
|
||||
link.strip()
|
||||
for link in self.reference_links.splitlines()
|
||||
if link.strip()
|
||||
]
|
||||
|
||||
@property
|
||||
def public_attachment_filename_list(self):
|
||||
if not self.show_attachments_on_website:
|
||||
return []
|
||||
return list(self.attachments.values_list("original_filename", flat=True))
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("projects:detail", kwargs={"pk": self.pk})
|
||||
|
||||
|
||||
class ProjectBriefAttachment(models.Model):
|
||||
brief = models.ForeignKey(
|
||||
ProjectBrief,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="attachments",
|
||||
)
|
||||
file = models.FileField(
|
||||
upload_to=project_attachment_upload_to,
|
||||
storage=project_attachment_storage,
|
||||
)
|
||||
original_filename = models.CharField(max_length=255)
|
||||
uploaded_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["uploaded_at"]
|
||||
verbose_name = "Project Attachment"
|
||||
verbose_name_plural = "Project Attachments"
|
||||
|
||||
def __str__(self):
|
||||
return self.original_filename
|
||||
|
||||
|
||||
class ProjectInternalNote(models.Model):
|
||||
brief = models.ForeignKey(
|
||||
ProjectBrief,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="internal_notes",
|
||||
)
|
||||
author = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="project_internal_notes",
|
||||
)
|
||||
note = models.TextField()
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
verbose_name = "Internal Note"
|
||||
verbose_name_plural = "Internal Notes"
|
||||
|
||||
def __str__(self):
|
||||
preview = self.note[:60]
|
||||
return f"{self.brief.title} — {preview}"
|
||||
@@ -0,0 +1,58 @@
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from .models import ProjectBrief
|
||||
|
||||
|
||||
def _notify_recipients():
|
||||
configured = getattr(settings, "MANAGED_PROJECTS_NOTIFY_EMAILS", None)
|
||||
if configured:
|
||||
return [email.strip() for email in configured.split(",") if email.strip()]
|
||||
User = get_user_model()
|
||||
return list(
|
||||
User.objects.filter(is_superuser=True, is_active=True)
|
||||
.exclude(email="")
|
||||
.values_list("email", flat=True)
|
||||
)
|
||||
|
||||
|
||||
def send_new_brief_admin_email(brief: ProjectBrief):
|
||||
recipients = _notify_recipients()
|
||||
if not recipients:
|
||||
return
|
||||
subject = f"New project brief: {brief.title}"
|
||||
body = render_to_string(
|
||||
"projects/emails/new_brief_admin.txt",
|
||||
{"brief": brief},
|
||||
)
|
||||
send_mail(
|
||||
subject,
|
||||
body,
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
recipients,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
|
||||
def send_brief_status_email(brief: ProjectBrief, previous_status: str):
|
||||
client_email = brief.client.email
|
||||
if not client_email:
|
||||
return
|
||||
status_labels = dict(ProjectBrief.STATUS_CHOICES)
|
||||
subject = f"Project update: {brief.title}"
|
||||
body = render_to_string(
|
||||
"projects/emails/status_change_client.txt",
|
||||
{
|
||||
"brief": brief,
|
||||
"previous_status": status_labels.get(previous_status, previous_status),
|
||||
},
|
||||
)
|
||||
send_mail(
|
||||
subject,
|
||||
body,
|
||||
settings.DEFAULT_FROM_EMAIL,
|
||||
[client_email],
|
||||
fail_silently=False,
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
from django.db.models.signals import post_save, pre_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from .models import ProjectBrief
|
||||
from .notifications import send_brief_status_email, send_new_brief_admin_email
|
||||
|
||||
|
||||
@receiver(pre_save, sender=ProjectBrief)
|
||||
def capture_previous_status(sender, instance, **kwargs):
|
||||
if instance.pk:
|
||||
try:
|
||||
instance._previous_status = ProjectBrief.objects.values_list(
|
||||
"status", flat=True
|
||||
).get(pk=instance.pk)
|
||||
except ProjectBrief.DoesNotExist:
|
||||
instance._previous_status = None
|
||||
else:
|
||||
instance._previous_status = None
|
||||
|
||||
|
||||
@receiver(post_save, sender=ProjectBrief)
|
||||
def handle_brief_notifications(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
send_new_brief_admin_email(instance)
|
||||
return
|
||||
previous_status = getattr(instance, "_previous_status", None)
|
||||
if previous_status and previous_status != instance.status:
|
||||
send_brief_status_email(instance, previous_status)
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.core import mail
|
||||
from django.test import TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.accounts.models import ClientProfile
|
||||
from apps.projects.models import ProjectBrief, ProjectInternalNote
|
||||
|
||||
|
||||
class ProjectPortalTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="client",
|
||||
email="client@example.com",
|
||||
password="Str0ngPass!word",
|
||||
first_name="Client",
|
||||
last_name="User",
|
||||
)
|
||||
ClientProfile.objects.create(user=self.user, timezone="UTC")
|
||||
self.other = User.objects.create_user(
|
||||
username="other",
|
||||
email="other@example.com",
|
||||
password="Str0ngPass!word",
|
||||
)
|
||||
ClientProfile.objects.create(user=self.other, timezone="UTC")
|
||||
|
||||
def _brief_payload(self):
|
||||
return {
|
||||
"title": "Radiomics pipeline",
|
||||
"category": ProjectBrief.CATEGORY_MEDICAL_IMAGING,
|
||||
"description": "Need a standardized radiomics workflow.",
|
||||
"budget_range": ProjectBrief.BUDGET_5K_15K,
|
||||
"desired_deadline": "2026-12-31",
|
||||
"reference_links": "https://example.com/spec",
|
||||
}
|
||||
|
||||
def _create_brief(self, **overrides):
|
||||
defaults = dict(
|
||||
client=self.user,
|
||||
title="Brief",
|
||||
category=ProjectBrief.CATEGORY_WEB,
|
||||
description="d",
|
||||
budget_range=ProjectBrief.BUDGET_UNDER_5K,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return ProjectBrief.objects.create(**defaults)
|
||||
|
||||
def test_dashboard_requires_login(self):
|
||||
response = self.client.get(reverse("projects:dashboard"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_create_brief_sends_admin_email(self):
|
||||
self.client.login(username="client", password="Str0ngPass!word")
|
||||
with override_settings(MANAGED_PROJECTS_NOTIFY_EMAILS="ops@tecvico.com"):
|
||||
response = self.client.post(reverse("projects:create"), data=self._brief_payload())
|
||||
self.assertEqual(response.status_code, 302)
|
||||
brief = ProjectBrief.objects.get(title="Radiomics pipeline")
|
||||
self.assertEqual(brief.client, self.user)
|
||||
self.assertEqual(brief.status, ProjectBrief.STATUS_SUBMITTED)
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertIn("Radiomics pipeline", mail.outbox[0].subject)
|
||||
self.assertIn("ops@tecvico.com", mail.outbox[0].to)
|
||||
|
||||
def test_create_brief_with_title_only(self):
|
||||
self.client.login(username="client", password="Str0ngPass!word")
|
||||
response = self.client.post(
|
||||
reverse("projects:create"),
|
||||
data={"title": "Minimal brief"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
brief = ProjectBrief.objects.get(title="Minimal brief")
|
||||
self.assertEqual(brief.description, "")
|
||||
self.assertEqual(brief.category, "")
|
||||
self.assertEqual(brief.budget_range, "")
|
||||
|
||||
def test_create_brief_requires_title(self):
|
||||
self.client.login(username="client", password="Str0ngPass!word")
|
||||
response = self.client.post(reverse("projects:create"), data={"title": ""})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "This field is required")
|
||||
self.assertEqual(ProjectBrief.objects.count(), 0)
|
||||
|
||||
def test_create_renders_submit_only_on_last_step(self):
|
||||
self.client.login(username="client", password="Str0ngPass!word")
|
||||
response = self.client.get(reverse("projects:create"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'data-wizard-submit hidden')
|
||||
self.assertContains(response, 'data-wizard-next')
|
||||
self.assertNotContains(response, 'data-wizard-submit">{{ submit_label }}</button>')
|
||||
|
||||
def test_dashboard_lists_only_own_briefs(self):
|
||||
self._create_brief(title="Mine")
|
||||
self._create_brief(client=self.other, title="Not mine")
|
||||
self.client.login(username="client", password="Str0ngPass!word")
|
||||
response = self.client.get(reverse("projects:dashboard"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
titles = [brief.title for brief in response.context["briefs"]]
|
||||
self.assertEqual(titles, ["Mine"])
|
||||
|
||||
def test_edit_allowed_only_when_submitted(self):
|
||||
brief = self._create_brief(
|
||||
title="Editable",
|
||||
category=ProjectBrief.CATEGORY_DATA,
|
||||
budget_range=ProjectBrief.BUDGET_NOT_SURE,
|
||||
status=ProjectBrief.STATUS_SUBMITTED,
|
||||
)
|
||||
self.client.login(username="client", password="Str0ngPass!word")
|
||||
response = self.client.get(reverse("projects:edit", kwargs={"pk": brief.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
brief.status = ProjectBrief.STATUS_UNDER_REVIEW
|
||||
brief.save()
|
||||
response = self.client.get(reverse("projects:edit", kwargs={"pk": brief.pk}))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_locked_project_blocks_edit(self):
|
||||
brief = self._create_brief(
|
||||
title="Locked",
|
||||
status=ProjectBrief.STATUS_SUBMITTED,
|
||||
locked=True,
|
||||
)
|
||||
self.client.login(username="client", password="Str0ngPass!word")
|
||||
response = self.client.get(reverse("projects:edit", kwargs={"pk": brief.pk}))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_locked_field_is_disabled_on_edit_form(self):
|
||||
brief = self._create_brief(
|
||||
title="Lock field",
|
||||
status=ProjectBrief.STATUS_SUBMITTED,
|
||||
lock_title=True,
|
||||
)
|
||||
self.client.login(username="client", password="Str0ngPass!word")
|
||||
response = self.client.get(reverse("projects:edit", kwargs={"pk": brief.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, 'name="title"')
|
||||
self.assertContains(response, "disabled")
|
||||
|
||||
def test_locked_field_value_is_preserved_on_submit(self):
|
||||
brief = self._create_brief(
|
||||
title="Original title",
|
||||
status=ProjectBrief.STATUS_SUBMITTED,
|
||||
lock_title=True,
|
||||
)
|
||||
self.client.login(username="client", password="Str0ngPass!word")
|
||||
self.client.post(
|
||||
reverse("projects:edit", kwargs={"pk": brief.pk}),
|
||||
data={
|
||||
"title": "Hacked title",
|
||||
"category": brief.category,
|
||||
"description": brief.description,
|
||||
"budget_range": brief.budget_range,
|
||||
"desired_deadline": "",
|
||||
"reference_links": "",
|
||||
},
|
||||
)
|
||||
brief.refresh_from_db()
|
||||
self.assertEqual(brief.title, "Original title")
|
||||
|
||||
def test_status_change_emails_client(self):
|
||||
brief = self._create_brief(
|
||||
title="Notify me",
|
||||
category=ProjectBrief.CATEGORY_RESEARCH,
|
||||
budget_range=ProjectBrief.BUDGET_15K_50K,
|
||||
status=ProjectBrief.STATUS_SUBMITTED,
|
||||
)
|
||||
mail.outbox.clear()
|
||||
brief.status = ProjectBrief.STATUS_UNDER_REVIEW
|
||||
brief.save()
|
||||
self.assertEqual(len(mail.outbox), 1)
|
||||
self.assertIn("client@example.com", mail.outbox[0].to)
|
||||
|
||||
def test_internal_notes_not_on_detail_page(self):
|
||||
brief = self._create_brief(
|
||||
title="Secret notes",
|
||||
category=ProjectBrief.CATEGORY_OTHER,
|
||||
)
|
||||
ProjectInternalNote.objects.create(brief=brief, note="Internal only", author=self.other)
|
||||
self.client.login(username="client", password="Str0ngPass!word")
|
||||
response = self.client.get(reverse("projects:detail", kwargs={"pk": brief.pk}))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn(b"Internal only", response.content)
|
||||
|
||||
def test_website_notes_visible_on_detail_page(self):
|
||||
brief = self._create_brief(
|
||||
title="Public notes",
|
||||
category=ProjectBrief.CATEGORY_WEB,
|
||||
website_notes="Your kickoff call is scheduled for next week.",
|
||||
)
|
||||
self.client.login(username="client", password="Str0ngPass!word")
|
||||
response = self.client.get(reverse("projects:detail", kwargs={"pk": brief.pk}))
|
||||
self.assertContains(response, "Notes from Tecvico")
|
||||
self.assertContains(response, "kickoff call is scheduled")
|
||||
|
||||
def test_create_renders_wizard_steps(self):
|
||||
self.client.login(username="client", password="Str0ngPass!word")
|
||||
response = self.client.get(reverse("projects:create"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertContains(response, "data-brief-wizard")
|
||||
self.assertContains(response, "Project basics")
|
||||
self.assertContains(response, "References & files")
|
||||
|
||||
def test_quote_visible_when_quoted(self):
|
||||
brief = self._create_brief(
|
||||
title="Quoted project",
|
||||
category=ProjectBrief.CATEGORY_WORKFLOW,
|
||||
budget_range=ProjectBrief.BUDGET_50K_PLUS,
|
||||
status=ProjectBrief.STATUS_QUOTED,
|
||||
quote_text="We can deliver in 8 weeks for $45,000.",
|
||||
)
|
||||
self.client.login(username="client", password="Str0ngPass!word")
|
||||
response = self.client.get(reverse("projects:detail", kwargs={"pk": brief.pk}))
|
||||
self.assertContains(response, "We can deliver in 8 weeks")
|
||||
@@ -0,0 +1,21 @@
|
||||
import os
|
||||
import uuid
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import FileSystemStorage
|
||||
|
||||
|
||||
class ProjectAttachmentStorage(FileSystemStorage):
|
||||
def __init__(self):
|
||||
super().__init__(location=settings.PROJECT_UPLOAD_ROOT)
|
||||
|
||||
|
||||
project_attachment_storage = ProjectAttachmentStorage()
|
||||
|
||||
|
||||
def project_attachment_upload_to(instance, _filename):
|
||||
ext = os.path.splitext(instance.original_filename)[1].lower()
|
||||
if not ext:
|
||||
ext = ".bin"
|
||||
subdir = str(instance.brief_id) if instance.brief_id else "pending"
|
||||
return f"{subdir}/{uuid.uuid4().hex}{ext}"
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "projects"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.DashboardView.as_view(), name="dashboard"),
|
||||
path("new/", views.ProjectBriefCreateView.as_view(), name="create"),
|
||||
path("<int:pk>/", views.ProjectBriefDetailView.as_view(), name="detail"),
|
||||
path("<int:pk>/edit/", views.ProjectBriefUpdateView.as_view(), name="edit"),
|
||||
]
|
||||
@@ -0,0 +1,127 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
from django.views.generic import CreateView, DetailView, ListView, UpdateView
|
||||
|
||||
from .forms import ProjectBriefForm, WIZARD_STEPS
|
||||
from .models import ProjectBrief, ProjectBriefAttachment
|
||||
|
||||
|
||||
class ClientBriefMixin(LoginRequiredMixin):
|
||||
def get_queryset(self):
|
||||
return ProjectBrief.objects.filter(client=self.request.user)
|
||||
|
||||
|
||||
class DashboardView(ClientBriefMixin, ListView):
|
||||
model = ProjectBrief
|
||||
template_name = "projects/dashboard.html"
|
||||
context_object_name = "briefs"
|
||||
paginate_by = 10
|
||||
|
||||
|
||||
class ProjectBriefCreateView(LoginRequiredMixin, CreateView):
|
||||
model = ProjectBrief
|
||||
form_class = ProjectBriefForm
|
||||
template_name = "projects/brief_wizard.html"
|
||||
|
||||
def get_success_url(self):
|
||||
messages.success(self.request, "Project brief submitted. We'll review it soon.")
|
||||
return reverse("projects:detail", kwargs={"pk": self.object.pk})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["form_title"] = "Submit a Project Brief"
|
||||
ctx["submit_label"] = "Submit brief"
|
||||
ctx["wizard_steps"] = WIZARD_STEPS
|
||||
ctx["initial_step"] = self._initial_step()
|
||||
return ctx
|
||||
|
||||
def _initial_step(self):
|
||||
form = self.get_form()
|
||||
if form.is_bound and form.errors:
|
||||
return form.first_error_step()
|
||||
return 1
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
if self.request.method == "POST":
|
||||
kwargs["file_list"] = self.request.FILES.getlist("attachments")
|
||||
else:
|
||||
kwargs["file_list"] = None
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
self.object = form.save(commit=False)
|
||||
self.object.client = self.request.user
|
||||
self.object.save()
|
||||
self._save_attachments(form)
|
||||
return redirect(self.get_success_url())
|
||||
|
||||
def _save_attachments(self, form):
|
||||
for uploaded_file, original_name in form.cleaned_data.get("attachments", []):
|
||||
attachment = ProjectBriefAttachment(
|
||||
brief=self.object,
|
||||
original_filename=original_name,
|
||||
)
|
||||
attachment.file.save(original_name, uploaded_file, save=True)
|
||||
|
||||
|
||||
class ProjectBriefDetailView(ClientBriefMixin, DetailView):
|
||||
model = ProjectBrief
|
||||
template_name = "projects/brief_detail.html"
|
||||
context_object_name = "brief"
|
||||
|
||||
|
||||
class ProjectBriefUpdateView(ClientBriefMixin, UserPassesTestMixin, UpdateView):
|
||||
model = ProjectBrief
|
||||
form_class = ProjectBriefForm
|
||||
template_name = "projects/brief_wizard.html"
|
||||
|
||||
def test_func(self):
|
||||
brief = self.get_object()
|
||||
return brief.is_editable_by_client
|
||||
|
||||
def handle_no_permission(self):
|
||||
messages.error(self.request, "This brief can no longer be edited.")
|
||||
return redirect("projects:detail", pk=self.kwargs["pk"])
|
||||
|
||||
def get_success_url(self):
|
||||
messages.success(self.request, "Project brief updated.")
|
||||
return reverse("projects:detail", kwargs={"pk": self.object.pk})
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
ctx = super().get_context_data(**kwargs)
|
||||
ctx["form_title"] = "Edit Project Brief"
|
||||
ctx["submit_label"] = "Save changes"
|
||||
ctx["brief"] = self.object
|
||||
ctx["wizard_steps"] = WIZARD_STEPS
|
||||
ctx["initial_step"] = self._initial_step()
|
||||
return ctx
|
||||
|
||||
def _initial_step(self):
|
||||
form = self.get_form()
|
||||
if form.is_bound and form.errors:
|
||||
return form.first_error_step()
|
||||
return 1
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
if self.request.method == "POST":
|
||||
kwargs["file_list"] = self.request.FILES.getlist("attachments")
|
||||
else:
|
||||
kwargs["file_list"] = None
|
||||
return kwargs
|
||||
|
||||
def form_valid(self, form):
|
||||
response = super().form_valid(form)
|
||||
self._save_attachments(form)
|
||||
return response
|
||||
|
||||
def _save_attachments(self, form):
|
||||
for uploaded_file, original_name in form.cleaned_data.get("attachments", []):
|
||||
attachment = ProjectBriefAttachment(
|
||||
brief=self.object,
|
||||
original_filename=original_name,
|
||||
)
|
||||
attachment.file.save(original_name, uploaded_file, save=True)
|
||||
Reference in New Issue
Block a user