feat: admin panel enrichment
This commit is contained in:
@@ -15,3 +15,12 @@ SECURE_SSL_REDIRECT=False
|
||||
DJANGO_SUPERUSER_USERNAME=admin
|
||||
DJANGO_SUPERUSER_EMAIL=admin@yourdomain.com
|
||||
DJANGO_SUPERUSER_PASSWORD=bS7_U_uRTmivj7W-lCR5
|
||||
|
||||
DEFAULT_FROM_EMAIL=noreply@tecvico.com
|
||||
MANAGED_PROJECTS_NOTIFY_EMAILS=admin@yourdomain.com
|
||||
|
||||
SITE_DOMAIN=localhost:8000
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
|
||||
@@ -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)
|
||||
+68
-1
@@ -18,17 +18,28 @@ DJANGO_APPS = [
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.sites",
|
||||
"whitenoise.runserver_nostatic",
|
||||
"django.contrib.staticfiles",
|
||||
]
|
||||
|
||||
THIRD_PARTY_APPS = [
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
"allauth.socialaccount.providers.google",
|
||||
"allauth.socialaccount.providers.github",
|
||||
]
|
||||
|
||||
LOCAL_APPS = [
|
||||
"apps.core",
|
||||
"apps.products",
|
||||
"apps.pages",
|
||||
"apps.accounts",
|
||||
"apps.projects",
|
||||
]
|
||||
|
||||
INSTALLED_APPS = DJANGO_APPS + LOCAL_APPS
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
@@ -39,6 +50,7 @@ MIDDLEWARE = [
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"allauth.account.middleware.AccountMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "config.urls"
|
||||
@@ -57,6 +69,7 @@ TEMPLATES = [
|
||||
"apps.core.context_processors.site_branding",
|
||||
"apps.core.context_processors.site_contact",
|
||||
"apps.core.context_processors.navigation",
|
||||
"apps.accounts.context_processors.social_auth",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -103,6 +116,60 @@ CONTACT_UPLOAD_ROOT = BASE_DIR / "private_uploads" / "contact"
|
||||
CONTACT_ATTACHMENT_MAX_SIZE = 10 * 1024 * 1024
|
||||
CONTACT_ATTACHMENT_MAX_COUNT = 3
|
||||
|
||||
PROJECT_UPLOAD_ROOT = BASE_DIR / "private_uploads" / "projects"
|
||||
PROJECT_ATTACHMENT_MAX_SIZE = CONTACT_ATTACHMENT_MAX_SIZE
|
||||
PROJECT_ATTACHMENT_MAX_COUNT = CONTACT_ATTACHMENT_MAX_COUNT
|
||||
|
||||
LOGIN_URL = "/accounts/login/"
|
||||
LOGIN_REDIRECT_URL = "/projects/"
|
||||
LOGOUT_REDIRECT_URL = "/"
|
||||
|
||||
SITE_ID = 1
|
||||
SITE_DOMAIN = os.environ.get("SITE_DOMAIN", "localhost:8000")
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
"django.contrib.auth.backends.ModelBackend",
|
||||
"allauth.account.auth_backends.AuthenticationBackend",
|
||||
]
|
||||
|
||||
ACCOUNT_ADAPTER = "apps.accounts.adapters.TecvicoAccountAdapter"
|
||||
SOCIALACCOUNT_ADAPTER = "apps.accounts.adapters.TecvicoSocialAccountAdapter"
|
||||
ACCOUNT_EMAIL_VERIFICATION = "none"
|
||||
ACCOUNT_LOGIN_ON_GET = False
|
||||
ACCOUNT_LOGOUT_ON_GET = False
|
||||
SOCIALACCOUNT_AUTO_SIGNUP = True
|
||||
SOCIALACCOUNT_LOGIN_ON_GET = False
|
||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
|
||||
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
|
||||
|
||||
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")
|
||||
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "")
|
||||
GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", "")
|
||||
GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET", "")
|
||||
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
"google": {
|
||||
"SCOPE": ["profile", "email"],
|
||||
"AUTH_PARAMS": {"access_type": "online"},
|
||||
"OAUTH_PKCE_ENABLED": True,
|
||||
"APP": {
|
||||
"client_id": GOOGLE_CLIENT_ID,
|
||||
"secret": GOOGLE_CLIENT_SECRET,
|
||||
"key": "",
|
||||
},
|
||||
},
|
||||
"github": {
|
||||
"SCOPE": ["user:email"],
|
||||
"APP": {
|
||||
"client_id": GITHUB_CLIENT_ID,
|
||||
"secret": GITHUB_CLIENT_SECRET,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "noreply@tecvico.com")
|
||||
MANAGED_PROJECTS_NOTIFY_EMAILS = os.environ.get("MANAGED_PROJECTS_NOTIFY_EMAILS", "")
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
LOGGING = {
|
||||
|
||||
@@ -8,6 +8,8 @@ from .base import * # noqa: F401, F403, E402
|
||||
|
||||
DEBUG = True
|
||||
|
||||
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
|
||||
|
||||
SECRET_KEY = os.environ.get(
|
||||
"DJANGO_SECRET_KEY",
|
||||
"django-insecure-development-key-not-for-production-use",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .development import * # noqa: F401, F403
|
||||
|
||||
DATABASES["default"]["CONN_MAX_AGE"] = 0 # noqa: F405
|
||||
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
|
||||
|
||||
@@ -11,6 +11,9 @@ admin.site.index_title = "Welcome to Tecvico Administration"
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("accounts/", include("apps.accounts.urls", namespace="accounts")),
|
||||
path("accounts/", include("allauth.urls")),
|
||||
path("projects/", include("apps.projects.urls", namespace="projects")),
|
||||
path("products/", include("apps.products.urls", namespace="products")),
|
||||
path("", include("apps.pages.urls", namespace="pages")),
|
||||
]
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
# Managed Projects Roadmap — Simple → Advanced
|
||||
|
||||
Expand the existing Tecvico Django site so **clients define projects; Tecvico delivers them**. No freelancer marketplace — users submit briefs, your team quotes, builds, and ships.
|
||||
|
||||
---
|
||||
|
||||
## Is This Doable on the Current Stack?
|
||||
|
||||
**Yes — and simpler than a marketplace.** No two-sided matching, escrow to third parties, or dispute flows between strangers. You are the sole provider.
|
||||
|
||||
|
||||
| Already in place | Reuse for managed projects |
|
||||
| -------------------------------- | ------------------------------------------- |
|
||||
| Django + PostgreSQL + Docker | Core backend |
|
||||
| `apps.pages` + contact form | Evolve into project brief submission |
|
||||
| File uploads (`contact_uploads`) | Brief attachments, deliverables |
|
||||
| `apps.products` CMS patterns | Service catalog / package pages |
|
||||
| Admin panel | Intake queue, quotes, assignment, delivery |
|
||||
| Email + templates | Status updates, quote sent, milestone ready |
|
||||
|
||||
|
||||
**New apps to add:**
|
||||
|
||||
- `apps.accounts` — client login, org profiles (Plan 2+)
|
||||
- `apps.projects` — briefs, quotes, orders, milestones, deliverables
|
||||
- `apps.messaging` — client ↔ Tecvico threads per project
|
||||
- `apps.payments` — client invoicing via Stripe (Plan 2+)
|
||||
|
||||
**Internal-only (admin):** team assignment, internal notes, time tracking — no public freelancer profiles.
|
||||
|
||||
**Verdict:** Plan 1 is close to an enhanced contact flow. Plan 2–3 add quoting, payments, and a client portal — all standard Django.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Plan 1 — Simple (MVP) · 3–6 weeks
|
||||
|
||||
**Goal:** Clients submit project briefs online; Tecvico reviews and delivers off-platform billing if needed.
|
||||
|
||||
|
||||
| Milestone | Weeks | Deliverable |
|
||||
| ------------------ | ----- | ----------------------------------------------- |
|
||||
| M1 Client accounts | 1 | Sign up, login, password reset |
|
||||
| M2 Project briefs | 1–2 | Structured submission form + attachments |
|
||||
| M3 Status tracking | 2–3 | Client sees request status; email notifications |
|
||||
| M4 Admin intake | 3–4 | Review queue, status updates, internal notes |
|
||||
|
||||
|
||||
|
||||
|
||||
### Feature detail
|
||||
|
||||
**Accounts**
|
||||
|
||||
- Email/password signup (client role only)
|
||||
- Profile: name, company, phone, timezone
|
||||
- Dashboard: list of my project requests
|
||||
|
||||
**Project brief**
|
||||
|
||||
- Fields: title, category (web, data, research, etc.), description, goals, budget range, desired deadline, reference links
|
||||
- Attachments: specs, mockups, datasets (reuse upload patterns)
|
||||
- Status: `submitted` → `under_review` → `quoted` → `accepted` / `declined` → `in_progress` → `delivered` → `closed`
|
||||
- Client can edit brief while status is `submitted` only
|
||||
|
||||
**Notifications**
|
||||
|
||||
- Email to client on status change
|
||||
- Email to admin on new submission
|
||||
|
||||
**Admin**
|
||||
|
||||
- Intake list with filters (status, category, date)
|
||||
- View brief + files; add internal notes (not visible to client)
|
||||
- Manually set status and paste quote text (PDF/email off-platform OK)
|
||||
|
||||
**Out of scope:** online quotes, payments, messaging, milestones.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Plan 2 — Medium (Client Portal) · 1–2 months
|
||||
|
||||
**Goal:** Quote, pay, and deliver on-platform. Productized packages + custom projects.
|
||||
|
||||
|
||||
| Milestone | Weeks | Deliverable |
|
||||
| ------------- | ----- | ----------------------------------------------------- |
|
||||
| M1 Foundation | 1–2 | Plan 1 + email verify, service catalog pages |
|
||||
| M2 Quotes | 3–4 | Admin builds quote; client accepts/declines in portal |
|
||||
| M3 Payments | 5–6 | Stripe deposit + milestone invoices |
|
||||
| M4 Delivery | 7–8 | Milestones, deliverable uploads, revision rounds |
|
||||
| M5 Comms | 9–10 | Per-project messaging, client dashboard polish |
|
||||
|
||||
|
||||
|
||||
|
||||
### Feature detail
|
||||
|
||||
**Service catalog**
|
||||
|
||||
- CMS pages for fixed offerings (e.g. “Landing page”, “Radiomics pipeline audit”)
|
||||
- Package tiers: scope summary, starting price, typical timeline
|
||||
- “Start from template” pre-fills brief fields
|
||||
|
||||
**Quotes**
|
||||
|
||||
- Admin creates quote from brief: line items, total, timeline, revision count included
|
||||
- Client receives email + in-portal quote view
|
||||
- Accept → order created; decline → brief archived with reason
|
||||
|
||||
**Orders & milestones**
|
||||
|
||||
- Order states: `awaiting_deposit` → `active` → `milestone_review` → `revision` → `completed`
|
||||
- Milestones: name, due date, amount, description of deliverable
|
||||
- Tecvico uploads files per milestone; client approves or requests revision (capped rounds)
|
||||
- Change requests: client submits add-on brief → admin sends revised quote
|
||||
|
||||
**Payments (Stripe)**
|
||||
|
||||
- Deposit on accept (e.g. 30–50%); remainder split across milestones
|
||||
- Checkout Session per invoice; receipt PDF
|
||||
- Refund policy flags (admin-only)
|
||||
|
||||
**Messaging**
|
||||
|
||||
- Thread per project: client ↔ assigned PM
|
||||
- File attach in thread; email digest on new message
|
||||
|
||||
**Admin**
|
||||
|
||||
- Assign internal owner (staff user) per project
|
||||
- Pipeline board: new → quoted → active → blocked → done
|
||||
- Basic reports: open projects, revenue this month
|
||||
|
||||
**Out of scope:** e-sign contracts, org/team accounts, automated estimates, real-time chat.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Plan 3 — Advanced (Full Client Platform) · 2–3 months
|
||||
|
||||
**Goal:** Scalable delivery ops — enterprise clients, SLAs, automation, integrations.
|
||||
|
||||
|
||||
| Milestone | Months | Deliverable |
|
||||
| --------------- | ------ | --------------------------------------------- |
|
||||
| M1 Hardening | 1 | Plan 2 + 2FA, OAuth, org accounts |
|
||||
| M2 Sales flow | 1–2 | E-sign, ballpark estimator, kickoff booking |
|
||||
| M3 Delivery ops | 2–3 | SLA timers, internal time log, task checklist |
|
||||
| M4 Enterprise | 3–4 | Multi-user orgs, API, webhooks |
|
||||
| M5 Growth | 4–5 | Referrals, analytics, PWA, knowledge base |
|
||||
|
||||
|
||||
|
||||
|
||||
### Feature detail
|
||||
|
||||
**Auth & organizations**
|
||||
|
||||
- Google / LinkedIn OAuth; optional 2FA
|
||||
- Organization account: owner + members, shared project list, role (viewer / submitter / billing)
|
||||
- Billing contact separate from project contact
|
||||
|
||||
**Pre-sale**
|
||||
|
||||
- Ballpark estimator: category + scope sliders → indicative range (admin-configured rules)
|
||||
- Discovery call booking (Calendly embed or built-in slot picker)
|
||||
- Digital SOW + NDA (DocuSign hook or in-app accept with audit trail)
|
||||
|
||||
**Delivery workspace**
|
||||
|
||||
- Shared project hub: brief, quote, contract, timeline, files, messages
|
||||
- Visual timeline / milestone Gantt for client
|
||||
- SLA badges: response within X hours, milestone due alerts (Celery + email)
|
||||
- Internal: task checklist, time entries per staff, capacity view (admin)
|
||||
|
||||
**Revisions & scope**
|
||||
|
||||
- Structured revision requests (what to change, priority)
|
||||
- Scope creep flow: auto-flag when revision count exceeded → change order quote
|
||||
|
||||
**Payments+**
|
||||
|
||||
- Multi-currency quotes; recurring maintenance plans (Stripe Subscription)
|
||||
- Pro-forma + final invoice; export for accounting
|
||||
|
||||
**Enterprise & integrations**
|
||||
|
||||
- REST API: submit brief, poll status, download deliverables
|
||||
- Webhooks: `quote.sent`, `milestone.delivered`, `project.completed`
|
||||
- SSO option (SAML) for large clients — optional phase
|
||||
|
||||
**Knowledge & retention**
|
||||
|
||||
- Client-facing FAQ / “how we work” tied to project phase
|
||||
- Post-delivery satisfaction survey + testimonial request
|
||||
- Referral codes (credit on next project)
|
||||
|
||||
**Admin analytics**
|
||||
|
||||
- Funnel: brief → quote → accept → complete
|
||||
- Utilization, avg delivery time, revenue by service line
|
||||
|
||||
**Out of scope:** marketplace, freelancer payouts, native mobile apps.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Comparison
|
||||
|
||||
|
||||
| | Simple | Medium | Advanced |
|
||||
| ------------------------------- | ---------- | -------- | ----------------- |
|
||||
| Project brief submission | ✓ | ✓ | ✓ + templates |
|
||||
| Online quotes | — | ✓ | ✓ + estimator |
|
||||
| Stripe payments | — | ✓ | ✓ + subscriptions |
|
||||
| Milestones & deliverables | — | ✓ | ✓ + SLA |
|
||||
| Client ↔ Tecvico messaging | — | ✓ | ✓ + digests |
|
||||
| Service catalog / packages | — | ✓ | ✓ |
|
||||
| Org / team accounts | — | — | ✓ |
|
||||
| E-sign / contracts | — | — | ✓ |
|
||||
| API / webhooks | — | — | ✓ |
|
||||
| Internal ops (assign, time log) | notes only | pipeline | full |
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Managed vs Marketplace
|
||||
|
||||
|
||||
| | Managed (this doc) | Marketplace (`marketplace-roadmap.md`) |
|
||||
| ------------ | -------------------------- | -------------------------------------- |
|
||||
| Who delivers | Tecvico team | Freelancers / clients hire each other |
|
||||
| Payments | Client → Tecvico | Escrow, payouts, commissions |
|
||||
| Complexity | Lower | Higher (trust, disputes, matching) |
|
||||
| Best when | You sell your own services | You broker others' work |
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Recommended Path
|
||||
|
||||
1. **Plan 1** — ship brief + status portal; keep quoting via email if needed.
|
||||
2. **Plan 2** — add quotes and Stripe once you have repeatable delivery workflow.
|
||||
3. **Plan 3** — when project volume needs SLAs, org accounts, and ops dashboards.
|
||||
|
||||
*Timelines assume extending the current codebase; bespoke UI redesign adds ~20%.*
|
||||
@@ -0,0 +1,241 @@
|
||||
# Marketplace Roadmap — Simple → Advanced
|
||||
|
||||
Expand the existing Tecvico Django site into a freelance/project marketplace (Upwork / Fiverr style).
|
||||
|
||||
---
|
||||
|
||||
## Is This Doable on the Current Stack?
|
||||
|
||||
**Yes.** Django is a standard choice for marketplaces (e.g. early Instacart, Disqus patterns). Your repo already has the right foundation:
|
||||
|
||||
|
||||
| Already in place | Reuse for marketplace |
|
||||
| -------------------------------------------------- | --------------------------------------- |
|
||||
| Django + PostgreSQL + Docker | Core backend — no rewrite needed |
|
||||
| `apps.core` (branding, site settings) | Global config, fees, categories |
|
||||
| `apps.pages` (CMS, hero, sections) | Marketing pages + marketplace landing |
|
||||
| File uploads (`contact_uploads`, product releases) | Proposals, deliverables, gig media |
|
||||
| Admin panel | Moderation, disputes, user management |
|
||||
| Templates + static CSS/JS | Extend UI; add dashboards incrementally |
|
||||
|
||||
|
||||
**New apps to add** (keeps existing code intact):
|
||||
|
||||
- `apps.accounts` — profiles, roles, verification
|
||||
- `apps.marketplace` — projects, gigs, proposals, contracts
|
||||
- `apps.messaging` — threads (Plan 1: polling/refresh; Plan 3: Channels/WebSockets)
|
||||
- `apps.payments` — Stripe from Plan 2 onward
|
||||
|
||||
**What gets harder later (not blockers now):** real-time chat, escrow/payouts, dispute automation, high traffic (Redis, Celery, CDN). None require leaving Django.
|
||||
|
||||
**Verdict:** Plan 1–2 are a natural extension. Plan 3 adds services (Stripe Connect, WebSockets, background jobs) but stays on the same project.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Plan 1 — Simple (MVP) · 3–5 weeks
|
||||
|
||||
**Goal:** List projects, apply, message. Off-platform payment OK.
|
||||
|
||||
|
||||
| Milestone | Weeks | Deliverable |
|
||||
| ------------------ | ----- | ----------------------------------------------------------- |
|
||||
| M1 Auth & profiles | 1–2 | Registration, login, password reset, client/freelancer role |
|
||||
| M2 Listings | 3–4 | Project CRUD, categories, search/filters |
|
||||
| M3 Proposals | 5–6 | Apply, withdraw, client shortlist |
|
||||
| M4 Comms & admin | 7–8 | Messaging, emails, moderation |
|
||||
|
||||
|
||||
|
||||
|
||||
### Feature detail
|
||||
|
||||
**Accounts**
|
||||
|
||||
- Email/password signup; role pick (client, freelancer, or both)
|
||||
- Profile: display name, avatar, bio, location, hourly rate (optional), skills (tags), portfolio URLs
|
||||
- Public profile URL (`/u/username/`)
|
||||
|
||||
**Projects**
|
||||
|
||||
- Fields: title, rich description, category, budget type (fixed/hourly/range), amount, deadline, attachments
|
||||
- Status: draft → open → closed → archived
|
||||
- Client dashboard: my projects, applicant count, close/reopen
|
||||
|
||||
**Discovery**
|
||||
|
||||
- List page with filters: category, budget range, posted date
|
||||
- Full-text search on title + description
|
||||
- Sort: newest, budget high/low
|
||||
|
||||
**Proposals**
|
||||
|
||||
- Cover letter, bid amount, estimated delivery days
|
||||
- One proposal per freelancer per project; edit until client views
|
||||
- Client view: compare proposals, mark shortlisted/rejected
|
||||
|
||||
**Messaging**
|
||||
|
||||
- Thread per project between client and applicant (after apply or invite)
|
||||
- Attach files (reuse existing upload patterns)
|
||||
- Email on new message/proposal (Django email backend)
|
||||
|
||||
**Admin**
|
||||
|
||||
- Approve/suspend users and listings; view contact-style audit trail
|
||||
|
||||
**Out of scope:** payments, contracts, reviews, gigs.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Plan 2 — Medium (Transactional) · 2–3 months
|
||||
|
||||
**Goal:** Hire and pay on-platform. Gigs (Fiverr) + custom projects (Upwork).
|
||||
|
||||
|
||||
| Milestone | Weeks | Deliverable |
|
||||
| ------------- | ----- | ----------------------------------------------------------- |
|
||||
| M1 Foundation | 1–4 | Plan 1 + email verify, profile completeness, S3/local media |
|
||||
| M2 Gigs | 5–8 | Service listings with 3 tiers |
|
||||
| M3 Contracts | 9–12 | Award, milestones, deliverables |
|
||||
| M4 Payments | 13–16 | Stripe escrow, release, platform fee |
|
||||
| M5 Trust | 17–20 | Reviews, dashboards, manual disputes |
|
||||
|
||||
|
||||
|
||||
|
||||
### Feature detail
|
||||
|
||||
**Gigs (Fiverr-style)**
|
||||
|
||||
- Gig: title, gallery (images/video), category, FAQ, requirements form
|
||||
- Packages: Basic / Standard / Premium — price, delivery days, revision count, feature bullet list
|
||||
- Extras: add-ons (e.g. +$50 rush delivery)
|
||||
- Order flow: client picks package → answers requirements → pays → freelancer accepts
|
||||
|
||||
**Contracts (Upwork-style)**
|
||||
|
||||
- Client awards proposal → contract generated (scope, total, deadline)
|
||||
- Milestones: name, amount, due date; client funds each before work starts
|
||||
- Deliverable upload per milestone; client approve or request revision (limited rounds)
|
||||
- Order states: `pending_payment` → `active` → `delivered` → `revision` → `completed` / `cancelled`
|
||||
|
||||
**Payments (Stripe)**
|
||||
|
||||
- Checkout Session on award/order; funds held (platform balance or Stripe separate charges)
|
||||
- Release to freelancer on milestone approval minus commission (e.g. 10–20%)
|
||||
- Refund rules: cancelled before start; partial on dispute
|
||||
- PDF invoice/receipt per transaction
|
||||
|
||||
**Trust**
|
||||
|
||||
- Double-blind reviews after completion (1–5 stars + text)
|
||||
- Public rating on profile; aggregate on gig
|
||||
- Dispute form: reason, evidence files → admin resolves manually
|
||||
|
||||
**Dashboards**
|
||||
|
||||
- Client: spend, active orders, past hires
|
||||
- Freelancer: earnings, pending clearance, gig analytics (views, orders)
|
||||
|
||||
**Out of scope:** hourly time tracker, OAuth, multi-currency, automated disputes.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Plan 3 — Advanced (Full Platform) · 4–6 months
|
||||
|
||||
**Goal:** Production marketplace with discovery, compliance, and scale.
|
||||
|
||||
|
||||
| Milestone | Months | Deliverable |
|
||||
| ----------------- | ------ | ----------------------------------------- |
|
||||
| M1 Core hardening | 1–2 | Plan 2 + 2FA, OAuth, rate limits |
|
||||
| M2 Discovery | 3–4 | Search, recommendations, skill tests |
|
||||
| M3 Collaboration | 5–6 | Time tracking, workspace, real-time chat |
|
||||
| M4 Payments+ | 7–8 | Stripe Connect, multi-currency, KYC hooks |
|
||||
| M5 Trust & safety | 9–10 | Auto disputes, moderation, fraud signals |
|
||||
| M6 Growth | 11–12 | API, referrals, analytics, PWA |
|
||||
|
||||
|
||||
|
||||
|
||||
### Feature detail
|
||||
|
||||
**Auth & identity**
|
||||
|
||||
- Google / LinkedIn OAuth; TOTP 2FA
|
||||
- Identity verification hook (Stripe Identity or manual)
|
||||
- Verified badge on profile
|
||||
|
||||
**Discovery & matching**
|
||||
|
||||
- Elasticsearch/PostgreSQL full-text: skills, rate, location, availability
|
||||
- Saved searches + email alerts for new matching projects/gigs
|
||||
- Skill tests (quiz → badge on pass)
|
||||
- “Recommended for you” (rules-based first; ML optional later)
|
||||
|
||||
**Collaboration**
|
||||
|
||||
- Hourly contracts: weekly hour cap, manual or screenshot time log
|
||||
- Shared order workspace: brief, files, milestone checklist
|
||||
- Real-time chat (Django Channels + Redis): typing, read receipts, file drag-drop
|
||||
- Video intro on profile; calendar link for intro calls
|
||||
|
||||
**Payments & compliance**
|
||||
|
||||
- Stripe Connect Express: freelancer onboarding, automatic payouts
|
||||
- Multi-currency display; platform fee per region
|
||||
- Tax form collection hook (W-9 / W-8BEN — integrate when legally required)
|
||||
- Chargebacks and refund automation with audit log
|
||||
|
||||
**Trust & safety**
|
||||
|
||||
- Dispute pipeline: open → evidence window → mediator → verdict → auto payout split
|
||||
- Content moderation queue (profiles, gigs, messages flagged by users or keywords)
|
||||
- Fraud heuristics: duplicate accounts, velocity limits, payment anomalies
|
||||
|
||||
**Growth & ops**
|
||||
|
||||
- Freelancer subscriptions: featured gig, lower commission tier
|
||||
- Referral codes (credit for referrer/referee)
|
||||
- Public REST API + webhooks (`order.completed`, `payment.released`)
|
||||
- Admin analytics: GMV, take rate, conversion funnel, dispute rate, cohort retention
|
||||
- SEO pages for categories and top gigs; structured data (JSON-LD)
|
||||
|
||||
**Out of scope:** native iOS/Android (PWA first).
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Comparison
|
||||
|
||||
|
||||
| | Simple | Medium | Advanced |
|
||||
| -------------------- | ------- | ------------- | ------------------------ |
|
||||
| Projects + proposals | ✓ | ✓ | ✓ |
|
||||
| Gigs / packages | — | ✓ | ✓ |
|
||||
| Messaging | refresh | refresh | real-time |
|
||||
| Stripe / escrow | — | ✓ | Connect + multi-currency |
|
||||
| Milestones + hourly | — | milestones | both |
|
||||
| Reviews | — | ✓ | ✓ + badges + tests |
|
||||
| Disputes | — | manual | automated |
|
||||
| API / analytics | — | basic reports | full |
|
||||
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
## Recommended Path
|
||||
|
||||
1. **Plan 1 on this repo** — add `apps.accounts` + `apps.marketplace`; keep marketing site live.
|
||||
2. **Plan 2 when volume justifies escrow** — biggest lift is payments + order state machine.
|
||||
3. **Plan 3 when GMV supports ops** — disputes, compliance, and real-time infra need people, not just code.
|
||||
|
||||
*Timelines assume extending the current codebase; full UI redesign adds ~20%.*
|
||||
@@ -5,3 +5,7 @@ whitenoise[brotli]>=6.7.0
|
||||
Pillow>=10.4.0
|
||||
python-dotenv>=1.0.1
|
||||
markdown>=3.6
|
||||
django-allauth>=65.0.0
|
||||
requests>=2.32.0
|
||||
PyJWT>=2.9.0
|
||||
cryptography>=43.0.0
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
.brief-wizard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.wizard-progress-track {
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: rgba(148, 163, 184, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.wizard-progress-fill {
|
||||
height: 100%;
|
||||
width: 0;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, var(--accent-blue), #5b7cff);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.wizard-stepper {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 0.75rem;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.wizard-stepper-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.wizard-stepper-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(148, 163, 184, 0.35);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
background: #fff;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.wizard-stepper-label {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.wizard-stepper-item.is-active .wizard-stepper-number,
|
||||
.wizard-stepper-item.is-complete .wizard-stepper-number {
|
||||
border-color: var(--accent-blue);
|
||||
background: rgba(48, 81, 255, 0.1);
|
||||
color: var(--accent-blue);
|
||||
}
|
||||
|
||||
.wizard-stepper-item.is-active {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.wizard-stepper-item.is-complete .wizard-stepper-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wizard-panel-header {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.wizard-panel-eyebrow {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--accent-blue);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.wizard-panel-title {
|
||||
font-size: 1.35rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.wizard-panel-subtitle {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.wizard-actions .btn-primary,
|
||||
.wizard-actions .btn-ghost {
|
||||
min-width: 7.5rem;
|
||||
}
|
||||
|
||||
.wizard-actions .btn-primary {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.brief-wizard [data-wizard-submit][hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.wizard-review {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1.25rem;
|
||||
border-radius: var(--radius-md);
|
||||
background: rgba(48, 81, 255, 0.04);
|
||||
border: 1px solid rgba(48, 81, 255, 0.12);
|
||||
}
|
||||
|
||||
.wizard-review-title {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.wizard-review-list {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.wizard-review-list dt {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.wizard-review-list dd {
|
||||
margin: 0.2rem 0 0;
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wizard-stepper {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.wizard-actions {
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.wizard-actions .btn-primary,
|
||||
.wizard-actions .btn-ghost {
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
@@ -394,6 +394,23 @@ h1, h2, h3, h4, h5, h6 {
|
||||
padding: 0.5rem 1.25rem;
|
||||
}
|
||||
|
||||
.nav-logout-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.nav-link-button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.nav-link-button:hover,
|
||||
.nav-link-button:focus-visible {
|
||||
color: var(--accent-blue);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-arrow {
|
||||
transition: transform var(--navbar-expand-duration) var(--navbar-expand-ease);
|
||||
}
|
||||
@@ -936,6 +953,39 @@ h1, h2, h3, h4, h5, h6 {
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.project-card-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.project-card-links {
|
||||
list-style: none;
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.project-card-links a {
|
||||
font-size: 0.8rem;
|
||||
color: var(--accent-blue);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.project-card-attachments {
|
||||
list-style: none;
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Experience / Benefits
|
||||
============================================================ */
|
||||
@@ -3838,3 +3888,385 @@ a.citation-count-badge:hover {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.container--narrow {
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.portal-card {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.portal-footer-text,
|
||||
.portal-intro {
|
||||
margin-top: 1.25rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.portal-footer-text a,
|
||||
.portal-link {
|
||||
color: var(--accent-blue);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.portal-footer-text a:hover,
|
||||
.portal-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.form-grid--2 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.form-errors {
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.flash-messages {
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
z-index: 90;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.flash-message {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.flash-message--success,
|
||||
.flash-message--info {
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
color: #047857;
|
||||
border: 1px solid rgba(16, 185, 129, 0.25);
|
||||
}
|
||||
|
||||
.flash-message--error {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #b91c1c;
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
}
|
||||
|
||||
.page-hero-content--row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.portal-table-wrap {
|
||||
overflow-x: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.portal-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.portal-table th,
|
||||
.portal-table td {
|
||||
padding: 1rem 1.25rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
|
||||
.portal-table th {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.2rem 0.65rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
background: rgba(59, 130, 246, 0.12);
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.status-pill--submitted { background: rgba(59, 130, 246, 0.12); color: #1d4ed8; }
|
||||
.status-pill--under_review { background: rgba(139, 92, 246, 0.12); color: #6d28d9; }
|
||||
.status-pill--quoted { background: rgba(245, 158, 11, 0.15); color: #b45309; }
|
||||
.status-pill--accepted { background: rgba(16, 185, 129, 0.15); color: #047857; }
|
||||
.status-pill--declined { background: rgba(239, 68, 68, 0.12); color: #b91c1c; }
|
||||
.status-pill--in_progress { background: rgba(6, 182, 212, 0.15); color: #0e7490; }
|
||||
.status-pill--delivered { background: rgba(34, 197, 94, 0.15); color: #15803d; }
|
||||
.status-pill--closed { background: rgba(107, 114, 128, 0.15); color: #374151; }
|
||||
|
||||
.portal-empty {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.portal-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.portal-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.portal-section-title {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.portal-subtitle {
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.portal-body-text {
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.portal-meta-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.portal-meta-list dt {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.portal-meta-list dd {
|
||||
margin: 0.15rem 0 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.portal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.portal-attachment-list ul {
|
||||
margin: 0.5rem 0 0;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form-grid--2,
|
||||
.portal-detail-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.social-auth {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.social-auth-label {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.social-auth-buttons {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.social-auth-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.65rem;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
background: #ffffff;
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.social-auth-btn:hover {
|
||||
border-color: var(--accent-blue);
|
||||
box-shadow: 0 0 0 3px rgba(48, 81, 255, 0.1);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.social-auth-btn--google .social-auth-icon {
|
||||
color: #ea4335;
|
||||
}
|
||||
|
||||
.social-auth-btn--github .social-auth-icon {
|
||||
color: #24292f;
|
||||
}
|
||||
|
||||
.social-auth-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.portal-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin: 1.25rem 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.portal-divider::before,
|
||||
.portal-divider::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: rgba(148, 163, 184, 0.35);
|
||||
}
|
||||
|
||||
.social-oauth-card {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.social-oauth-provider {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(48, 81, 255, 0.04);
|
||||
border: 1px solid rgba(48, 81, 255, 0.12);
|
||||
}
|
||||
|
||||
.social-oauth-provider--google {
|
||||
background: rgba(234, 67, 53, 0.06);
|
||||
border-color: rgba(234, 67, 53, 0.18);
|
||||
}
|
||||
|
||||
.social-oauth-provider--github {
|
||||
background: rgba(36, 41, 47, 0.05);
|
||||
border-color: rgba(36, 41, 47, 0.15);
|
||||
}
|
||||
|
||||
.social-oauth-provider-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 800;
|
||||
background: #ffffff;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.social-oauth-provider--google .social-oauth-provider-icon {
|
||||
color: #ea4335;
|
||||
}
|
||||
|
||||
.social-oauth-provider--github .social-oauth-provider-icon {
|
||||
color: #24292f;
|
||||
}
|
||||
|
||||
.social-oauth-provider-title {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.social-oauth-provider-copy {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.social-oauth-steps {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.social-oauth-steps li {
|
||||
position: relative;
|
||||
padding-left: 1.35rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.social-oauth-steps li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0.55rem;
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-blue);
|
||||
}
|
||||
|
||||
.social-oauth-form {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.social-oauth-continue {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.social-oauth-redirect {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.social-oauth-spinner {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
margin: 0 auto 1rem;
|
||||
border-radius: 999px;
|
||||
border: 3px solid rgba(48, 81, 255, 0.15);
|
||||
border-top-color: var(--accent-blue);
|
||||
animation: social-oauth-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes social-oauth-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
'use strict';
|
||||
|
||||
function initBriefWizard() {
|
||||
const form = document.querySelector('[data-brief-wizard]');
|
||||
if (!form) return;
|
||||
|
||||
const panels = Array.from(form.querySelectorAll('[data-wizard-panel]'));
|
||||
const indicators = Array.from(form.querySelectorAll('[data-wizard-step-indicator]'));
|
||||
const prevBtn = form.querySelector('[data-wizard-prev]');
|
||||
const nextBtn = form.querySelector('[data-wizard-next]');
|
||||
const submitBtn = form.querySelector('[data-wizard-submit]');
|
||||
const progressFill = form.querySelector('[data-wizard-progress-fill]');
|
||||
const reviewBlock = form.querySelector('[data-wizard-review]');
|
||||
const totalSteps = panels.length;
|
||||
let currentStep = Number(form.dataset.initialStep || '1');
|
||||
|
||||
if (currentStep < 1 || currentStep > totalSteps) {
|
||||
currentStep = 1;
|
||||
}
|
||||
|
||||
function getPanel(step) {
|
||||
return form.querySelector(`[data-wizard-panel="${step}"]`);
|
||||
}
|
||||
|
||||
function getFields(step) {
|
||||
const panel = getPanel(step);
|
||||
if (!panel) return [];
|
||||
const fieldNames = (panel.dataset.stepFields || '').split(',').filter(Boolean);
|
||||
return fieldNames
|
||||
.map((name) => {
|
||||
if (name === 'attachments') {
|
||||
return form.querySelector('#id_attachments');
|
||||
}
|
||||
return form.querySelector(`[name="${name}"]`);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function stepForField(field) {
|
||||
if (!field || !field.name) return 1;
|
||||
for (const panel of panels) {
|
||||
const fieldNames = (panel.dataset.stepFields || '').split(',').filter(Boolean);
|
||||
if (fieldNames.includes(field.name)) {
|
||||
return Number(panel.dataset.wizardPanel);
|
||||
}
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
function isFieldValid(field) {
|
||||
if (!field) return true;
|
||||
if (field.disabled) return true;
|
||||
if (field.type === 'file') return true;
|
||||
if (field.required && !String(field.value || '').trim()) {
|
||||
field.reportValidity();
|
||||
return false;
|
||||
}
|
||||
if (!field.checkValidity()) {
|
||||
field.reportValidity();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateStep(step) {
|
||||
const fields = getFields(step);
|
||||
return fields.every((field) => isFieldValid(field));
|
||||
}
|
||||
|
||||
function validateAllRequired() {
|
||||
const requiredFields = Array.from(
|
||||
form.querySelectorAll('input, select, textarea')
|
||||
).filter((field) => field.required && !field.disabled);
|
||||
for (const field of requiredFields) {
|
||||
if (!isFieldValid(field)) {
|
||||
setStep(stepForField(field));
|
||||
field.focus();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function optionLabel(select) {
|
||||
if (!select || select.tagName !== 'SELECT') return '—';
|
||||
const option = select.options[select.selectedIndex];
|
||||
if (!option || !option.value) return 'Not specified';
|
||||
return option.textContent.trim();
|
||||
}
|
||||
|
||||
function updateReview() {
|
||||
if (!reviewBlock) return;
|
||||
|
||||
const title = form.querySelector('#id_title');
|
||||
const category = form.querySelector('#id_category');
|
||||
const description = form.querySelector('#id_description');
|
||||
const budget = form.querySelector('#id_budget_range');
|
||||
const deadline = form.querySelector('#id_desired_deadline');
|
||||
const links = form.querySelector('#id_reference_links');
|
||||
const attachments = form.querySelector('#id_attachments');
|
||||
|
||||
const setReview = (name, value) => {
|
||||
const target = reviewBlock.querySelector(`[data-review-field="${name}"]`);
|
||||
if (target) target.textContent = value || '—';
|
||||
};
|
||||
|
||||
setReview('title', title ? title.value.trim() : '—');
|
||||
setReview('category', optionLabel(category));
|
||||
setReview('description', description && description.value.trim() ? description.value.trim() : 'Not specified');
|
||||
setReview('budget_range', optionLabel(budget));
|
||||
setReview('desired_deadline', deadline && deadline.value ? deadline.value : 'Not specified');
|
||||
setReview('reference_links', links && links.value.trim() ? links.value.trim() : 'None');
|
||||
|
||||
if (attachments && attachments.files && attachments.files.length) {
|
||||
const names = Array.from(attachments.files).map((file) => file.name).join(', ');
|
||||
setReview('attachments', names);
|
||||
} else {
|
||||
setReview('attachments', 'None');
|
||||
}
|
||||
}
|
||||
|
||||
function setStep(step) {
|
||||
currentStep = step;
|
||||
|
||||
panels.forEach((panel) => {
|
||||
const panelStep = Number(panel.dataset.wizardPanel);
|
||||
const isActive = panelStep === step;
|
||||
panel.classList.toggle('is-active', isActive);
|
||||
panel.hidden = !isActive;
|
||||
});
|
||||
|
||||
indicators.forEach((indicator) => {
|
||||
const indicatorStep = Number(indicator.dataset.wizardStepIndicator);
|
||||
indicator.classList.toggle('is-active', indicatorStep === step);
|
||||
indicator.classList.toggle('is-complete', indicatorStep < step);
|
||||
});
|
||||
|
||||
if (progressFill && totalSteps > 1) {
|
||||
const progress = ((step - 1) / (totalSteps - 1)) * 100;
|
||||
progressFill.style.width = `${progress}%`;
|
||||
}
|
||||
|
||||
if (prevBtn) prevBtn.hidden = step === 1;
|
||||
if (nextBtn) nextBtn.hidden = step === totalSteps;
|
||||
if (submitBtn) submitBtn.hidden = step !== totalSteps;
|
||||
if (reviewBlock) reviewBlock.hidden = step !== totalSteps;
|
||||
|
||||
if (step === totalSteps) {
|
||||
updateReview();
|
||||
}
|
||||
}
|
||||
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', () => {
|
||||
if (currentStep > 1) {
|
||||
setStep(currentStep - 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (nextBtn) {
|
||||
nextBtn.addEventListener('click', () => {
|
||||
if (!validateStep(currentStep)) return;
|
||||
if (currentStep < totalSteps) {
|
||||
setStep(currentStep + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
if (currentStep !== totalSteps) {
|
||||
event.preventDefault();
|
||||
if (validateStep(currentStep) && currentStep < totalSteps) {
|
||||
setStep(currentStep + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!validateAllRequired()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
form.querySelectorAll('input, select, textarea').forEach((field) => {
|
||||
field.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' && field.tagName !== 'TEXTAREA' && currentStep !== totalSteps) {
|
||||
event.preventDefault();
|
||||
if (validateStep(currentStep) && currentStep < totalSteps) {
|
||||
setStep(currentStep + 1);
|
||||
}
|
||||
}
|
||||
});
|
||||
field.addEventListener('input', () => {
|
||||
if (currentStep === totalSteps) {
|
||||
updateReview();
|
||||
}
|
||||
});
|
||||
field.addEventListener('change', () => {
|
||||
if (currentStep === totalSteps) {
|
||||
updateReview();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setStep(currentStep);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initBriefWizard);
|
||||
@@ -0,0 +1,20 @@
|
||||
{% load socialaccount %}
|
||||
{% if social_auth_enabled %}
|
||||
<div class="social-auth">
|
||||
<p class="social-auth-label">Continue with</p>
|
||||
<div class="social-auth-buttons">
|
||||
{% for provider in social_providers %}
|
||||
<a href="{% provider_login_url provider.id %}" class="social-auth-btn social-auth-btn--{{ provider.id }}">
|
||||
{% if provider.id == "google" %}
|
||||
<span class="social-auth-icon" aria-hidden="true">G</span>
|
||||
<span>Google</span>
|
||||
{% else %}
|
||||
<span class="social-auth-icon" aria-hidden="true">GH</span>
|
||||
<span>GitHub</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="portal-divider" role="separator"><span>or use email</span></div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,5 @@
|
||||
Password reset for {{ user.get_username }}
|
||||
|
||||
{{ protocol }}://{{ domain }}{% url 'accounts:password_reset_confirm' uidb64=uid token=token %}
|
||||
|
||||
If you did not request this, ignore this email.
|
||||
@@ -0,0 +1 @@
|
||||
Tecvico password reset
|
||||
@@ -0,0 +1,55 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Login{% endblock %}
|
||||
{% block meta_description %}Log in to your Tecvico client portal.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero" aria-labelledby="login-heading">
|
||||
<div class="page-hero-blobs" aria-hidden="true">
|
||||
<div class="blob blob--1"></div>
|
||||
<div class="blob blob--2"></div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<div class="section-badge">Client Portal</div>
|
||||
<h1 class="page-hero-title" id="login-heading">Log In</h1>
|
||||
<p class="page-hero-subtitle">Access your project briefs and track progress with the Tecvico team.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container container--narrow">
|
||||
<div class="glass-card portal-card fade-in">
|
||||
{% include "accounts/_social_auth.html" %}
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="form-errors">{{ form.non_field_errors }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_username">Username <span class="required-star">*</span></label>
|
||||
{{ form.username }}
|
||||
{{ form.username.errors }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_password">Password <span class="required-star">*</span></label>
|
||||
{{ form.password }}
|
||||
{{ form.password.errors }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary btn-submit">Log in</button>
|
||||
</form>
|
||||
|
||||
<p class="portal-footer-text">
|
||||
<a href="{% url 'accounts:password_reset' %}">Forgot password?</a>
|
||||
·
|
||||
<a href="{% url 'accounts:signup' %}">Create account</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,24 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Reset Password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="container container--narrow">
|
||||
<div class="glass-card portal-card fade-in">
|
||||
<h1 class="contact-form-title">Reset password</h1>
|
||||
<p class="portal-intro">Enter your email and we'll send reset instructions.</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="form-group">
|
||||
<label for="id_email">Email</label>
|
||||
{{ form.email }}
|
||||
{{ form.email.errors }}
|
||||
</div>
|
||||
<button type="submit" class="btn-primary btn-submit">Send reset link</button>
|
||||
</form>
|
||||
<p class="portal-footer-text"><a href="{% url 'accounts:login' %}">Back to login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Password Updated{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="container container--narrow">
|
||||
<div class="glass-card portal-card fade-in">
|
||||
<h1 class="contact-form-title">Password updated</h1>
|
||||
<p class="portal-intro">You can now log in with your new password.</p>
|
||||
<p class="portal-footer-text"><a href="{% url 'accounts:login' %}">Log in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Set New Password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="container container--narrow">
|
||||
<div class="glass-card portal-card fade-in">
|
||||
<h1 class="contact-form-title">Set a new password</h1>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{{ form.non_field_errors }}
|
||||
<div class="form-group">
|
||||
<label for="id_new_password1">New password</label>
|
||||
{{ form.new_password1 }}
|
||||
{{ form.new_password1.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="id_new_password2">Confirm password</label>
|
||||
{{ form.new_password2 }}
|
||||
{{ form.new_password2.errors }}
|
||||
</div>
|
||||
<button type="submit" class="btn-primary btn-submit">Update password</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,15 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Reset Email Sent{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="container container--narrow">
|
||||
<div class="glass-card portal-card fade-in">
|
||||
<h1 class="contact-form-title">Check your email</h1>
|
||||
<p class="portal-intro">If an account exists for that address, you'll receive password reset instructions shortly.</p>
|
||||
<p class="portal-footer-text"><a href="{% url 'accounts:login' %}">Back to login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Profile{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero" aria-labelledby="profile-heading">
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<h1 class="page-hero-title" id="profile-heading">Your Profile</h1>
|
||||
<p class="page-hero-subtitle">Keep your contact details up to date for project communication.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container container--narrow">
|
||||
<div class="glass-card portal-card fade-in">
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="form-grid form-grid--2">
|
||||
<div class="form-group">
|
||||
<label for="id_first_name">First name <span class="required-star">*</span></label>
|
||||
{{ form.first_name }}
|
||||
{{ form.first_name.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="id_last_name">Last name <span class="required-star">*</span></label>
|
||||
{{ form.last_name }}
|
||||
{{ form.last_name.errors }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_email">Email <span class="required-star">*</span></label>
|
||||
{{ form.email }}
|
||||
{{ form.email.errors }}
|
||||
</div>
|
||||
|
||||
<div class="form-grid form-grid--2">
|
||||
<div class="form-group">
|
||||
<label for="id_company">Company</label>
|
||||
{{ form.company }}
|
||||
{{ form.company.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="id_phone">Phone</label>
|
||||
{{ form.phone }}
|
||||
{{ form.phone.errors }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_timezone">Timezone <span class="required-star">*</span></label>
|
||||
{{ form.timezone }}
|
||||
{{ form.timezone.errors }}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary btn-submit">Save profile</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,96 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Sign Up{% endblock %}
|
||||
{% block meta_description %}Create a Tecvico client account and submit your project brief.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero" aria-labelledby="signup-heading">
|
||||
<div class="page-hero-blobs" aria-hidden="true">
|
||||
<div class="blob blob--1"></div>
|
||||
<div class="blob blob--2"></div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<div class="section-badge">Client Portal</div>
|
||||
<h1 class="page-hero-title" id="signup-heading">Create Account</h1>
|
||||
<p class="page-hero-subtitle">Tell us about your project and our team will take it from there.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container container--narrow">
|
||||
<div class="glass-card portal-card fade-in">
|
||||
{% include "accounts/_social_auth.html" %}
|
||||
|
||||
<form method="post" novalidate>
|
||||
{% csrf_token %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="form-errors">{{ form.non_field_errors }}</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="form-grid form-grid--2">
|
||||
<div class="form-group">
|
||||
<label for="id_first_name">First name <span class="required-star">*</span></label>
|
||||
{{ form.first_name }}
|
||||
{{ form.first_name.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="id_last_name">Last name <span class="required-star">*</span></label>
|
||||
{{ form.last_name }}
|
||||
{{ form.last_name.errors }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_email">Email <span class="required-star">*</span></label>
|
||||
{{ form.email }}
|
||||
{{ form.email.errors }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_username">Username <span class="required-star">*</span></label>
|
||||
{{ form.username }}
|
||||
{{ form.username.errors }}
|
||||
</div>
|
||||
|
||||
<div class="form-grid form-grid--2">
|
||||
<div class="form-group">
|
||||
<label for="id_company">Company</label>
|
||||
{{ form.company }}
|
||||
{{ form.company.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="id_phone">Phone</label>
|
||||
{{ form.phone }}
|
||||
{{ form.phone.errors }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_timezone">Timezone <span class="required-star">*</span></label>
|
||||
{{ form.timezone }}
|
||||
{{ form.timezone.errors }}
|
||||
</div>
|
||||
|
||||
<div class="form-grid form-grid--2">
|
||||
<div class="form-group">
|
||||
<label for="id_password1">Password <span class="required-star">*</span></label>
|
||||
{{ form.password1 }}
|
||||
{{ form.password1.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="id_password2">Confirm password <span class="required-star">*</span></label>
|
||||
{{ form.password2 }}
|
||||
{{ form.password2.errors }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary btn-submit">Create account</button>
|
||||
</form>
|
||||
|
||||
<p class="portal-footer-text">Already have an account? <a href="{% url 'accounts:login' %}">Log in</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -17,6 +17,8 @@
|
||||
|
||||
{% include "partials/_navbar.html" %}
|
||||
|
||||
{% include "partials/_messages.html" %}
|
||||
|
||||
<main id="main-content">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -37,6 +37,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% include "partials/_page_sections.html" with sections=homepage_sections %}
|
||||
{% include "partials/_page_sections.html" with sections=homepage_sections website_project_briefs=website_project_briefs %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{% if messages %}
|
||||
<div class="flash-messages" aria-live="polite">
|
||||
{% for message in messages %}
|
||||
<div class="flash-message flash-message--{{ message.tags|default:'info' }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -90,6 +90,36 @@
|
||||
</a>
|
||||
</li>
|
||||
|
||||
{% if user.is_authenticated %}
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'projects:dashboard' %}" class="nav-link {% if request.resolver_match.namespace == 'projects' %}active{% endif %}">
|
||||
My Projects
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'accounts:profile' %}" class="nav-link {% if request.resolver_match.url_name == 'profile' %}active{% endif %}">
|
||||
Profile
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<form method="post" action="{% url 'accounts:logout' %}" class="nav-logout-form">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="nav-link nav-link-button">Logout</button>
|
||||
</form>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'accounts:login' %}" class="nav-link {% if request.resolver_match.url_name == 'login' %}active{% endif %}">
|
||||
Login
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{% url 'accounts:signup' %}" class="nav-link nav-link--cta {% if request.resolver_match.url_name == 'signup' %}active{% endif %}">
|
||||
Start a Project
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -245,6 +245,58 @@
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if website_project_briefs %}
|
||||
{% for brief in website_project_briefs %}
|
||||
{% if brief.public_url %}
|
||||
<a href="{{ brief.public_url }}" class="project-card glass-card fade-in" data-project-status="{{ brief.public_project_status|default:'all' }}"{% if brief.public_url|slice:":4" == "http" %} target="_blank" rel="noopener noreferrer"{% endif %}>
|
||||
{% else %}
|
||||
<article class="project-card glass-card fade-in" data-project-status="{{ brief.public_project_status|default:'all' }}">
|
||||
{% endif %}
|
||||
{% if brief.public_image %}
|
||||
<div class="project-card-image">
|
||||
<img src="{{ brief.public_image.url }}" alt="{{ brief.public_display_title|default:brief.title }}" loading="lazy" />
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="project-card-body">
|
||||
{% if brief.public_display_title %}<h3 class="project-card-title">{{ brief.public_display_title }}</h3>{% endif %}
|
||||
{% if brief.public_display_summary %}
|
||||
<p class="project-card-desc" data-readmore="120">{{ brief.public_display_summary }}</p>
|
||||
{% endif %}
|
||||
{% if brief.public_budget_display or brief.public_deadline_display %}
|
||||
<p class="project-card-meta">
|
||||
{% if brief.public_budget_display %}<span>Budget: {{ brief.public_budget_display }}</span>{% endif %}
|
||||
{% if brief.public_deadline_display %}<span>Deadline: {{ brief.public_deadline_display }}</span>{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if brief.public_tag_list %}
|
||||
<ul class="project-card-tags" role="list">
|
||||
{% for tag in brief.public_tag_list %}
|
||||
<li>{{ tag }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if brief.public_reference_link_list %}
|
||||
<ul class="project-card-links" role="list">
|
||||
{% for link in brief.public_reference_link_list %}
|
||||
<li><a href="{{ link }}" target="_blank" rel="noopener noreferrer">{{ link }}</a></li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% if brief.public_attachment_filename_list %}
|
||||
<ul class="project-card-attachments" role="list">
|
||||
{% for filename in brief.public_attachment_filename_list %}
|
||||
<li>{{ filename }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if brief.public_url %}
|
||||
</a>
|
||||
{% else %}
|
||||
</article>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ brief.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero" aria-labelledby="brief-heading">
|
||||
<div class="container">
|
||||
<div class="page-hero-content page-hero-content--row">
|
||||
<div>
|
||||
{% if brief.category %}<div class="section-badge">{{ brief.get_category_display }}</div>{% endif %}
|
||||
<h1 class="page-hero-title" id="brief-heading">{{ brief.title }}</h1>
|
||||
<p class="page-hero-subtitle">
|
||||
<span class="status-pill status-pill--{{ brief.status }}">{{ brief.get_status_display }}</span>
|
||||
· Submitted {{ brief.submitted_at|date:"M j, Y" }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="portal-actions">
|
||||
<a href="{% url 'projects:dashboard' %}" class="btn-ghost">Back to list</a>
|
||||
{% if brief.is_editable_by_client %}
|
||||
<a href="{% url 'projects:edit' pk=brief.pk %}" class="btn-primary">Edit brief</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="portal-detail-grid">
|
||||
<div class="glass-card portal-card fade-in">
|
||||
<h2 class="portal-section-title">Overview</h2>
|
||||
<dl class="portal-meta-list">
|
||||
{% if brief.budget_range %}<div><dt>Budget</dt><dd>{{ brief.get_budget_range_display }}</dd></div>{% endif %}
|
||||
<div><dt>Deadline</dt><dd>{% if brief.desired_deadline %}{{ brief.desired_deadline|date:"M j, Y" }}{% else %}Not specified{% endif %}</dd></div>
|
||||
<div><dt>Last updated</dt><dd>{{ brief.updated_at|date:"M j, Y, P" }}</dd></div>
|
||||
</dl>
|
||||
|
||||
{% if brief.description %}
|
||||
<h3 class="portal-subtitle">Description & goals</h3>
|
||||
<p class="portal-body-text">{{ brief.description|linebreaksbr }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if brief.reference_links %}
|
||||
<h3 class="portal-subtitle">Reference links</h3>
|
||||
<p class="portal-body-text">{{ brief.reference_links|linebreaksbr }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if brief.attachments.exists %}
|
||||
<h3 class="portal-subtitle">Attachments</h3>
|
||||
<ul class="portal-attachment-list">
|
||||
{% for attachment in brief.attachments.all %}
|
||||
<li>{{ attachment.original_filename }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if brief.shows_quote_to_client %}
|
||||
<div class="glass-card portal-card fade-in">
|
||||
<h2 class="portal-section-title">Quote from Tecvico</h2>
|
||||
<p class="portal-body-text">{{ brief.quote_text|linebreaksbr }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if brief.has_website_notes %}
|
||||
<div class="glass-card portal-card fade-in">
|
||||
<h2 class="portal-section-title">Notes from Tecvico</h2>
|
||||
<p class="portal-body-text">{{ brief.website_notes|linebreaksbr }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,150 @@
|
||||
{% extends "base.html" %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ form_title }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="{% static 'css/brief-wizard.css' %}" />
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero" aria-labelledby="brief-form-heading">
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<h1 class="page-hero-title" id="brief-form-heading">{{ form_title }}</h1>
|
||||
<p class="page-hero-subtitle">Complete each step to submit your project brief.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container container--narrow">
|
||||
<div class="glass-card portal-card fade-in">
|
||||
<form method="post" enctype="multipart/form-data" novalidate class="brief-wizard" data-brief-wizard data-initial-step="{{ initial_step }}">
|
||||
{% csrf_token %}
|
||||
|
||||
<div class="wizard-progress" aria-hidden="true">
|
||||
<div class="wizard-progress-track">
|
||||
<div class="wizard-progress-fill" data-wizard-progress-fill></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ol class="wizard-stepper" role="list">
|
||||
{% for step in wizard_steps %}
|
||||
<li class="wizard-stepper-item{% if forloop.first %} is-active{% endif %}" data-wizard-step-indicator="{{ forloop.counter }}">
|
||||
<span class="wizard-stepper-number">{{ forloop.counter }}</span>
|
||||
<span class="wizard-stepper-label">{{ step.title }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
{% for step in wizard_steps %}
|
||||
<section
|
||||
class="wizard-panel{% if forloop.first %} is-active{% endif %}"
|
||||
data-wizard-panel="{{ forloop.counter }}"
|
||||
data-step-fields="{{ step.fields|join:',' }}"
|
||||
aria-labelledby="wizard-step-title-{{ forloop.counter }}"
|
||||
{% if not forloop.first %}hidden{% endif %}
|
||||
>
|
||||
<header class="wizard-panel-header">
|
||||
<p class="wizard-panel-eyebrow">Step {{ forloop.counter }} of {{ wizard_steps|length }}</p>
|
||||
<h2 class="wizard-panel-title" id="wizard-step-title-{{ forloop.counter }}">{{ step.title }}</h2>
|
||||
<p class="wizard-panel-subtitle">{{ step.subtitle }}</p>
|
||||
</header>
|
||||
|
||||
{% if step.id == "basics" %}
|
||||
<div class="form-group">
|
||||
<label for="id_title">Project title <span class="required-star">*</span></label>
|
||||
{{ form.title }}
|
||||
{{ form.title.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="id_category">Category</label>
|
||||
{{ form.category }}
|
||||
{{ form.category.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if step.id == "scope" %}
|
||||
<div class="form-group">
|
||||
<label for="id_description">Description & goals</label>
|
||||
{{ form.description }}
|
||||
<p class="form-hint">Describe the project scope, context, and the outcomes you expect.</p>
|
||||
{{ form.description.errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if step.id == "timeline" %}
|
||||
<div class="form-grid form-grid--2">
|
||||
<div class="form-group">
|
||||
<label for="id_budget_range">Budget range</label>
|
||||
{{ form.budget_range }}
|
||||
{{ form.budget_range.errors }}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="id_desired_deadline">Desired deadline</label>
|
||||
{{ form.desired_deadline }}
|
||||
{{ form.desired_deadline.errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if step.id == "references" %}
|
||||
<div class="form-group">
|
||||
<label for="id_reference_links">Reference links</label>
|
||||
{{ form.reference_links }}
|
||||
<p class="form-hint">One URL per line — docs, mockups, repos, etc.</p>
|
||||
{{ form.reference_links.errors }}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_attachments">Attachments</label>
|
||||
<input type="file" name="attachments" id="id_attachments" multiple
|
||||
accept=".png,.jpg,.jpeg,.gif,.webp,.bmp,.zip,.log,.txt"
|
||||
class="file-input"
|
||||
{% if brief and brief.attachments_locked %}disabled{% endif %} />
|
||||
<p class="form-hint">Images, ZIP, or log/text files — up to 3 files, 10 MB each.</p>
|
||||
{{ form.attachments.errors }}
|
||||
</div>
|
||||
|
||||
{% if brief and brief.attachments.exists %}
|
||||
<div class="portal-attachment-list">
|
||||
<p class="form-hint">Existing attachments:</p>
|
||||
<ul>
|
||||
{% for attachment in brief.attachments.all %}
|
||||
<li>{{ attachment.original_filename }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="wizard-review" data-wizard-review hidden>
|
||||
<h3 class="wizard-review-title">Review your brief</h3>
|
||||
<dl class="wizard-review-list">
|
||||
<div><dt>Title</dt><dd data-review-field="title">—</dd></div>
|
||||
<div><dt>Category</dt><dd data-review-field="category">—</dd></div>
|
||||
<div><dt>Description & goals</dt><dd data-review-field="description">—</dd></div>
|
||||
<div><dt>Budget</dt><dd data-review-field="budget_range">—</dd></div>
|
||||
<div><dt>Deadline</dt><dd data-review-field="desired_deadline">—</dd></div>
|
||||
<div><dt>Reference links</dt><dd data-review-field="reference_links">—</dd></div>
|
||||
<div><dt>New attachments</dt><dd data-review-field="attachments">None</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endfor %}
|
||||
|
||||
<div class="wizard-actions">
|
||||
<button type="button" class="btn-ghost" data-wizard-prev hidden>Back</button>
|
||||
<button type="button" class="btn-primary" data-wizard-next>Continue</button>
|
||||
<button type="submit" class="btn-primary btn-submit" data-wizard-submit hidden>{{ submit_label }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="{% static 'js/brief-wizard.js' %}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,70 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}My Projects{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero" aria-labelledby="dashboard-heading">
|
||||
<div class="container">
|
||||
<div class="page-hero-content page-hero-content--row">
|
||||
<div>
|
||||
<div class="section-badge">Client Portal</div>
|
||||
<h1 class="page-hero-title" id="dashboard-heading">My Projects</h1>
|
||||
<p class="page-hero-subtitle">Track briefs you've submitted to the Tecvico team.</p>
|
||||
</div>
|
||||
<a href="{% url 'projects:create' %}" class="btn-primary">New project brief</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
{% if briefs %}
|
||||
<div class="portal-table-wrap glass-card fade-in">
|
||||
<table class="portal-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Category</th>
|
||||
<th>Status</th>
|
||||
<th>Budget</th>
|
||||
<th>Submitted</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for brief in briefs %}
|
||||
<tr>
|
||||
<td>{{ brief.title }}</td>
|
||||
<td>{% if brief.category %}{{ brief.get_category_display }}{% else %}—{% endif %}</td>
|
||||
<td><span class="status-pill status-pill--{{ brief.status }}">{{ brief.get_status_display }}</span></td>
|
||||
<td>{% if brief.budget_range %}{{ brief.get_budget_range_display }}{% else %}—{% endif %}</td>
|
||||
<td>{{ brief.submitted_at|date:"M j, Y" }}</td>
|
||||
<td><a href="{% url 'projects:detail' pk=brief.pk %}" class="portal-link">View</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if page_obj.has_other_pages %}
|
||||
<div class="portal-pagination">
|
||||
{% if page_obj.has_previous %}
|
||||
<a href="?page={{ page_obj.previous_page_number }}" class="portal-link">Previous</a>
|
||||
{% endif %}
|
||||
<span>Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span>
|
||||
{% if page_obj.has_next %}
|
||||
<a href="?page={{ page_obj.next_page_number }}" class="portal-link">Next</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="glass-card portal-card portal-empty fade-in">
|
||||
<h2>No project briefs yet</h2>
|
||||
<p class="portal-intro">Describe what you need and our team will review your request.</p>
|
||||
<a href="{% url 'projects:create' %}" class="btn-primary">Submit your first brief</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,13 @@
|
||||
New project brief submitted
|
||||
|
||||
Title: {{ brief.title }}
|
||||
Client: {{ brief.client.get_full_name|default:brief.client.username }} ({{ brief.client.email }})
|
||||
Category: {{ brief.get_category_display }}
|
||||
Budget: {{ brief.get_budget_range_display }}
|
||||
Status: {{ brief.get_status_display }}
|
||||
Submitted: {{ brief.submitted_at }}
|
||||
|
||||
Description & goals:
|
||||
{{ brief.description }}
|
||||
|
||||
Review in admin: /admin/projects/projectbrief/{{ brief.pk }}/change/
|
||||
@@ -0,0 +1,13 @@
|
||||
Hello {{ brief.client.first_name|default:brief.client.username }},
|
||||
|
||||
Your project "{{ brief.title }}" status changed from {{ previous_status }} to {{ brief.status }}.
|
||||
|
||||
{% if brief.shows_quote_to_client %}
|
||||
Quote from Tecvico:
|
||||
{{ brief.quote_text }}
|
||||
{% endif %}
|
||||
|
||||
View your project: {{ brief.get_absolute_url }}
|
||||
|
||||
Thank you,
|
||||
Tecvico Team
|
||||
@@ -0,0 +1,32 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}Sign-in failed{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero" aria-labelledby="auth-error-heading">
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<h1 class="page-hero-title" id="auth-error-heading">Sign-in failed</h1>
|
||||
<p class="page-hero-subtitle">We could not complete third-party authentication.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container container--narrow">
|
||||
<div class="glass-card portal-card fade-in">
|
||||
<div class="form-errors">
|
||||
{% trans "An error occurred while attempting to sign in with your third-party account." %}
|
||||
</div>
|
||||
<p class="portal-intro">
|
||||
{% trans "Please try again, or sign in with your email and password." %}
|
||||
</p>
|
||||
<div class="portal-actions">
|
||||
<a href="{% url 'accounts:login' %}" class="btn-primary">Back to login</a>
|
||||
<a href="{% url 'pages:contact' %}" class="btn-ghost">Contact support</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,80 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}
|
||||
{% if process == "connect" %}Connect {{ provider.name }}{% else %}Continue with {{ provider.name }}{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block meta_description %}Secure sign-in with {{ provider.name }} for your Tecvico client portal.{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero" aria-labelledby="social-login-heading">
|
||||
<div class="page-hero-blobs" aria-hidden="true">
|
||||
<div class="blob blob--1"></div>
|
||||
<div class="blob blob--2"></div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<div class="section-badge">Client Portal</div>
|
||||
<h1 class="page-hero-title" id="social-login-heading">
|
||||
{% if process == "connect" %}
|
||||
Connect {{ provider.name }}
|
||||
{% else %}
|
||||
Sign in with {{ provider.name }}
|
||||
{% endif %}
|
||||
</h1>
|
||||
<p class="page-hero-subtitle">
|
||||
{% if process == "connect" %}
|
||||
Link your {{ provider.name }} account to your Tecvico profile.
|
||||
{% else %}
|
||||
You will be redirected to {{ provider.name }} to verify your identity.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container container--narrow">
|
||||
<div class="glass-card portal-card fade-in social-oauth-card">
|
||||
<div class="social-oauth-provider social-oauth-provider--{{ provider.id }}">
|
||||
<span class="social-oauth-provider-icon" aria-hidden="true">
|
||||
{% if provider.id == "google" %}G{% else %}GH{% endif %}
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="social-oauth-provider-title">{{ provider.name }}</h2>
|
||||
<p class="social-oauth-provider-copy">
|
||||
{% if process == "connect" %}
|
||||
{% blocktrans with provider_name=provider.name %}You are about to connect a {{ provider_name }} account to Tecvico.{% endblocktrans %}
|
||||
{% else %}
|
||||
{% blocktrans with provider_name=provider.name %}Continue to authorize access with your {{ provider_name }} account.{% endblocktrans %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="social-oauth-steps">
|
||||
<li>Redirect to {{ provider.name }}</li>
|
||||
<li>Approve access</li>
|
||||
<li>Return to your project dashboard</li>
|
||||
</ul>
|
||||
|
||||
<form method="post" class="social-oauth-form">
|
||||
{% csrf_token %}
|
||||
{% if redirect_field_value %}
|
||||
<input type="hidden" name="{{ redirect_field_name }}" value="{{ redirect_field_value }}" />
|
||||
{% endif %}
|
||||
<button type="submit" class="btn-primary btn-submit social-oauth-continue">
|
||||
Continue to {{ provider.name }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="portal-footer-text">
|
||||
<a href="{% url 'accounts:login' %}">Back to login</a>
|
||||
·
|
||||
<a href="{% url 'pages:home' %}">Home</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}Login cancelled{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero" aria-labelledby="cancelled-heading">
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<h1 class="page-hero-title" id="cancelled-heading">Login cancelled</h1>
|
||||
<p class="page-hero-subtitle">No changes were made to your account.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container container--narrow">
|
||||
<div class="glass-card portal-card fade-in">
|
||||
<p class="portal-intro">
|
||||
{% trans "You cancelled third-party sign-in. You can try again or use email login instead." %}
|
||||
</p>
|
||||
<div class="portal-actions">
|
||||
<a href="{% url 'accounts:login' %}" class="btn-primary">Back to login</a>
|
||||
<a href="{% url 'pages:home' %}" class="btn-ghost">Home</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}Redirecting to {{ provider }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="section">
|
||||
<div class="container container--narrow">
|
||||
<div class="glass-card portal-card fade-in social-oauth-redirect">
|
||||
<div class="social-oauth-spinner" aria-hidden="true"></div>
|
||||
<h1 class="contact-form-title">Redirecting to {{ provider }}</h1>
|
||||
<p class="portal-intro">{% trans "Please wait while we connect you securely." %}</p>
|
||||
<p class="portal-footer-text">
|
||||
<a href="{{ redirect_to }}" class="portal-link">{% trans "Continue manually" %}</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>window.location.replace("{{ redirect_to|escapejs }}");</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,57 @@
|
||||
{% extends "base.html" %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}Complete sign up{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-hero" aria-labelledby="social-signup-heading">
|
||||
<div class="page-hero-blobs" aria-hidden="true">
|
||||
<div class="blob blob--1"></div>
|
||||
<div class="blob blob--2"></div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="page-hero-content">
|
||||
<div class="section-badge">Client Portal</div>
|
||||
<h1 class="page-hero-title" id="social-signup-heading">Complete your account</h1>
|
||||
<p class="page-hero-subtitle">
|
||||
{% blocktrans with provider_name=account.get_provider.name %}Finish setup after signing in with {{ provider_name }}.{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container container--narrow">
|
||||
<div class="glass-card portal-card fade-in">
|
||||
<form method="post" action="{% url 'socialaccount_signup' %}" novalidate>
|
||||
{% csrf_token %}
|
||||
{{ redirect_field }}
|
||||
|
||||
{% if form.non_field_errors %}
|
||||
<div class="form-errors">{{ form.non_field_errors }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% for field in form %}
|
||||
<div class="form-group{% if field.errors %} has-error{% endif %}">
|
||||
<label for="{{ field.id_for_label }}">{{ field.label }}{% if field.field.required %} <span class="required-star">*</span>{% endif %}</label>
|
||||
{{ field }}
|
||||
{{ field.errors }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<button type="submit" class="btn-primary btn-submit">Create account</button>
|
||||
</form>
|
||||
|
||||
<p class="portal-footer-text"><a href="{% url 'accounts:login' %}">Back to login</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.querySelectorAll(".portal-card input, .portal-card select, .portal-card textarea").forEach(function (el) {
|
||||
el.classList.add("form-control");
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user