feat: admin panel enrichment

This commit is contained in:
mohamad
2026-07-19 14:31:28 +03:30
parent d8d7819eca
commit 1042fba121
77 changed files with 4616 additions and 2 deletions
+9
View File
@@ -15,3 +15,12 @@ SECURE_SSL_REDIRECT=False
DJANGO_SUPERUSER_USERNAME=admin DJANGO_SUPERUSER_USERNAME=admin
DJANGO_SUPERUSER_EMAIL=admin@yourdomain.com DJANGO_SUPERUSER_EMAIL=admin@yourdomain.com
DJANGO_SUPERUSER_PASSWORD=bS7_U_uRTmivj7W-lCR5 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=
+1
View File
@@ -0,0 +1 @@
+39
View File
@@ -0,0 +1,39 @@
import os
from allauth.account.adapter import DefaultAccountAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from django.urls import reverse
from .models import ClientProfile
class TecvicoAccountAdapter(DefaultAccountAdapter):
def get_login_redirect_url(self, request):
return reverse("projects:dashboard")
def get_signup_redirect_url(self, request):
return reverse("projects:dashboard")
class TecvicoSocialAccountAdapter(DefaultSocialAccountAdapter):
def save_user(self, request, sociallogin, form=None):
user = super().save_user(request, sociallogin, form)
self._apply_profile_names(user, sociallogin)
user.save()
ClientProfile.objects.get_or_create(user=user)
return user
def _apply_profile_names(self, user, sociallogin):
extra_data = sociallogin.account.extra_data or {}
if sociallogin.account.provider == "google":
if not user.first_name:
user.first_name = extra_data.get("given_name", "")
if not user.last_name:
user.last_name = extra_data.get("family_name", "")
return
if sociallogin.account.provider == "github" and not user.first_name:
name = (extra_data.get("name") or "").strip()
if name:
parts = name.split(" ", 1)
user.first_name = parts[0]
user.last_name = parts[1] if len(parts) > 1 else ""
+10
View File
@@ -0,0 +1,10 @@
from django.contrib import admin
from .models import ClientProfile
@admin.register(ClientProfile)
class ClientProfileAdmin(admin.ModelAdmin):
list_display = ("user", "company", "phone", "timezone", "updated_at")
search_fields = ("user__username", "user__email", "user__first_name", "user__last_name", "company")
list_filter = ("timezone",)
+26
View File
@@ -0,0 +1,26 @@
import os
from django.apps import AppConfig
from django.db.models.signals import post_migrate
def ensure_site(sender, **kwargs):
from django.conf import settings
from django.contrib.sites.models import Site
domain = os.environ.get("SITE_DOMAIN", "localhost:8000")
Site.objects.update_or_create(
id=getattr(settings, "SITE_ID", 1),
defaults={"domain": domain, "name": "Tecvico"},
)
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.accounts"
label = "accounts"
def ready(self):
from . import signals # noqa: F401
post_migrate.connect(ensure_site, sender=self)
+12
View File
@@ -0,0 +1,12 @@
from django.conf import settings
from .social import enabled_social_providers
def social_auth(request):
providers = enabled_social_providers()
return {
"social_providers": providers,
"social_auth_enabled": bool(providers),
"site_domain": settings.SITE_DOMAIN,
}
+102
View File
@@ -0,0 +1,102 @@
from django import forms
from django.contrib.auth.forms import AuthenticationForm, UserCreationForm
from django.contrib.auth.models import User
from .models import ClientProfile
class SignUpForm(UserCreationForm):
first_name = forms.CharField(max_length=150, required=True)
last_name = forms.CharField(max_length=150, required=True)
email = forms.EmailField(required=True)
company = forms.CharField(max_length=200, required=False)
phone = forms.CharField(max_length=50, required=False)
timezone = forms.ChoiceField(choices=ClientProfile.TIMEZONE_CHOICES, required=True)
class Meta:
model = User
fields = ("username", "first_name", "last_name", "email", "password1", "password2")
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name in ("username", "first_name", "last_name", "email", "company", "phone", "timezone"):
if field_name in self.fields:
self.fields[field_name].widget.attrs.setdefault("class", "form-control")
for field_name in ("password1", "password2"):
self.fields[field_name].widget.attrs.setdefault("class", "form-control")
def clean_email(self):
email = self.cleaned_data["email"].strip().lower()
if User.objects.filter(email__iexact=email).exists():
raise forms.ValidationError("An account with this email already exists.")
return email
def save(self, commit=True):
user = super().save(commit=False)
user.email = self.cleaned_data["email"]
user.first_name = self.cleaned_data["first_name"]
user.last_name = self.cleaned_data["last_name"]
if commit:
user.save()
ClientProfile.objects.create(
user=user,
company=self.cleaned_data.get("company", ""),
phone=self.cleaned_data.get("phone", ""),
timezone=self.cleaned_data.get("timezone", "UTC"),
)
return user
class ClientLoginForm(AuthenticationForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["username"].widget.attrs.update(
{"placeholder": "Username or email", "class": "form-control"}
)
self.fields["password"].widget.attrs.update(
{"placeholder": "Password", "class": "form-control"}
)
class ClientProfileForm(forms.ModelForm):
first_name = forms.CharField(max_length=150, required=True)
last_name = forms.CharField(max_length=150, required=True)
email = forms.EmailField(required=True)
class Meta:
model = ClientProfile
fields = ("company", "phone", "timezone")
widgets = {
"company": forms.TextInput(attrs={"class": "form-control"}),
"phone": forms.TextInput(attrs={"class": "form-control"}),
"timezone": forms.Select(attrs={"class": "form-control"}),
}
def __init__(self, *args, **kwargs):
self.user = kwargs.pop("user")
super().__init__(*args, **kwargs)
self.fields["first_name"].initial = self.user.first_name
self.fields["last_name"].initial = self.user.last_name
self.fields["email"].initial = self.user.email
for name in ("first_name", "last_name", "email"):
self.fields[name].widget.attrs.setdefault("class", "form-control")
def clean_email(self):
email = self.cleaned_data["email"].strip().lower()
if (
User.objects.filter(email__iexact=email)
.exclude(pk=self.user.pk)
.exists()
):
raise forms.ValidationError("An account with this email already exists.")
return email
def save(self, commit=True):
profile = super().save(commit=False)
self.user.first_name = self.cleaned_data["first_name"]
self.user.last_name = self.cleaned_data["last_name"]
self.user.email = self.cleaned_data["email"]
if commit:
self.user.save()
profile.save()
return profile
+33
View File
@@ -0,0 +1,33 @@
# Generated by Django 5.2.15 on 2026-07-09 07:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ClientProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('company', models.CharField(blank=True, max_length=200)),
('phone', models.CharField(blank=True, max_length=50)),
('timezone', models.CharField(choices=[('UTC', 'UTC'), ('America/New_York', 'America/New York'), ('America/Chicago', 'America/Chicago'), ('America/Denver', 'America/Denver'), ('America/Los_Angeles', 'America/Los Angeles'), ('Europe/London', 'Europe/London'), ('Europe/Berlin', 'Europe/Berlin'), ('Asia/Dubai', 'Asia/Dubai'), ('Asia/Tehran', 'Asia/Tehran'), ('Asia/Tokyo', 'Asia/Tokyo'), ('Australia/Sydney', 'Australia/Sydney')], default='UTC', max_length=64)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='client_profile', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Client Profile',
'verbose_name_plural': 'Client Profiles',
},
),
]
+47
View File
@@ -0,0 +1,47 @@
from django.conf import settings
from django.db import models
class ClientProfile(models.Model):
TIMEZONE_CHOICES = [
("UTC", "UTC"),
("America/New_York", "America/New York"),
("America/Chicago", "America/Chicago"),
("America/Denver", "America/Denver"),
("America/Los_Angeles", "America/Los Angeles"),
("Europe/London", "Europe/London"),
("Europe/Berlin", "Europe/Berlin"),
("Asia/Dubai", "Asia/Dubai"),
("Asia/Tehran", "Asia/Tehran"),
("Asia/Tokyo", "Asia/Tokyo"),
("Australia/Sydney", "Australia/Sydney"),
]
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="client_profile",
)
company = models.CharField(max_length=200, blank=True)
phone = models.CharField(max_length=50, blank=True)
timezone = models.CharField(
max_length=64,
choices=TIMEZONE_CHOICES,
default="UTC",
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Client Profile"
verbose_name_plural = "Client Profiles"
def __str__(self):
return self.user.get_full_name() or self.user.email or str(self.user.pk)
@property
def display_name(self):
full_name = self.user.get_full_name().strip()
if full_name:
return full_name
return self.user.email
+10
View File
@@ -0,0 +1,10 @@
from django.dispatch import receiver
from allauth.account.signals import user_signed_up
from .models import ClientProfile
@receiver(user_signed_up)
def ensure_client_profile(sender, request, user, **kwargs):
ClientProfile.objects.get_or_create(user=user)
+15
View File
@@ -0,0 +1,15 @@
from django.conf import settings
def enabled_social_providers():
providers = []
configured = getattr(settings, "SOCIALACCOUNT_PROVIDERS", {})
labels = {
"google": "Google",
"github": "GitHub",
}
for provider_id, label in labels.items():
app = configured.get(provider_id, {}).get("APP", {})
if app.get("client_id") and app.get("secret"):
providers.append({"id": provider_id, "label": label})
return providers
+1
View File
@@ -0,0 +1 @@
+72
View File
@@ -0,0 +1,72 @@
from django.contrib.auth.models import User
from django.test import TestCase
from django.urls import reverse
from apps.accounts.forms import SignUpForm
from apps.accounts.models import ClientProfile
class SignUpFormTest(TestCase):
def test_signup_creates_user_and_profile(self):
form = SignUpForm(
data={
"username": "client1",
"first_name": "Ada",
"last_name": "Lovelace",
"email": "ada@example.com",
"company": "Analytical Engines",
"phone": "+1 555 0100",
"timezone": "UTC",
"password1": "Str0ngPass!word",
"password2": "Str0ngPass!word",
}
)
self.assertTrue(form.is_valid(), form.errors)
user = form.save()
self.assertEqual(user.email, "ada@example.com")
profile = ClientProfile.objects.get(user=user)
self.assertEqual(profile.company, "Analytical Engines")
self.assertEqual(profile.timezone, "UTC")
class AccountViewsTest(TestCase):
def test_signup_page_renders(self):
response = self.client.get(reverse("accounts:signup"))
self.assertEqual(response.status_code, 200)
def test_signup_creates_account_and_logs_in(self):
response = self.client.post(
reverse("accounts:signup"),
data={
"username": "newclient",
"first_name": "Grace",
"last_name": "Hopper",
"email": "grace@example.com",
"company": "",
"phone": "",
"timezone": "UTC",
"password1": "Str0ngPass!word",
"password2": "Str0ngPass!word",
},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("projects:dashboard"))
self.assertTrue(User.objects.filter(username="newclient").exists())
def test_login_required_for_profile(self):
response = self.client.get(reverse("accounts:profile"))
self.assertEqual(response.status_code, 302)
self.assertIn(reverse("accounts:login"), response.url)
def test_logout_requires_post_and_clears_session(self):
self.client.login(username="newclient", password="Str0ngPass!word")
response = self.client.get(reverse("accounts:logout"))
self.assertEqual(response.status_code, 405)
response = self.client.post(reverse("accounts:logout"))
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, reverse("pages:home"))
response = self.client.get(reverse("projects:dashboard"))
self.assertEqual(response.status_code, 302)
self.assertIn(reverse("accounts:login"), response.url)
+99
View File
@@ -0,0 +1,99 @@
from unittest.mock import MagicMock
from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from django.urls import reverse
from apps.accounts.adapters import TecvicoSocialAccountAdapter
from apps.accounts.social import enabled_social_providers
class SocialAuthHelpersTest(TestCase):
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"google": {"APP": {"client_id": "g-id", "secret": "g-secret"}},
"github": {"APP": {"client_id": "", "secret": ""}},
}
)
def test_enabled_social_providers_filters_missing_credentials(self):
providers = enabled_social_providers()
self.assertEqual(len(providers), 1)
self.assertEqual(providers[0]["id"], "google")
class SocialAuthViewsTest(TestCase):
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"google": {"APP": {"client_id": "g-id", "secret": "g-secret"}},
"github": {"APP": {"client_id": "gh-id", "secret": "gh-secret"}},
}
)
def test_login_page_renders_social_buttons(self):
response = self.client.get(reverse("accounts:login"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Continue with")
self.assertContains(response, "Google")
self.assertContains(response, "GitHub")
self.assertContains(response, "/accounts/google/login/")
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"google": {"APP": {"client_id": "", "secret": ""}},
"github": {"APP": {"client_id": "", "secret": ""}},
}
)
def test_login_page_hides_social_buttons_without_credentials(self):
response = self.client.get(reverse("accounts:login"))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "Continue with")
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"google": {"APP": {"client_id": "g-id", "secret": "g-secret"}},
"github": {"APP": {"client_id": "gh-id", "secret": "gh-secret"}},
}
)
def test_signup_page_renders_social_buttons(self):
response = self.client.get(reverse("accounts:signup"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Google")
self.assertContains(response, "GitHub")
@override_settings(
SOCIALACCOUNT_PROVIDERS={
"google": {"APP": {"client_id": "g-id", "secret": "g-secret"}},
"github": {"APP": {"client_id": "gh-id", "secret": "gh-secret"}},
}
)
def test_google_login_page_uses_portal_styles(self):
response = self.client.get(reverse("google_login"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Sign in with Google")
self.assertContains(response, "social-oauth-card")
self.assertContains(response, "navbar")
self.assertContains(response, "Continue to Google")
class TecvicoSocialAccountAdapterTest(TestCase):
def test_apply_profile_names_from_google(self):
user = User(username="oauth-user", email="oauth@example.com")
sociallogin = MagicMock()
sociallogin.account.provider = "google"
sociallogin.account.extra_data = {
"given_name": "OAuth",
"family_name": "User",
}
adapter = TecvicoSocialAccountAdapter()
adapter._apply_profile_names(user, sociallogin)
self.assertEqual(user.first_name, "OAuth")
self.assertEqual(user.last_name, "User")
def test_apply_profile_names_from_github(self):
user = User(username="gh-user", email="gh@example.com")
sociallogin = MagicMock()
sociallogin.account.provider = "github"
sociallogin.account.extra_data = {"name": "Grace Hopper"}
adapter = TecvicoSocialAccountAdapter()
adapter._apply_profile_names(user, sociallogin)
self.assertEqual(user.first_name, "Grace")
self.assertEqual(user.last_name, "Hopper")
+45
View File
@@ -0,0 +1,45 @@
from django.contrib.auth import views as auth_views
from django.urls import path
from . import views
app_name = "accounts"
urlpatterns = [
path("signup/", views.SignUpView.as_view(), name="signup"),
path("login/", views.ClientLoginView.as_view(), name="login"),
path("logout/", views.ClientLogoutView.as_view(), name="logout"),
path("profile/", views.ProfileView.as_view(), name="profile"),
path(
"password-reset/",
auth_views.PasswordResetView.as_view(
template_name="accounts/password_reset.html",
email_template_name="accounts/emails/password_reset_email.txt",
subject_template_name="accounts/emails/password_reset_subject.txt",
success_url="/accounts/password-reset/done/",
),
name="password_reset",
),
path(
"password-reset/done/",
auth_views.PasswordResetDoneView.as_view(
template_name="accounts/password_reset_done.html",
),
name="password_reset_done",
),
path(
"password-reset/<uidb64>/<token>/",
auth_views.PasswordResetConfirmView.as_view(
template_name="accounts/password_reset_confirm.html",
success_url="/accounts/password-reset/complete/",
),
name="password_reset_confirm",
),
path(
"password-reset/complete/",
auth_views.PasswordResetCompleteView.as_view(
template_name="accounts/password_reset_complete.html",
),
name="password_reset_complete",
),
]
+75
View File
@@ -0,0 +1,75 @@
from django.contrib import messages
from django.contrib.auth import login
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.views import LoginView, LogoutView
from django.urls import reverse_lazy
from django.views.generic import CreateView, FormView
from .forms import ClientLoginForm, ClientProfileForm, SignUpForm
from .models import ClientProfile
class SignUpView(CreateView):
form_class = SignUpForm
template_name = "accounts/signup.html"
success_url = reverse_lazy("projects:dashboard")
def dispatch(self, request, *args, **kwargs):
if request.user.is_authenticated:
return self.redirect_authenticated()
return super().dispatch(request, *args, **kwargs)
def redirect_authenticated(self):
from django.shortcuts import redirect
return redirect("projects:dashboard")
def form_valid(self, form):
response = super().form_valid(form)
login(self.request, self.object, backend="django.contrib.auth.backends.ModelBackend")
messages.success(self.request, "Welcome! Your client account is ready.")
return response
class ClientLoginView(LoginView):
form_class = ClientLoginForm
template_name = "accounts/login.html"
redirect_authenticated_user = True
def get_success_url(self):
return self.request.GET.get("next") or reverse_lazy("projects:dashboard")
class ClientLogoutView(LogoutView):
next_page = reverse_lazy("pages:home")
http_method_names = ["post", "options"]
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
if request.method.lower() == "post":
messages.success(request, "You have been logged out.")
return response
class ProfileView(LoginRequiredMixin, FormView):
form_class = ClientProfileForm
template_name = "accounts/profile.html"
success_url = reverse_lazy("accounts:profile")
def get_form_kwargs(self):
kwargs = super().get_form_kwargs()
kwargs["user"] = self.request.user
profile, _ = ClientProfile.objects.get_or_create(user=self.request.user)
kwargs["instance"] = profile
return kwargs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
profile, _ = ClientProfile.objects.get_or_create(user=self.request.user)
ctx["profile"] = profile
return ctx
def form_valid(self, form):
form.save()
messages.success(self.request, "Profile updated.")
return super().form_valid(form)
@@ -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),
),
]
+2
View File
@@ -16,6 +16,8 @@ RESERVED_PAGE_SLUGS = frozenset({
"faq", "faq",
"home", "home",
"products", "products",
"projects",
"accounts",
"static", "static",
"media", "media",
}) })
+76
View File
@@ -1,3 +1,5 @@
from datetime import date
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
@@ -41,6 +43,80 @@ class HomeViewTest(TestCase):
self.assertIn("the Way in Development", content) self.assertIn("the Way in Development", content)
self.assertIn('data-project-filter="all"', 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): class AboutViewTest(TestCase):
def test_about_returns_200(self): def test_about_returns_200(self):
+5
View File
@@ -7,6 +7,7 @@ from django.views import View
from django.views.generic import DetailView, ListView, TemplateView from django.views.generic import DetailView, ListView, TemplateView
from apps.products.models import MainProduct, SubProduct from apps.products.models import MainProduct, SubProduct
from apps.projects.models import ProjectBrief
from .forms import ContactForm from .forms import ContactForm
from .models import ( from .models import (
@@ -50,6 +51,10 @@ class HomeView(TemplateView):
.order_by("homepage_order", "order") .order_by("homepage_order", "order")
) )
ctx["homepage_products_catalog"] = _homepage_products_catalog_queryset() 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 return ctx
+1
View File
@@ -0,0 +1 @@
+171
View File
@@ -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",)
+10
View File
@@ -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
+114
View File
@@ -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
+1
View File
@@ -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
+71
View File
@@ -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.'),
),
]
+329
View File
@@ -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}"
+58
View File
@@ -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,
)
+28
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
+212
View File
@@ -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 &amp; 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")
+21
View File
@@ -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}"
+12
View File
@@ -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"),
]
+127
View File
@@ -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
View File
@@ -18,17 +18,28 @@ DJANGO_APPS = [
"django.contrib.contenttypes", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.sites",
"whitenoise.runserver_nostatic", "whitenoise.runserver_nostatic",
"django.contrib.staticfiles", "django.contrib.staticfiles",
] ]
THIRD_PARTY_APPS = [
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.google",
"allauth.socialaccount.providers.github",
]
LOCAL_APPS = [ LOCAL_APPS = [
"apps.core", "apps.core",
"apps.products", "apps.products",
"apps.pages", "apps.pages",
"apps.accounts",
"apps.projects",
] ]
INSTALLED_APPS = DJANGO_APPS + LOCAL_APPS INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
@@ -39,6 +50,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
] ]
ROOT_URLCONF = "config.urls" ROOT_URLCONF = "config.urls"
@@ -57,6 +69,7 @@ TEMPLATES = [
"apps.core.context_processors.site_branding", "apps.core.context_processors.site_branding",
"apps.core.context_processors.site_contact", "apps.core.context_processors.site_contact",
"apps.core.context_processors.navigation", "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_SIZE = 10 * 1024 * 1024
CONTACT_ATTACHMENT_MAX_COUNT = 3 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" DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGGING = { LOGGING = {
+2
View File
@@ -8,6 +8,8 @@ from .base import * # noqa: F401, F403, E402
DEBUG = True DEBUG = True
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
SECRET_KEY = os.environ.get( SECRET_KEY = os.environ.get(
"DJANGO_SECRET_KEY", "DJANGO_SECRET_KEY",
"django-insecure-development-key-not-for-production-use", "django-insecure-development-key-not-for-production-use",
+1
View File
@@ -1,3 +1,4 @@
from .development import * # noqa: F401, F403 from .development import * # noqa: F401, F403
DATABASES["default"]["CONN_MAX_AGE"] = 0 # noqa: F405 DATABASES["default"]["CONN_MAX_AGE"] = 0 # noqa: F405
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
+3
View File
@@ -11,6 +11,9 @@ admin.site.index_title = "Welcome to Tecvico Administration"
urlpatterns = [ urlpatterns = [
path("admin/", admin.site.urls), 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("products/", include("apps.products.urls", namespace="products")),
path("", include("apps.pages.urls", namespace="pages")), path("", include("apps.pages.urls", namespace="pages")),
] ]
+256
View File
@@ -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 23 add quoting, payments, and a client portal — all standard Django.
---
## Plan 1 — Simple (MVP) · 36 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 | 12 | Structured submission form + attachments |
| M3 Status tracking | 23 | Client sees request status; email notifications |
| M4 Admin intake | 34 | 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) · 12 months
**Goal:** Quote, pay, and deliver on-platform. Productized packages + custom projects.
| Milestone | Weeks | Deliverable |
| ------------- | ----- | ----------------------------------------------------- |
| M1 Foundation | 12 | Plan 1 + email verify, service catalog pages |
| M2 Quotes | 34 | Admin builds quote; client accepts/declines in portal |
| M3 Payments | 56 | Stripe deposit + milestone invoices |
| M4 Delivery | 78 | Milestones, deliverable uploads, revision rounds |
| M5 Comms | 910 | 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. 3050%); 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) · 23 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 | 12 | E-sign, ballpark estimator, kickoff booking |
| M3 Delivery ops | 23 | SLA timers, internal time log, task checklist |
| M4 Enterprise | 34 | Multi-user orgs, API, webhooks |
| M5 Growth | 45 | 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%.*
+241
View File
@@ -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 12 are a natural extension. Plan 3 adds services (Stripe Connect, WebSockets, background jobs) but stays on the same project.
---
## Plan 1 — Simple (MVP) · 35 weeks
**Goal:** List projects, apply, message. Off-platform payment OK.
| Milestone | Weeks | Deliverable |
| ------------------ | ----- | ----------------------------------------------------------- |
| M1 Auth & profiles | 12 | Registration, login, password reset, client/freelancer role |
| M2 Listings | 34 | Project CRUD, categories, search/filters |
| M3 Proposals | 56 | Apply, withdraw, client shortlist |
| M4 Comms & admin | 78 | 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) · 23 months
**Goal:** Hire and pay on-platform. Gigs (Fiverr) + custom projects (Upwork).
| Milestone | Weeks | Deliverable |
| ------------- | ----- | ----------------------------------------------------------- |
| M1 Foundation | 14 | Plan 1 + email verify, profile completeness, S3/local media |
| M2 Gigs | 58 | Service listings with 3 tiers |
| M3 Contracts | 912 | Award, milestones, deliverables |
| M4 Payments | 1316 | Stripe escrow, release, platform fee |
| M5 Trust | 1720 | 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. 1020%)
- Refund rules: cancelled before start; partial on dispute
- PDF invoice/receipt per transaction
**Trust**
- Double-blind reviews after completion (15 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) · 46 months
**Goal:** Production marketplace with discovery, compliance, and scale.
| Milestone | Months | Deliverable |
| ----------------- | ------ | ----------------------------------------- |
| M1 Core hardening | 12 | Plan 2 + 2FA, OAuth, rate limits |
| M2 Discovery | 34 | Search, recommendations, skill tests |
| M3 Collaboration | 56 | Time tracking, workspace, real-time chat |
| M4 Payments+ | 78 | Stripe Connect, multi-currency, KYC hooks |
| M5 Trust & safety | 910 | Auto disputes, moderation, fraud signals |
| M6 Growth | 1112 | 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%.*
+4
View File
@@ -5,3 +5,7 @@ whitenoise[brotli]>=6.7.0
Pillow>=10.4.0 Pillow>=10.4.0
python-dotenv>=1.0.1 python-dotenv>=1.0.1
markdown>=3.6 markdown>=3.6
django-allauth>=65.0.0
requests>=2.32.0
PyJWT>=2.9.0
cryptography>=43.0.0
+165
View File
@@ -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;
}
}
+432
View File
@@ -394,6 +394,23 @@ h1, h2, h3, h4, h5, h6 {
padding: 0.5rem 1.25rem; 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 { .nav-arrow {
transition: transform var(--navbar-expand-duration) var(--navbar-expand-ease); 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); 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 Experience / Benefits
============================================================ */ ============================================================ */
@@ -3838,3 +3888,385 @@ a.citation-count-badge:hover {
height: 150px; 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);
}
}
+207
View File
@@ -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);
+20
View File
@@ -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
+55
View File
@@ -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 %}
+24
View File
@@ -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 %}
+64
View File
@@ -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 %}
+96
View File
@@ -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 %}
+2
View File
@@ -17,6 +17,8 @@
{% include "partials/_navbar.html" %} {% include "partials/_navbar.html" %}
{% include "partials/_messages.html" %}
<main id="main-content"> <main id="main-content">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</main> </main>
+1 -1
View File
@@ -37,6 +37,6 @@
</div> </div>
</section> </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 %} {% endblock %}
+7
View File
@@ -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 %}
+30
View File
@@ -90,6 +90,36 @@
</a> </a>
</li> </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> </ul>
</div> </div>
+52
View File
@@ -245,6 +245,58 @@
</article> </article>
{% endif %} {% endif %}
{% endfor %} {% 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>
</div> </div>
</section> </section>
+74
View File
@@ -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 &amp; 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 %}
View File
+150
View File
@@ -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 &amp; 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 &amp; 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 %}
+70
View File
@@ -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 %}
+80
View File
@@ -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 %}
+57
View File
@@ -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 %}