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_EMAIL=admin@yourdomain.com
DJANGO_SUPERUSER_PASSWORD=bS7_U_uRTmivj7W-lCR5
DEFAULT_FROM_EMAIL=noreply@tecvico.com
MANAGED_PROJECTS_NOTIFY_EMAILS=admin@yourdomain.com
SITE_DOMAIN=localhost:8000
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
+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",
"home",
"products",
"projects",
"accounts",
"static",
"media",
})
+76
View File
@@ -1,3 +1,5 @@
from datetime import date
from django.test import TestCase
from django.urls import reverse
@@ -41,6 +43,80 @@ class HomeViewTest(TestCase):
self.assertIn("the Way in Development", content)
self.assertIn('data-project-filter="all"', content)
def test_home_renders_published_project_briefs(self):
from django.contrib.auth.models import User
from apps.accounts.models import ClientProfile
from apps.pages.models import HomepageSection
from apps.projects.models import ProjectBrief
user = User.objects.create_user(username="client", email="c@example.com", password="x")
ClientProfile.objects.create(user=user, timezone="UTC")
HomepageSection.objects.create(
section_type=HomepageSection.TYPE_PROJECTS,
title="Showcase",
order=1,
is_active=True,
)
ProjectBrief.objects.create(
client=user,
title="Private title",
category=ProjectBrief.CATEGORY_WEB,
description="Full description for the project.",
budget_range=ProjectBrief.BUDGET_5K_15K,
show_on_website=True,
public_title="Public showcase title",
public_summary="A curated summary for the website.",
public_project_status="new",
public_tags="Django, Portal",
show_category_on_website=False,
)
response = self.client.get(reverse("pages:home"))
self.assertContains(response, "Public showcase title")
self.assertContains(response, "A curated summary for the website.")
self.assertContains(response, "Django")
self.assertNotContains(response, "Private title")
def test_home_respects_per_field_website_visibility(self):
from django.contrib.auth.models import User
from apps.accounts.models import ClientProfile
from apps.pages.models import HomepageSection
from apps.projects.models import ProjectBrief
user = User.objects.create_user(username="c2", email="c2@example.com", password="x")
ClientProfile.objects.create(user=user, timezone="UTC")
HomepageSection.objects.create(
section_type=HomepageSection.TYPE_PROJECTS,
title="Showcase",
order=1,
is_active=True,
)
ProjectBrief.objects.create(
client=user,
title="Visible project",
category=ProjectBrief.CATEGORY_DATA,
description="Secret internal description.",
budget_range=ProjectBrief.BUDGET_15K_50K,
desired_deadline=date(2026, 12, 1),
reference_links="https://example.com/spec",
show_on_website=True,
public_summary="Public summary.",
show_budget_on_website=True,
show_deadline_on_website=True,
show_references_on_website=True,
show_description_on_website=False,
show_category_on_website=False,
)
response = self.client.get(reverse("pages:home"))
content = response.content.decode()
self.assertIn("$15,000", content)
self.assertIn("Dec 1, 2026", content)
self.assertIn("https://example.com/spec", content)
self.assertNotIn("Secret internal description.", content)
class AboutViewTest(TestCase):
def test_about_returns_200(self):
+5
View File
@@ -7,6 +7,7 @@ from django.views import View
from django.views.generic import DetailView, ListView, TemplateView
from apps.products.models import MainProduct, SubProduct
from apps.projects.models import ProjectBrief
from .forms import ContactForm
from .models import (
@@ -50,6 +51,10 @@ class HomeView(TemplateView):
.order_by("homepage_order", "order")
)
ctx["homepage_products_catalog"] = _homepage_products_catalog_queryset()
ctx["website_project_briefs"] = (
ProjectBrief.objects.filter(show_on_website=True)
.order_by("-submitted_at")
)
return ctx
+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.sessions",
"django.contrib.messages",
"django.contrib.sites",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
]
THIRD_PARTY_APPS = [
"allauth",
"allauth.account",
"allauth.socialaccount",
"allauth.socialaccount.providers.google",
"allauth.socialaccount.providers.github",
]
LOCAL_APPS = [
"apps.core",
"apps.products",
"apps.pages",
"apps.accounts",
"apps.projects",
]
INSTALLED_APPS = DJANGO_APPS + LOCAL_APPS
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
@@ -39,6 +50,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"allauth.account.middleware.AccountMiddleware",
]
ROOT_URLCONF = "config.urls"
@@ -57,6 +69,7 @@ TEMPLATES = [
"apps.core.context_processors.site_branding",
"apps.core.context_processors.site_contact",
"apps.core.context_processors.navigation",
"apps.accounts.context_processors.social_auth",
],
},
},
@@ -103,6 +116,60 @@ CONTACT_UPLOAD_ROOT = BASE_DIR / "private_uploads" / "contact"
CONTACT_ATTACHMENT_MAX_SIZE = 10 * 1024 * 1024
CONTACT_ATTACHMENT_MAX_COUNT = 3
PROJECT_UPLOAD_ROOT = BASE_DIR / "private_uploads" / "projects"
PROJECT_ATTACHMENT_MAX_SIZE = CONTACT_ATTACHMENT_MAX_SIZE
PROJECT_ATTACHMENT_MAX_COUNT = CONTACT_ATTACHMENT_MAX_COUNT
LOGIN_URL = "/accounts/login/"
LOGIN_REDIRECT_URL = "/projects/"
LOGOUT_REDIRECT_URL = "/"
SITE_ID = 1
SITE_DOMAIN = os.environ.get("SITE_DOMAIN", "localhost:8000")
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"allauth.account.auth_backends.AuthenticationBackend",
]
ACCOUNT_ADAPTER = "apps.accounts.adapters.TecvicoAccountAdapter"
SOCIALACCOUNT_ADAPTER = "apps.accounts.adapters.TecvicoSocialAccountAdapter"
ACCOUNT_EMAIL_VERIFICATION = "none"
ACCOUNT_LOGIN_ON_GET = False
ACCOUNT_LOGOUT_ON_GET = False
SOCIALACCOUNT_AUTO_SIGNUP = True
SOCIALACCOUNT_LOGIN_ON_GET = False
SOCIALACCOUNT_EMAIL_AUTHENTICATION = True
SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True
GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")
GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "")
GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", "")
GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET", "")
SOCIALACCOUNT_PROVIDERS = {
"google": {
"SCOPE": ["profile", "email"],
"AUTH_PARAMS": {"access_type": "online"},
"OAUTH_PKCE_ENABLED": True,
"APP": {
"client_id": GOOGLE_CLIENT_ID,
"secret": GOOGLE_CLIENT_SECRET,
"key": "",
},
},
"github": {
"SCOPE": ["user:email"],
"APP": {
"client_id": GITHUB_CLIENT_ID,
"secret": GITHUB_CLIENT_SECRET,
},
},
}
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "noreply@tecvico.com")
MANAGED_PROJECTS_NOTIFY_EMAILS = os.environ.get("MANAGED_PROJECTS_NOTIFY_EMAILS", "")
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
LOGGING = {
+2
View File
@@ -8,6 +8,8 @@ from .base import * # noqa: F401, F403, E402
DEBUG = True
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
SECRET_KEY = os.environ.get(
"DJANGO_SECRET_KEY",
"django-insecure-development-key-not-for-production-use",
+1
View File
@@ -1,3 +1,4 @@
from .development import * # noqa: F401, F403
DATABASES["default"]["CONN_MAX_AGE"] = 0 # noqa: F405
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
+3
View File
@@ -11,6 +11,9 @@ admin.site.index_title = "Welcome to Tecvico Administration"
urlpatterns = [
path("admin/", admin.site.urls),
path("accounts/", include("apps.accounts.urls", namespace="accounts")),
path("accounts/", include("allauth.urls")),
path("projects/", include("apps.projects.urls", namespace="projects")),
path("products/", include("apps.products.urls", namespace="products")),
path("", include("apps.pages.urls", namespace="pages")),
]
+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
python-dotenv>=1.0.1
markdown>=3.6
django-allauth>=65.0.0
requests>=2.32.0
PyJWT>=2.9.0
cryptography>=43.0.0
+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;
}
.nav-logout-form {
margin: 0;
}
.nav-link-button {
border: none;
background: transparent;
cursor: pointer;
font-family: inherit;
}
.nav-link-button:hover,
.nav-link-button:focus-visible {
color: var(--accent-blue);
background: transparent;
}
.nav-arrow {
transition: transform var(--navbar-expand-duration) var(--navbar-expand-ease);
}
@@ -936,6 +953,39 @@ h1, h2, h3, h4, h5, h6 {
border-radius: var(--radius-pill);
}
.project-card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin: 0.5rem 0 0;
font-size: 0.8rem;
color: var(--text-secondary);
}
.project-card-links {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
display: grid;
gap: 0.25rem;
}
.project-card-links a {
font-size: 0.8rem;
color: var(--accent-blue);
word-break: break-all;
}
.project-card-attachments {
list-style: none;
margin: 0.5rem 0 0;
padding: 0;
display: grid;
gap: 0.25rem;
font-size: 0.78rem;
color: var(--text-secondary);
}
/* ============================================================
Experience / Benefits
============================================================ */
@@ -3838,3 +3888,385 @@ a.citation-count-badge:hover {
height: 150px;
}
}
.container--narrow {
max-width: 720px;
}
.portal-card {
padding: 2rem;
}
.portal-footer-text,
.portal-intro {
margin-top: 1.25rem;
color: var(--text-secondary);
font-size: 0.92rem;
}
.portal-footer-text a,
.portal-link {
color: var(--accent-blue);
font-weight: 600;
text-decoration: none;
}
.portal-footer-text a:hover,
.portal-link:hover {
text-decoration: underline;
}
.form-grid--2 {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.form-errors {
margin-bottom: 1rem;
padding: 0.85rem 1rem;
border-radius: var(--radius-sm);
background: rgba(239, 68, 68, 0.1);
color: #b91c1c;
}
.flash-messages {
position: sticky;
top: 72px;
z-index: 90;
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 1rem;
}
.flash-message {
max-width: 960px;
margin: 0 auto;
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
font-size: 0.9rem;
font-weight: 600;
}
.flash-message--success,
.flash-message--info {
background: rgba(16, 185, 129, 0.12);
color: #047857;
border: 1px solid rgba(16, 185, 129, 0.25);
}
.flash-message--error {
background: rgba(239, 68, 68, 0.12);
color: #b91c1c;
border: 1px solid rgba(239, 68, 68, 0.25);
}
.page-hero-content--row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
flex-wrap: wrap;
}
.portal-table-wrap {
overflow-x: auto;
padding: 0;
}
.portal-table {
width: 100%;
border-collapse: collapse;
}
.portal-table th,
.portal-table td {
padding: 1rem 1.25rem;
text-align: left;
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
}
.portal-table th {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
}
.status-pill {
display: inline-flex;
align-items: center;
padding: 0.2rem 0.65rem;
border-radius: 999px;
font-size: 0.78rem;
font-weight: 700;
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
}
.status-pill--submitted { background: rgba(59, 130, 246, 0.12); color: #1d4ed8; }
.status-pill--under_review { background: rgba(139, 92, 246, 0.12); color: #6d28d9; }
.status-pill--quoted { background: rgba(245, 158, 11, 0.15); color: #b45309; }
.status-pill--accepted { background: rgba(16, 185, 129, 0.15); color: #047857; }
.status-pill--declined { background: rgba(239, 68, 68, 0.12); color: #b91c1c; }
.status-pill--in_progress { background: rgba(6, 182, 212, 0.15); color: #0e7490; }
.status-pill--delivered { background: rgba(34, 197, 94, 0.15); color: #15803d; }
.status-pill--closed { background: rgba(107, 114, 128, 0.15); color: #374151; }
.portal-empty {
text-align: center;
}
.portal-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
margin-top: 1.5rem;
color: var(--text-secondary);
}
.portal-detail-grid {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: 1.5rem;
}
.portal-section-title {
font-size: 1.2rem;
margin-bottom: 1rem;
}
.portal-subtitle {
margin-top: 1.25rem;
margin-bottom: 0.5rem;
font-size: 0.95rem;
}
.portal-body-text {
color: var(--text-secondary);
line-height: 1.6;
}
.portal-meta-list {
display: grid;
gap: 0.75rem;
}
.portal-meta-list dt {
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary);
}
.portal-meta-list dd {
margin: 0.15rem 0 0;
font-weight: 600;
}
.portal-actions {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.portal-attachment-list ul {
margin: 0.5rem 0 0;
padding-left: 1.2rem;
}
@media (max-width: 768px) {
.form-grid--2,
.portal-detail-grid {
grid-template-columns: 1fr;
}
}
.social-auth {
margin-bottom: 1.25rem;
}
.social-auth-label {
margin: 0 0 0.75rem;
font-size: 0.86rem;
font-weight: 600;
color: var(--text-secondary);
text-align: center;
}
.social-auth-buttons {
display: grid;
gap: 0.75rem;
}
.social-auth-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.65rem;
width: 100%;
padding: 0.75rem 1rem;
border-radius: var(--radius-sm);
border: 1px solid rgba(148, 163, 184, 0.35);
background: #ffffff;
color: var(--text-primary);
font-weight: 600;
text-decoration: none;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.social-auth-btn:hover {
border-color: var(--accent-blue);
box-shadow: 0 0 0 3px rgba(48, 81, 255, 0.1);
transform: translateY(-1px);
}
.social-auth-btn--google .social-auth-icon {
color: #ea4335;
}
.social-auth-btn--github .social-auth-icon {
color: #24292f;
}
.social-auth-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.35rem;
height: 1.35rem;
font-size: 0.72rem;
font-weight: 800;
}
.portal-divider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 1.25rem 0;
color: var(--text-secondary);
font-size: 0.82rem;
}
.portal-divider::before,
.portal-divider::after {
content: "";
flex: 1;
height: 1px;
background: rgba(148, 163, 184, 0.35);
}
.social-oauth-card {
display: grid;
gap: 1.25rem;
}
.social-oauth-provider {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
border-radius: var(--radius-sm);
background: rgba(48, 81, 255, 0.04);
border: 1px solid rgba(48, 81, 255, 0.12);
}
.social-oauth-provider--google {
background: rgba(234, 67, 53, 0.06);
border-color: rgba(234, 67, 53, 0.18);
}
.social-oauth-provider--github {
background: rgba(36, 41, 47, 0.05);
border-color: rgba(36, 41, 47, 0.15);
}
.social-oauth-provider-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
border-radius: 999px;
font-size: 0.95rem;
font-weight: 800;
background: #ffffff;
border: 1px solid rgba(148, 163, 184, 0.35);
flex-shrink: 0;
}
.social-oauth-provider--google .social-oauth-provider-icon {
color: #ea4335;
}
.social-oauth-provider--github .social-oauth-provider-icon {
color: #24292f;
}
.social-oauth-provider-title {
margin: 0 0 0.35rem;
font-size: 1.15rem;
}
.social-oauth-provider-copy {
margin: 0;
color: var(--text-secondary);
line-height: 1.5;
font-size: 0.92rem;
}
.social-oauth-steps {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.55rem;
}
.social-oauth-steps li {
position: relative;
padding-left: 1.35rem;
color: var(--text-secondary);
font-size: 0.9rem;
}
.social-oauth-steps li::before {
content: "";
position: absolute;
left: 0;
top: 0.55rem;
width: 0.45rem;
height: 0.45rem;
border-radius: 999px;
background: var(--accent-blue);
}
.social-oauth-form {
margin-top: 0.25rem;
}
.social-oauth-continue {
width: 100%;
}
.social-oauth-redirect {
text-align: center;
}
.social-oauth-spinner {
width: 2.5rem;
height: 2.5rem;
margin: 0 auto 1rem;
border-radius: 999px;
border: 3px solid rgba(48, 81, 255, 0.15);
border-top-color: var(--accent-blue);
animation: social-oauth-spin 0.9s linear infinite;
}
@keyframes social-oauth-spin {
to {
transform: rotate(360deg);
}
}
+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/_messages.html" %}
<main id="main-content">
{% block content %}{% endblock %}
</main>
+1 -1
View File
@@ -37,6 +37,6 @@
</div>
</section>
{% include "partials/_page_sections.html" with sections=homepage_sections %}
{% include "partials/_page_sections.html" with sections=homepage_sections website_project_briefs=website_project_briefs %}
{% endblock %}
+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>
</li>
{% if user.is_authenticated %}
<li class="nav-item">
<a href="{% url 'projects:dashboard' %}" class="nav-link {% if request.resolver_match.namespace == 'projects' %}active{% endif %}">
My Projects
</a>
</li>
<li class="nav-item">
<a href="{% url 'accounts:profile' %}" class="nav-link {% if request.resolver_match.url_name == 'profile' %}active{% endif %}">
Profile
</a>
</li>
<li class="nav-item">
<form method="post" action="{% url 'accounts:logout' %}" class="nav-logout-form">
{% csrf_token %}
<button type="submit" class="nav-link nav-link-button">Logout</button>
</form>
</li>
{% else %}
<li class="nav-item">
<a href="{% url 'accounts:login' %}" class="nav-link {% if request.resolver_match.url_name == 'login' %}active{% endif %}">
Login
</a>
</li>
<li class="nav-item">
<a href="{% url 'accounts:signup' %}" class="nav-link nav-link--cta {% if request.resolver_match.url_name == 'signup' %}active{% endif %}">
Start a Project
</a>
</li>
{% endif %}
</ul>
</div>
+52
View File
@@ -245,6 +245,58 @@
</article>
{% endif %}
{% endfor %}
{% if website_project_briefs %}
{% for brief in website_project_briefs %}
{% if brief.public_url %}
<a href="{{ brief.public_url }}" class="project-card glass-card fade-in" data-project-status="{{ brief.public_project_status|default:'all' }}"{% if brief.public_url|slice:":4" == "http" %} target="_blank" rel="noopener noreferrer"{% endif %}>
{% else %}
<article class="project-card glass-card fade-in" data-project-status="{{ brief.public_project_status|default:'all' }}">
{% endif %}
{% if brief.public_image %}
<div class="project-card-image">
<img src="{{ brief.public_image.url }}" alt="{{ brief.public_display_title|default:brief.title }}" loading="lazy" />
</div>
{% endif %}
<div class="project-card-body">
{% if brief.public_display_title %}<h3 class="project-card-title">{{ brief.public_display_title }}</h3>{% endif %}
{% if brief.public_display_summary %}
<p class="project-card-desc" data-readmore="120">{{ brief.public_display_summary }}</p>
{% endif %}
{% if brief.public_budget_display or brief.public_deadline_display %}
<p class="project-card-meta">
{% if brief.public_budget_display %}<span>Budget: {{ brief.public_budget_display }}</span>{% endif %}
{% if brief.public_deadline_display %}<span>Deadline: {{ brief.public_deadline_display }}</span>{% endif %}
</p>
{% endif %}
{% if brief.public_tag_list %}
<ul class="project-card-tags" role="list">
{% for tag in brief.public_tag_list %}
<li>{{ tag }}</li>
{% endfor %}
</ul>
{% endif %}
{% if brief.public_reference_link_list %}
<ul class="project-card-links" role="list">
{% for link in brief.public_reference_link_list %}
<li><a href="{{ link }}" target="_blank" rel="noopener noreferrer">{{ link }}</a></li>
{% endfor %}
</ul>
{% endif %}
{% if brief.public_attachment_filename_list %}
<ul class="project-card-attachments" role="list">
{% for filename in brief.public_attachment_filename_list %}
<li>{{ filename }}</li>
{% endfor %}
</ul>
{% endif %}
</div>
{% if brief.public_url %}
</a>
{% else %}
</article>
{% endif %}
{% endfor %}
{% endif %}
</div>
</div>
</section>
+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 %}