From 1042fba121fc977e9187bba8dfca80be9c773f9b Mon Sep 17 00:00:00 2001 From: mohamad Date: Sun, 19 Jul 2026 14:31:28 +0330 Subject: [PATCH] feat: admin panel enrichment --- .env.example | 9 + apps/accounts/__init__.py | 1 + apps/accounts/adapters.py | 39 ++ apps/accounts/admin.py | 10 + apps/accounts/apps.py | 26 ++ apps/accounts/context_processors.py | 12 + apps/accounts/forms.py | 102 +++++ apps/accounts/migrations/0001_initial.py | 33 ++ apps/accounts/migrations/__init__.py | 0 apps/accounts/models.py | 47 ++ apps/accounts/signals.py | 10 + apps/accounts/social.py | 15 + apps/accounts/tests/__init__.py | 1 + apps/accounts/tests/test_accounts.py | 72 +++ apps/accounts/tests/test_social_auth.py | 99 ++++ apps/accounts/urls.py | 45 ++ apps/accounts/views.py | 75 +++ .../0003_alter_sitebranding_border_color.py | 18 + apps/pages/models.py | 2 + apps/pages/tests/test_views.py | 76 +++ apps/pages/views.py | 5 + apps/projects/__init__.py | 1 + apps/projects/admin.py | 171 +++++++ apps/projects/apps.py | 10 + apps/projects/forms.py | 114 +++++ apps/projects/management/__init__.py | 1 + apps/projects/management/commands/__init__.py | 1 + .../management/commands/seed_portal_data.py | 235 ++++++++++ apps/projects/migrations/0001_initial.py | 71 +++ .../0002_website_showcase_fields.py | 68 +++ .../0003_merge_goals_and_lock_visibility.py | 96 ++++ .../migrations/0004_optional_brief_fields.py | 28 ++ apps/projects/migrations/__init__.py | 0 apps/projects/models.py | 329 +++++++++++++ apps/projects/notifications.py | 58 +++ apps/projects/signals.py | 28 ++ apps/projects/tests/__init__.py | 1 + apps/projects/tests/test_views.py | 212 +++++++++ apps/projects/uploads.py | 21 + apps/projects/urls.py | 12 + apps/projects/views.py | 127 +++++ config/settings/base.py | 69 ++- config/settings/development.py | 2 + config/settings/test.py | 1 + config/urls.py | 3 + docs/managed-projects-roadmap.md | 256 +++++++++++ docs/marketplace-roadmap.md | 241 ++++++++++ requirements.txt | 4 + static/css/brief-wizard.css | 165 +++++++ static/css/main.css | 432 ++++++++++++++++++ static/js/brief-wizard.js | 207 +++++++++ templates/accounts/_social_auth.html | 20 + .../accounts/emails/password_reset_email.txt | 5 + .../emails/password_reset_subject.txt | 1 + templates/accounts/login.html | 55 +++ templates/accounts/password_reset.html | 24 + .../accounts/password_reset_complete.html | 15 + .../accounts/password_reset_confirm.html | 28 ++ templates/accounts/password_reset_done.html | 15 + templates/accounts/profile.html | 64 +++ templates/accounts/signup.html | 96 ++++ templates/base.html | 2 + templates/pages/home.html | 2 +- templates/partials/_messages.html | 7 + templates/partials/_navbar.html | 30 ++ templates/partials/_page_sections.html | 52 +++ templates/projects/brief_detail.html | 74 +++ templates/projects/brief_form.html | 0 templates/projects/brief_wizard.html | 150 ++++++ templates/projects/dashboard.html | 70 +++ templates/projects/emails/new_brief_admin.txt | 13 + .../projects/emails/status_change_client.txt | 13 + .../socialaccount/authentication_error.html | 32 ++ templates/socialaccount/login.html | 80 ++++ templates/socialaccount/login_cancelled.html | 29 ++ templates/socialaccount/login_redirect.html | 23 + templates/socialaccount/signup.html | 57 +++ 77 files changed, 4616 insertions(+), 2 deletions(-) create mode 100644 apps/accounts/__init__.py create mode 100644 apps/accounts/adapters.py create mode 100644 apps/accounts/admin.py create mode 100644 apps/accounts/apps.py create mode 100644 apps/accounts/context_processors.py create mode 100644 apps/accounts/forms.py create mode 100644 apps/accounts/migrations/0001_initial.py create mode 100644 apps/accounts/migrations/__init__.py create mode 100644 apps/accounts/models.py create mode 100644 apps/accounts/signals.py create mode 100644 apps/accounts/social.py create mode 100644 apps/accounts/tests/__init__.py create mode 100644 apps/accounts/tests/test_accounts.py create mode 100644 apps/accounts/tests/test_social_auth.py create mode 100644 apps/accounts/urls.py create mode 100644 apps/accounts/views.py create mode 100644 apps/core/migrations/0003_alter_sitebranding_border_color.py create mode 100644 apps/projects/__init__.py create mode 100644 apps/projects/admin.py create mode 100644 apps/projects/apps.py create mode 100644 apps/projects/forms.py create mode 100644 apps/projects/management/__init__.py create mode 100644 apps/projects/management/commands/__init__.py create mode 100644 apps/projects/management/commands/seed_portal_data.py create mode 100644 apps/projects/migrations/0001_initial.py create mode 100644 apps/projects/migrations/0002_website_showcase_fields.py create mode 100644 apps/projects/migrations/0003_merge_goals_and_lock_visibility.py create mode 100644 apps/projects/migrations/0004_optional_brief_fields.py create mode 100644 apps/projects/migrations/__init__.py create mode 100644 apps/projects/models.py create mode 100644 apps/projects/notifications.py create mode 100644 apps/projects/signals.py create mode 100644 apps/projects/tests/__init__.py create mode 100644 apps/projects/tests/test_views.py create mode 100644 apps/projects/uploads.py create mode 100644 apps/projects/urls.py create mode 100644 apps/projects/views.py create mode 100644 docs/managed-projects-roadmap.md create mode 100644 docs/marketplace-roadmap.md create mode 100644 static/css/brief-wizard.css create mode 100644 static/js/brief-wizard.js create mode 100644 templates/accounts/_social_auth.html create mode 100644 templates/accounts/emails/password_reset_email.txt create mode 100644 templates/accounts/emails/password_reset_subject.txt create mode 100644 templates/accounts/login.html create mode 100644 templates/accounts/password_reset.html create mode 100644 templates/accounts/password_reset_complete.html create mode 100644 templates/accounts/password_reset_confirm.html create mode 100644 templates/accounts/password_reset_done.html create mode 100644 templates/accounts/profile.html create mode 100644 templates/accounts/signup.html create mode 100644 templates/partials/_messages.html create mode 100644 templates/projects/brief_detail.html create mode 100644 templates/projects/brief_form.html create mode 100644 templates/projects/brief_wizard.html create mode 100644 templates/projects/dashboard.html create mode 100644 templates/projects/emails/new_brief_admin.txt create mode 100644 templates/projects/emails/status_change_client.txt create mode 100644 templates/socialaccount/authentication_error.html create mode 100644 templates/socialaccount/login.html create mode 100644 templates/socialaccount/login_cancelled.html create mode 100644 templates/socialaccount/login_redirect.html create mode 100644 templates/socialaccount/signup.html diff --git a/.env.example b/.env.example index 932ab38..47538a4 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/apps/accounts/__init__.py b/apps/accounts/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/accounts/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/accounts/adapters.py b/apps/accounts/adapters.py new file mode 100644 index 0000000..7d46292 --- /dev/null +++ b/apps/accounts/adapters.py @@ -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 "" diff --git a/apps/accounts/admin.py b/apps/accounts/admin.py new file mode 100644 index 0000000..d57fc59 --- /dev/null +++ b/apps/accounts/admin.py @@ -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",) diff --git a/apps/accounts/apps.py b/apps/accounts/apps.py new file mode 100644 index 0000000..6c46a6f --- /dev/null +++ b/apps/accounts/apps.py @@ -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) diff --git a/apps/accounts/context_processors.py b/apps/accounts/context_processors.py new file mode 100644 index 0000000..a849845 --- /dev/null +++ b/apps/accounts/context_processors.py @@ -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, + } diff --git a/apps/accounts/forms.py b/apps/accounts/forms.py new file mode 100644 index 0000000..6c4ea2a --- /dev/null +++ b/apps/accounts/forms.py @@ -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 diff --git a/apps/accounts/migrations/0001_initial.py b/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..91806ea --- /dev/null +++ b/apps/accounts/migrations/0001_initial.py @@ -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', + }, + ), + ] diff --git a/apps/accounts/migrations/__init__.py b/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/accounts/models.py b/apps/accounts/models.py new file mode 100644 index 0000000..f2a9ec7 --- /dev/null +++ b/apps/accounts/models.py @@ -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 diff --git a/apps/accounts/signals.py b/apps/accounts/signals.py new file mode 100644 index 0000000..f7969f0 --- /dev/null +++ b/apps/accounts/signals.py @@ -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) diff --git a/apps/accounts/social.py b/apps/accounts/social.py new file mode 100644 index 0000000..e38e0f5 --- /dev/null +++ b/apps/accounts/social.py @@ -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 diff --git a/apps/accounts/tests/__init__.py b/apps/accounts/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/accounts/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/accounts/tests/test_accounts.py b/apps/accounts/tests/test_accounts.py new file mode 100644 index 0000000..9f40f01 --- /dev/null +++ b/apps/accounts/tests/test_accounts.py @@ -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) diff --git a/apps/accounts/tests/test_social_auth.py b/apps/accounts/tests/test_social_auth.py new file mode 100644 index 0000000..b4cb30f --- /dev/null +++ b/apps/accounts/tests/test_social_auth.py @@ -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") diff --git a/apps/accounts/urls.py b/apps/accounts/urls.py new file mode 100644 index 0000000..b1e4cd8 --- /dev/null +++ b/apps/accounts/urls.py @@ -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///", + 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", + ), +] diff --git a/apps/accounts/views.py b/apps/accounts/views.py new file mode 100644 index 0000000..ac04235 --- /dev/null +++ b/apps/accounts/views.py @@ -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) diff --git a/apps/core/migrations/0003_alter_sitebranding_border_color.py b/apps/core/migrations/0003_alter_sitebranding_border_color.py new file mode 100644 index 0000000..e8f7500 --- /dev/null +++ b/apps/core/migrations/0003_alter_sitebranding_border_color.py @@ -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), + ), + ] diff --git a/apps/pages/models.py b/apps/pages/models.py index 5dccf1a..eb8ca83 100644 --- a/apps/pages/models.py +++ b/apps/pages/models.py @@ -16,6 +16,8 @@ RESERVED_PAGE_SLUGS = frozenset({ "faq", "home", "products", + "projects", + "accounts", "static", "media", }) diff --git a/apps/pages/tests/test_views.py b/apps/pages/tests/test_views.py index e3b28ad..1602102 100644 --- a/apps/pages/tests/test_views.py +++ b/apps/pages/tests/test_views.py @@ -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): diff --git a/apps/pages/views.py b/apps/pages/views.py index ef5aab8..fde4a94 100644 --- a/apps/pages/views.py +++ b/apps/pages/views.py @@ -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 diff --git a/apps/projects/__init__.py b/apps/projects/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/projects/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/projects/admin.py b/apps/projects/admin.py new file mode 100644 index 0000000..e4e2627 --- /dev/null +++ b/apps/projects/admin.py @@ -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( + '{}', + 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",) diff --git a/apps/projects/apps.py b/apps/projects/apps.py new file mode 100644 index 0000000..430a54e --- /dev/null +++ b/apps/projects/apps.py @@ -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 diff --git a/apps/projects/forms.py b/apps/projects/forms.py new file mode 100644 index 0000000..2844a8b --- /dev/null +++ b/apps/projects/forms.py @@ -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 diff --git a/apps/projects/management/__init__.py b/apps/projects/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/projects/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/projects/management/commands/__init__.py b/apps/projects/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/projects/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/projects/management/commands/seed_portal_data.py b/apps/projects/management/commands/seed_portal_data.py new file mode 100644 index 0000000..c42075b --- /dev/null +++ b/apps/projects/management/commands/seed_portal_data.py @@ -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 diff --git a/apps/projects/migrations/0001_initial.py b/apps/projects/migrations/0001_initial.py new file mode 100644 index 0000000..a8eed54 --- /dev/null +++ b/apps/projects/migrations/0001_initial.py @@ -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'], + }, + ), + ] diff --git a/apps/projects/migrations/0002_website_showcase_fields.py b/apps/projects/migrations/0002_website_showcase_fields.py new file mode 100644 index 0000000..f40f273 --- /dev/null +++ b/apps/projects/migrations/0002_website_showcase_fields.py @@ -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.'), + ), + ] diff --git a/apps/projects/migrations/0003_merge_goals_and_lock_visibility.py b/apps/projects/migrations/0003_merge_goals_and_lock_visibility.py new file mode 100644 index 0000000..1835242 --- /dev/null +++ b/apps/projects/migrations/0003_merge_goals_and_lock_visibility.py @@ -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.'), + ), + ] diff --git a/apps/projects/migrations/0004_optional_brief_fields.py b/apps/projects/migrations/0004_optional_brief_fields.py new file mode 100644 index 0000000..40db027 --- /dev/null +++ b/apps/projects/migrations/0004_optional_brief_fields.py @@ -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.'), + ), + ] diff --git a/apps/projects/migrations/__init__.py b/apps/projects/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/projects/models.py b/apps/projects/models.py new file mode 100644 index 0000000..7c67160 --- /dev/null +++ b/apps/projects/models.py @@ -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}" diff --git a/apps/projects/notifications.py b/apps/projects/notifications.py new file mode 100644 index 0000000..245e9f7 --- /dev/null +++ b/apps/projects/notifications.py @@ -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, + ) diff --git a/apps/projects/signals.py b/apps/projects/signals.py new file mode 100644 index 0000000..8a14486 --- /dev/null +++ b/apps/projects/signals.py @@ -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) diff --git a/apps/projects/tests/__init__.py b/apps/projects/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/projects/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/projects/tests/test_views.py b/apps/projects/tests/test_views.py new file mode 100644 index 0000000..3e32508 --- /dev/null +++ b/apps/projects/tests/test_views.py @@ -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 }}') + + def test_dashboard_lists_only_own_briefs(self): + self._create_brief(title="Mine") + self._create_brief(client=self.other, title="Not mine") + self.client.login(username="client", password="Str0ngPass!word") + response = self.client.get(reverse("projects:dashboard")) + self.assertEqual(response.status_code, 200) + titles = [brief.title for brief in response.context["briefs"]] + self.assertEqual(titles, ["Mine"]) + + def test_edit_allowed_only_when_submitted(self): + brief = self._create_brief( + title="Editable", + category=ProjectBrief.CATEGORY_DATA, + budget_range=ProjectBrief.BUDGET_NOT_SURE, + status=ProjectBrief.STATUS_SUBMITTED, + ) + self.client.login(username="client", password="Str0ngPass!word") + response = self.client.get(reverse("projects:edit", kwargs={"pk": brief.pk})) + self.assertEqual(response.status_code, 200) + + brief.status = ProjectBrief.STATUS_UNDER_REVIEW + brief.save() + response = self.client.get(reverse("projects:edit", kwargs={"pk": brief.pk})) + self.assertEqual(response.status_code, 302) + + def test_locked_project_blocks_edit(self): + brief = self._create_brief( + title="Locked", + status=ProjectBrief.STATUS_SUBMITTED, + locked=True, + ) + self.client.login(username="client", password="Str0ngPass!word") + response = self.client.get(reverse("projects:edit", kwargs={"pk": brief.pk})) + self.assertEqual(response.status_code, 302) + + def test_locked_field_is_disabled_on_edit_form(self): + brief = self._create_brief( + title="Lock field", + status=ProjectBrief.STATUS_SUBMITTED, + lock_title=True, + ) + self.client.login(username="client", password="Str0ngPass!word") + response = self.client.get(reverse("projects:edit", kwargs={"pk": brief.pk})) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'name="title"') + self.assertContains(response, "disabled") + + def test_locked_field_value_is_preserved_on_submit(self): + brief = self._create_brief( + title="Original title", + status=ProjectBrief.STATUS_SUBMITTED, + lock_title=True, + ) + self.client.login(username="client", password="Str0ngPass!word") + self.client.post( + reverse("projects:edit", kwargs={"pk": brief.pk}), + data={ + "title": "Hacked title", + "category": brief.category, + "description": brief.description, + "budget_range": brief.budget_range, + "desired_deadline": "", + "reference_links": "", + }, + ) + brief.refresh_from_db() + self.assertEqual(brief.title, "Original title") + + def test_status_change_emails_client(self): + brief = self._create_brief( + title="Notify me", + category=ProjectBrief.CATEGORY_RESEARCH, + budget_range=ProjectBrief.BUDGET_15K_50K, + status=ProjectBrief.STATUS_SUBMITTED, + ) + mail.outbox.clear() + brief.status = ProjectBrief.STATUS_UNDER_REVIEW + brief.save() + self.assertEqual(len(mail.outbox), 1) + self.assertIn("client@example.com", mail.outbox[0].to) + + def test_internal_notes_not_on_detail_page(self): + brief = self._create_brief( + title="Secret notes", + category=ProjectBrief.CATEGORY_OTHER, + ) + ProjectInternalNote.objects.create(brief=brief, note="Internal only", author=self.other) + self.client.login(username="client", password="Str0ngPass!word") + response = self.client.get(reverse("projects:detail", kwargs={"pk": brief.pk})) + self.assertEqual(response.status_code, 200) + self.assertNotIn(b"Internal only", response.content) + + def test_website_notes_visible_on_detail_page(self): + brief = self._create_brief( + title="Public notes", + category=ProjectBrief.CATEGORY_WEB, + website_notes="Your kickoff call is scheduled for next week.", + ) + self.client.login(username="client", password="Str0ngPass!word") + response = self.client.get(reverse("projects:detail", kwargs={"pk": brief.pk})) + self.assertContains(response, "Notes from Tecvico") + self.assertContains(response, "kickoff call is scheduled") + + def test_create_renders_wizard_steps(self): + self.client.login(username="client", password="Str0ngPass!word") + response = self.client.get(reverse("projects:create")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "data-brief-wizard") + self.assertContains(response, "Project basics") + self.assertContains(response, "References & files") + + def test_quote_visible_when_quoted(self): + brief = self._create_brief( + title="Quoted project", + category=ProjectBrief.CATEGORY_WORKFLOW, + budget_range=ProjectBrief.BUDGET_50K_PLUS, + status=ProjectBrief.STATUS_QUOTED, + quote_text="We can deliver in 8 weeks for $45,000.", + ) + self.client.login(username="client", password="Str0ngPass!word") + response = self.client.get(reverse("projects:detail", kwargs={"pk": brief.pk})) + self.assertContains(response, "We can deliver in 8 weeks") diff --git a/apps/projects/uploads.py b/apps/projects/uploads.py new file mode 100644 index 0000000..af26bb5 --- /dev/null +++ b/apps/projects/uploads.py @@ -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}" diff --git a/apps/projects/urls.py b/apps/projects/urls.py new file mode 100644 index 0000000..443a169 --- /dev/null +++ b/apps/projects/urls.py @@ -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("/", views.ProjectBriefDetailView.as_view(), name="detail"), + path("/edit/", views.ProjectBriefUpdateView.as_view(), name="edit"), +] diff --git a/apps/projects/views.py b/apps/projects/views.py new file mode 100644 index 0000000..fe80b55 --- /dev/null +++ b/apps/projects/views.py @@ -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) diff --git a/config/settings/base.py b/config/settings/base.py index b6d59bb..77415e1 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -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 = { diff --git a/config/settings/development.py b/config/settings/development.py index d02c049..11ab40c 100644 --- a/config/settings/development.py +++ b/config/settings/development.py @@ -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", diff --git a/config/settings/test.py b/config/settings/test.py index 9cd8e83..0c52f8e 100644 --- a/config/settings/test.py +++ b/config/settings/test.py @@ -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" diff --git a/config/urls.py b/config/urls.py index d669ebb..83b417c 100644 --- a/config/urls.py +++ b/config/urls.py @@ -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")), ] diff --git a/docs/managed-projects-roadmap.md b/docs/managed-projects-roadmap.md new file mode 100644 index 0000000..be72e8d --- /dev/null +++ b/docs/managed-projects-roadmap.md @@ -0,0 +1,256 @@ +# Managed Projects Roadmap — Simple → Advanced + +Expand the existing Tecvico Django site so **clients define projects; Tecvico delivers them**. No freelancer marketplace — users submit briefs, your team quotes, builds, and ships. + +--- + +## Is This Doable on the Current Stack? + +**Yes — and simpler than a marketplace.** No two-sided matching, escrow to third parties, or dispute flows between strangers. You are the sole provider. + + +| Already in place | Reuse for managed projects | +| -------------------------------- | ------------------------------------------- | +| Django + PostgreSQL + Docker | Core backend | +| `apps.pages` + contact form | Evolve into project brief submission | +| File uploads (`contact_uploads`) | Brief attachments, deliverables | +| `apps.products` CMS patterns | Service catalog / package pages | +| Admin panel | Intake queue, quotes, assignment, delivery | +| Email + templates | Status updates, quote sent, milestone ready | + + +**New apps to add:** + +- `apps.accounts` — client login, org profiles (Plan 2+) +- `apps.projects` — briefs, quotes, orders, milestones, deliverables +- `apps.messaging` — client ↔ Tecvico threads per project +- `apps.payments` — client invoicing via Stripe (Plan 2+) + +**Internal-only (admin):** team assignment, internal notes, time tracking — no public freelancer profiles. + +**Verdict:** Plan 1 is close to an enhanced contact flow. Plan 2–3 add quoting, payments, and a client portal — all standard Django. + +--- + + + +## Plan 1 — Simple (MVP) · 3–6 weeks + +**Goal:** Clients submit project briefs online; Tecvico reviews and delivers off-platform billing if needed. + + +| Milestone | Weeks | Deliverable | +| ------------------ | ----- | ----------------------------------------------- | +| M1 Client accounts | 1 | Sign up, login, password reset | +| M2 Project briefs | 1–2 | Structured submission form + attachments | +| M3 Status tracking | 2–3 | Client sees request status; email notifications | +| M4 Admin intake | 3–4 | Review queue, status updates, internal notes | + + + + +### Feature detail + +**Accounts** + +- Email/password signup (client role only) +- Profile: name, company, phone, timezone +- Dashboard: list of my project requests + +**Project brief** + +- Fields: title, category (web, data, research, etc.), description, goals, budget range, desired deadline, reference links +- Attachments: specs, mockups, datasets (reuse upload patterns) +- Status: `submitted` → `under_review` → `quoted` → `accepted` / `declined` → `in_progress` → `delivered` → `closed` +- Client can edit brief while status is `submitted` only + +**Notifications** + +- Email to client on status change +- Email to admin on new submission + +**Admin** + +- Intake list with filters (status, category, date) +- View brief + files; add internal notes (not visible to client) +- Manually set status and paste quote text (PDF/email off-platform OK) + +**Out of scope:** online quotes, payments, messaging, milestones. + +--- + + + +## Plan 2 — Medium (Client Portal) · 1–2 months + +**Goal:** Quote, pay, and deliver on-platform. Productized packages + custom projects. + + +| Milestone | Weeks | Deliverable | +| ------------- | ----- | ----------------------------------------------------- | +| M1 Foundation | 1–2 | Plan 1 + email verify, service catalog pages | +| M2 Quotes | 3–4 | Admin builds quote; client accepts/declines in portal | +| M3 Payments | 5–6 | Stripe deposit + milestone invoices | +| M4 Delivery | 7–8 | Milestones, deliverable uploads, revision rounds | +| M5 Comms | 9–10 | Per-project messaging, client dashboard polish | + + + + +### Feature detail + +**Service catalog** + +- CMS pages for fixed offerings (e.g. “Landing page”, “Radiomics pipeline audit”) +- Package tiers: scope summary, starting price, typical timeline +- “Start from template” pre-fills brief fields + +**Quotes** + +- Admin creates quote from brief: line items, total, timeline, revision count included +- Client receives email + in-portal quote view +- Accept → order created; decline → brief archived with reason + +**Orders & milestones** + +- Order states: `awaiting_deposit` → `active` → `milestone_review` → `revision` → `completed` +- Milestones: name, due date, amount, description of deliverable +- Tecvico uploads files per milestone; client approves or requests revision (capped rounds) +- Change requests: client submits add-on brief → admin sends revised quote + +**Payments (Stripe)** + +- Deposit on accept (e.g. 30–50%); remainder split across milestones +- Checkout Session per invoice; receipt PDF +- Refund policy flags (admin-only) + +**Messaging** + +- Thread per project: client ↔ assigned PM +- File attach in thread; email digest on new message + +**Admin** + +- Assign internal owner (staff user) per project +- Pipeline board: new → quoted → active → blocked → done +- Basic reports: open projects, revenue this month + +**Out of scope:** e-sign contracts, org/team accounts, automated estimates, real-time chat. + +--- + + + +## Plan 3 — Advanced (Full Client Platform) · 2–3 months + +**Goal:** Scalable delivery ops — enterprise clients, SLAs, automation, integrations. + + +| Milestone | Months | Deliverable | +| --------------- | ------ | --------------------------------------------- | +| M1 Hardening | 1 | Plan 2 + 2FA, OAuth, org accounts | +| M2 Sales flow | 1–2 | E-sign, ballpark estimator, kickoff booking | +| M3 Delivery ops | 2–3 | SLA timers, internal time log, task checklist | +| M4 Enterprise | 3–4 | Multi-user orgs, API, webhooks | +| M5 Growth | 4–5 | Referrals, analytics, PWA, knowledge base | + + + + +### Feature detail + +**Auth & organizations** + +- Google / LinkedIn OAuth; optional 2FA +- Organization account: owner + members, shared project list, role (viewer / submitter / billing) +- Billing contact separate from project contact + +**Pre-sale** + +- Ballpark estimator: category + scope sliders → indicative range (admin-configured rules) +- Discovery call booking (Calendly embed or built-in slot picker) +- Digital SOW + NDA (DocuSign hook or in-app accept with audit trail) + +**Delivery workspace** + +- Shared project hub: brief, quote, contract, timeline, files, messages +- Visual timeline / milestone Gantt for client +- SLA badges: response within X hours, milestone due alerts (Celery + email) +- Internal: task checklist, time entries per staff, capacity view (admin) + +**Revisions & scope** + +- Structured revision requests (what to change, priority) +- Scope creep flow: auto-flag when revision count exceeded → change order quote + +**Payments+** + +- Multi-currency quotes; recurring maintenance plans (Stripe Subscription) +- Pro-forma + final invoice; export for accounting + +**Enterprise & integrations** + +- REST API: submit brief, poll status, download deliverables +- Webhooks: `quote.sent`, `milestone.delivered`, `project.completed` +- SSO option (SAML) for large clients — optional phase + +**Knowledge & retention** + +- Client-facing FAQ / “how we work” tied to project phase +- Post-delivery satisfaction survey + testimonial request +- Referral codes (credit on next project) + +**Admin analytics** + +- Funnel: brief → quote → accept → complete +- Utilization, avg delivery time, revenue by service line + +**Out of scope:** marketplace, freelancer payouts, native mobile apps. + +--- + + + +## Comparison + + +| | Simple | Medium | Advanced | +| ------------------------------- | ---------- | -------- | ----------------- | +| Project brief submission | ✓ | ✓ | ✓ + templates | +| Online quotes | — | ✓ | ✓ + estimator | +| Stripe payments | — | ✓ | ✓ + subscriptions | +| Milestones & deliverables | — | ✓ | ✓ + SLA | +| Client ↔ Tecvico messaging | — | ✓ | ✓ + digests | +| Service catalog / packages | — | ✓ | ✓ | +| Org / team accounts | — | — | ✓ | +| E-sign / contracts | — | — | ✓ | +| API / webhooks | — | — | ✓ | +| Internal ops (assign, time log) | notes only | pipeline | full | + + +--- + + + +## Managed vs Marketplace + + +| | Managed (this doc) | Marketplace (`marketplace-roadmap.md`) | +| ------------ | -------------------------- | -------------------------------------- | +| Who delivers | Tecvico team | Freelancers / clients hire each other | +| Payments | Client → Tecvico | Escrow, payouts, commissions | +| Complexity | Lower | Higher (trust, disputes, matching) | +| Best when | You sell your own services | You broker others' work | + + +--- + + + +## Recommended Path + +1. **Plan 1** — ship brief + status portal; keep quoting via email if needed. +2. **Plan 2** — add quotes and Stripe once you have repeatable delivery workflow. +3. **Plan 3** — when project volume needs SLAs, org accounts, and ops dashboards. + +*Timelines assume extending the current codebase; bespoke UI redesign adds ~20%.* \ No newline at end of file diff --git a/docs/marketplace-roadmap.md b/docs/marketplace-roadmap.md new file mode 100644 index 0000000..77014c7 --- /dev/null +++ b/docs/marketplace-roadmap.md @@ -0,0 +1,241 @@ +# Marketplace Roadmap — Simple → Advanced + +Expand the existing Tecvico Django site into a freelance/project marketplace (Upwork / Fiverr style). + +--- + +## Is This Doable on the Current Stack? + +**Yes.** Django is a standard choice for marketplaces (e.g. early Instacart, Disqus patterns). Your repo already has the right foundation: + + +| Already in place | Reuse for marketplace | +| -------------------------------------------------- | --------------------------------------- | +| Django + PostgreSQL + Docker | Core backend — no rewrite needed | +| `apps.core` (branding, site settings) | Global config, fees, categories | +| `apps.pages` (CMS, hero, sections) | Marketing pages + marketplace landing | +| File uploads (`contact_uploads`, product releases) | Proposals, deliverables, gig media | +| Admin panel | Moderation, disputes, user management | +| Templates + static CSS/JS | Extend UI; add dashboards incrementally | + + +**New apps to add** (keeps existing code intact): + +- `apps.accounts` — profiles, roles, verification +- `apps.marketplace` — projects, gigs, proposals, contracts +- `apps.messaging` — threads (Plan 1: polling/refresh; Plan 3: Channels/WebSockets) +- `apps.payments` — Stripe from Plan 2 onward + +**What gets harder later (not blockers now):** real-time chat, escrow/payouts, dispute automation, high traffic (Redis, Celery, CDN). None require leaving Django. + +**Verdict:** Plan 1–2 are a natural extension. Plan 3 adds services (Stripe Connect, WebSockets, background jobs) but stays on the same project. + +--- + + + +## Plan 1 — Simple (MVP) · 3–5 weeks + +**Goal:** List projects, apply, message. Off-platform payment OK. + + +| Milestone | Weeks | Deliverable | +| ------------------ | ----- | ----------------------------------------------------------- | +| M1 Auth & profiles | 1–2 | Registration, login, password reset, client/freelancer role | +| M2 Listings | 3–4 | Project CRUD, categories, search/filters | +| M3 Proposals | 5–6 | Apply, withdraw, client shortlist | +| M4 Comms & admin | 7–8 | Messaging, emails, moderation | + + + + +### Feature detail + +**Accounts** + +- Email/password signup; role pick (client, freelancer, or both) +- Profile: display name, avatar, bio, location, hourly rate (optional), skills (tags), portfolio URLs +- Public profile URL (`/u/username/`) + +**Projects** + +- Fields: title, rich description, category, budget type (fixed/hourly/range), amount, deadline, attachments +- Status: draft → open → closed → archived +- Client dashboard: my projects, applicant count, close/reopen + +**Discovery** + +- List page with filters: category, budget range, posted date +- Full-text search on title + description +- Sort: newest, budget high/low + +**Proposals** + +- Cover letter, bid amount, estimated delivery days +- One proposal per freelancer per project; edit until client views +- Client view: compare proposals, mark shortlisted/rejected + +**Messaging** + +- Thread per project between client and applicant (after apply or invite) +- Attach files (reuse existing upload patterns) +- Email on new message/proposal (Django email backend) + +**Admin** + +- Approve/suspend users and listings; view contact-style audit trail + +**Out of scope:** payments, contracts, reviews, gigs. + +--- + + + +## Plan 2 — Medium (Transactional) · 2–3 months + +**Goal:** Hire and pay on-platform. Gigs (Fiverr) + custom projects (Upwork). + + +| Milestone | Weeks | Deliverable | +| ------------- | ----- | ----------------------------------------------------------- | +| M1 Foundation | 1–4 | Plan 1 + email verify, profile completeness, S3/local media | +| M2 Gigs | 5–8 | Service listings with 3 tiers | +| M3 Contracts | 9–12 | Award, milestones, deliverables | +| M4 Payments | 13–16 | Stripe escrow, release, platform fee | +| M5 Trust | 17–20 | Reviews, dashboards, manual disputes | + + + + +### Feature detail + +**Gigs (Fiverr-style)** + +- Gig: title, gallery (images/video), category, FAQ, requirements form +- Packages: Basic / Standard / Premium — price, delivery days, revision count, feature bullet list +- Extras: add-ons (e.g. +$50 rush delivery) +- Order flow: client picks package → answers requirements → pays → freelancer accepts + +**Contracts (Upwork-style)** + +- Client awards proposal → contract generated (scope, total, deadline) +- Milestones: name, amount, due date; client funds each before work starts +- Deliverable upload per milestone; client approve or request revision (limited rounds) +- Order states: `pending_payment` → `active` → `delivered` → `revision` → `completed` / `cancelled` + +**Payments (Stripe)** + +- Checkout Session on award/order; funds held (platform balance or Stripe separate charges) +- Release to freelancer on milestone approval minus commission (e.g. 10–20%) +- Refund rules: cancelled before start; partial on dispute +- PDF invoice/receipt per transaction + +**Trust** + +- Double-blind reviews after completion (1–5 stars + text) +- Public rating on profile; aggregate on gig +- Dispute form: reason, evidence files → admin resolves manually + +**Dashboards** + +- Client: spend, active orders, past hires +- Freelancer: earnings, pending clearance, gig analytics (views, orders) + +**Out of scope:** hourly time tracker, OAuth, multi-currency, automated disputes. + +--- + + + +## Plan 3 — Advanced (Full Platform) · 4–6 months + +**Goal:** Production marketplace with discovery, compliance, and scale. + + +| Milestone | Months | Deliverable | +| ----------------- | ------ | ----------------------------------------- | +| M1 Core hardening | 1–2 | Plan 2 + 2FA, OAuth, rate limits | +| M2 Discovery | 3–4 | Search, recommendations, skill tests | +| M3 Collaboration | 5–6 | Time tracking, workspace, real-time chat | +| M4 Payments+ | 7–8 | Stripe Connect, multi-currency, KYC hooks | +| M5 Trust & safety | 9–10 | Auto disputes, moderation, fraud signals | +| M6 Growth | 11–12 | API, referrals, analytics, PWA | + + + + +### Feature detail + +**Auth & identity** + +- Google / LinkedIn OAuth; TOTP 2FA +- Identity verification hook (Stripe Identity or manual) +- Verified badge on profile + +**Discovery & matching** + +- Elasticsearch/PostgreSQL full-text: skills, rate, location, availability +- Saved searches + email alerts for new matching projects/gigs +- Skill tests (quiz → badge on pass) +- “Recommended for you” (rules-based first; ML optional later) + +**Collaboration** + +- Hourly contracts: weekly hour cap, manual or screenshot time log +- Shared order workspace: brief, files, milestone checklist +- Real-time chat (Django Channels + Redis): typing, read receipts, file drag-drop +- Video intro on profile; calendar link for intro calls + +**Payments & compliance** + +- Stripe Connect Express: freelancer onboarding, automatic payouts +- Multi-currency display; platform fee per region +- Tax form collection hook (W-9 / W-8BEN — integrate when legally required) +- Chargebacks and refund automation with audit log + +**Trust & safety** + +- Dispute pipeline: open → evidence window → mediator → verdict → auto payout split +- Content moderation queue (profiles, gigs, messages flagged by users or keywords) +- Fraud heuristics: duplicate accounts, velocity limits, payment anomalies + +**Growth & ops** + +- Freelancer subscriptions: featured gig, lower commission tier +- Referral codes (credit for referrer/referee) +- Public REST API + webhooks (`order.completed`, `payment.released`) +- Admin analytics: GMV, take rate, conversion funnel, dispute rate, cohort retention +- SEO pages for categories and top gigs; structured data (JSON-LD) + +**Out of scope:** native iOS/Android (PWA first). + +--- + + + +## Comparison + + +| | Simple | Medium | Advanced | +| -------------------- | ------- | ------------- | ------------------------ | +| Projects + proposals | ✓ | ✓ | ✓ | +| Gigs / packages | — | ✓ | ✓ | +| Messaging | refresh | refresh | real-time | +| Stripe / escrow | — | ✓ | Connect + multi-currency | +| Milestones + hourly | — | milestones | both | +| Reviews | — | ✓ | ✓ + badges + tests | +| Disputes | — | manual | automated | +| API / analytics | — | basic reports | full | + + +--- + + + +## Recommended Path + +1. **Plan 1 on this repo** — add `apps.accounts` + `apps.marketplace`; keep marketing site live. +2. **Plan 2 when volume justifies escrow** — biggest lift is payments + order state machine. +3. **Plan 3 when GMV supports ops** — disputes, compliance, and real-time infra need people, not just code. + +*Timelines assume extending the current codebase; full UI redesign adds ~20%.* \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index ffab9d4..24548fc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/static/css/brief-wizard.css b/static/css/brief-wizard.css new file mode 100644 index 0000000..3dbd373 --- /dev/null +++ b/static/css/brief-wizard.css @@ -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; + } +} diff --git a/static/css/main.css b/static/css/main.css index 2f29340..b6c5e1c 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -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); + } +} diff --git a/static/js/brief-wizard.js b/static/js/brief-wizard.js new file mode 100644 index 0000000..21feb3f --- /dev/null +++ b/static/js/brief-wizard.js @@ -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); diff --git a/templates/accounts/_social_auth.html b/templates/accounts/_social_auth.html new file mode 100644 index 0000000..18e6043 --- /dev/null +++ b/templates/accounts/_social_auth.html @@ -0,0 +1,20 @@ +{% load socialaccount %} +{% if social_auth_enabled %} + + +{% endif %} diff --git a/templates/accounts/emails/password_reset_email.txt b/templates/accounts/emails/password_reset_email.txt new file mode 100644 index 0000000..d4a8cd7 --- /dev/null +++ b/templates/accounts/emails/password_reset_email.txt @@ -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. diff --git a/templates/accounts/emails/password_reset_subject.txt b/templates/accounts/emails/password_reset_subject.txt new file mode 100644 index 0000000..adca1b9 --- /dev/null +++ b/templates/accounts/emails/password_reset_subject.txt @@ -0,0 +1 @@ +Tecvico password reset diff --git a/templates/accounts/login.html b/templates/accounts/login.html new file mode 100644 index 0000000..f1c4e31 --- /dev/null +++ b/templates/accounts/login.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} + +{% block title %}Login{% endblock %} +{% block meta_description %}Log in to your Tecvico client portal.{% endblock %} + +{% block content %} +
+ +
+
+
Client Portal
+

Log In

+

Access your project briefs and track progress with the Tecvico team.

+
+
+
+ +
+
+
+ {% include "accounts/_social_auth.html" %} + +
+ {% csrf_token %} + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} + +
+ + {{ form.username }} + {{ form.username.errors }} +
+ +
+ + {{ form.password }} + {{ form.password.errors }} +
+ + +
+ + +
+
+
+{% endblock %} diff --git a/templates/accounts/password_reset.html b/templates/accounts/password_reset.html new file mode 100644 index 0000000..88b4343 --- /dev/null +++ b/templates/accounts/password_reset.html @@ -0,0 +1,24 @@ +{% extends "base.html" %} + +{% block title %}Reset Password{% endblock %} + +{% block content %} +
+
+
+

Reset password

+

Enter your email and we'll send reset instructions.

+
+ {% csrf_token %} +
+ + {{ form.email }} + {{ form.email.errors }} +
+ +
+ +
+
+
+{% endblock %} diff --git a/templates/accounts/password_reset_complete.html b/templates/accounts/password_reset_complete.html new file mode 100644 index 0000000..77e21ca --- /dev/null +++ b/templates/accounts/password_reset_complete.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Password Updated{% endblock %} + +{% block content %} +
+
+
+

Password updated

+

You can now log in with your new password.

+ +
+
+
+{% endblock %} diff --git a/templates/accounts/password_reset_confirm.html b/templates/accounts/password_reset_confirm.html new file mode 100644 index 0000000..0470128 --- /dev/null +++ b/templates/accounts/password_reset_confirm.html @@ -0,0 +1,28 @@ +{% extends "base.html" %} + +{% block title %}Set New Password{% endblock %} + +{% block content %} +
+
+
+

Set a new password

+
+ {% csrf_token %} + {{ form.non_field_errors }} +
+ + {{ form.new_password1 }} + {{ form.new_password1.errors }} +
+
+ + {{ form.new_password2 }} + {{ form.new_password2.errors }} +
+ +
+
+
+
+{% endblock %} diff --git a/templates/accounts/password_reset_done.html b/templates/accounts/password_reset_done.html new file mode 100644 index 0000000..a430122 --- /dev/null +++ b/templates/accounts/password_reset_done.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} + +{% block title %}Reset Email Sent{% endblock %} + +{% block content %} +
+
+
+

Check your email

+

If an account exists for that address, you'll receive password reset instructions shortly.

+ +
+
+
+{% endblock %} diff --git a/templates/accounts/profile.html b/templates/accounts/profile.html new file mode 100644 index 0000000..7cb01b3 --- /dev/null +++ b/templates/accounts/profile.html @@ -0,0 +1,64 @@ +{% extends "base.html" %} + +{% block title %}Profile{% endblock %} + +{% block content %} +
+
+
+

Your Profile

+

Keep your contact details up to date for project communication.

+
+
+
+ +
+
+
+
+ {% csrf_token %} + +
+
+ + {{ form.first_name }} + {{ form.first_name.errors }} +
+
+ + {{ form.last_name }} + {{ form.last_name.errors }} +
+
+ +
+ + {{ form.email }} + {{ form.email.errors }} +
+ +
+
+ + {{ form.company }} + {{ form.company.errors }} +
+
+ + {{ form.phone }} + {{ form.phone.errors }} +
+
+ +
+ + {{ form.timezone }} + {{ form.timezone.errors }} +
+ + +
+
+
+
+{% endblock %} diff --git a/templates/accounts/signup.html b/templates/accounts/signup.html new file mode 100644 index 0000000..37b4137 --- /dev/null +++ b/templates/accounts/signup.html @@ -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 %} +
+ +
+
+
Client Portal
+

Create Account

+

Tell us about your project and our team will take it from there.

+
+
+
+ +
+
+
+ {% include "accounts/_social_auth.html" %} + +
+ {% csrf_token %} + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} + +
+
+ + {{ form.first_name }} + {{ form.first_name.errors }} +
+
+ + {{ form.last_name }} + {{ form.last_name.errors }} +
+
+ +
+ + {{ form.email }} + {{ form.email.errors }} +
+ +
+ + {{ form.username }} + {{ form.username.errors }} +
+ +
+
+ + {{ form.company }} + {{ form.company.errors }} +
+
+ + {{ form.phone }} + {{ form.phone.errors }} +
+
+ +
+ + {{ form.timezone }} + {{ form.timezone.errors }} +
+ +
+
+ + {{ form.password1 }} + {{ form.password1.errors }} +
+
+ + {{ form.password2 }} + {{ form.password2.errors }} +
+
+ + +
+ + +
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html index f05d9a7..72cdf33 100644 --- a/templates/base.html +++ b/templates/base.html @@ -17,6 +17,8 @@ {% include "partials/_navbar.html" %} + {% include "partials/_messages.html" %} +
{% block content %}{% endblock %}
diff --git a/templates/pages/home.html b/templates/pages/home.html index e5f5172..a0fa0e9 100644 --- a/templates/pages/home.html +++ b/templates/pages/home.html @@ -37,6 +37,6 @@ -{% 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 %} diff --git a/templates/partials/_messages.html b/templates/partials/_messages.html new file mode 100644 index 0000000..58473ce --- /dev/null +++ b/templates/partials/_messages.html @@ -0,0 +1,7 @@ +{% if messages %} +
+ {% for message in messages %} +
{{ message }}
+ {% endfor %} +
+{% endif %} diff --git a/templates/partials/_navbar.html b/templates/partials/_navbar.html index eb9c416..46f6c59 100644 --- a/templates/partials/_navbar.html +++ b/templates/partials/_navbar.html @@ -90,6 +90,36 @@ + {% if user.is_authenticated %} + + + + {% else %} + + + {% endif %} + diff --git a/templates/partials/_page_sections.html b/templates/partials/_page_sections.html index ac5299f..0c3a82a 100644 --- a/templates/partials/_page_sections.html +++ b/templates/partials/_page_sections.html @@ -245,6 +245,58 @@ {% endif %} {% endfor %} + {% if website_project_briefs %} + {% for brief in website_project_briefs %} + {% if brief.public_url %} + + {% else %} +
+ {% endif %} + {% if brief.public_image %} +
+ {{ brief.public_display_title|default:brief.title }} +
+ {% endif %} +
+ {% if brief.public_url %} + + {% else %} +
+ {% endif %} + {% endfor %} + {% endif %} diff --git a/templates/projects/brief_detail.html b/templates/projects/brief_detail.html new file mode 100644 index 0000000..f6eec0c --- /dev/null +++ b/templates/projects/brief_detail.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} + +{% block title %}{{ brief.title }}{% endblock %} + +{% block content %} +
+
+
+
+ {% if brief.category %}
{{ brief.get_category_display }}
{% endif %} +

{{ brief.title }}

+

+ {{ brief.get_status_display }} + · Submitted {{ brief.submitted_at|date:"M j, Y" }} +

+
+
+ Back to list + {% if brief.is_editable_by_client %} + Edit brief + {% endif %} +
+
+
+
+ +
+
+
+
+

Overview

+
+ {% if brief.budget_range %}
Budget
{{ brief.get_budget_range_display }}
{% endif %} +
Deadline
{% if brief.desired_deadline %}{{ brief.desired_deadline|date:"M j, Y" }}{% else %}Not specified{% endif %}
+
Last updated
{{ brief.updated_at|date:"M j, Y, P" }}
+
+ + {% if brief.description %} +

Description & goals

+

{{ brief.description|linebreaksbr }}

+ {% endif %} + + {% if brief.reference_links %} +

Reference links

+

{{ brief.reference_links|linebreaksbr }}

+ {% endif %} + + {% if brief.attachments.exists %} +

Attachments

+
    + {% for attachment in brief.attachments.all %} +
  • {{ attachment.original_filename }}
  • + {% endfor %} +
+ {% endif %} +
+ + {% if brief.shows_quote_to_client %} +
+

Quote from Tecvico

+

{{ brief.quote_text|linebreaksbr }}

+
+ {% endif %} + + {% if brief.has_website_notes %} +
+

Notes from Tecvico

+

{{ brief.website_notes|linebreaksbr }}

+
+ {% endif %} +
+
+
+{% endblock %} diff --git a/templates/projects/brief_form.html b/templates/projects/brief_form.html new file mode 100644 index 0000000..e69de29 diff --git a/templates/projects/brief_wizard.html b/templates/projects/brief_wizard.html new file mode 100644 index 0000000..52a573e --- /dev/null +++ b/templates/projects/brief_wizard.html @@ -0,0 +1,150 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}{{ form_title }}{% endblock %} + +{% block extra_css %} + +{% endblock %} + +{% block content %} +
+
+
+

{{ form_title }}

+

Complete each step to submit your project brief.

+
+
+
+ +
+
+
+
+ {% csrf_token %} + + + +
    + {% for step in wizard_steps %} +
  1. + {{ forloop.counter }} + {{ step.title }} +
  2. + {% endfor %} +
+ + {% for step in wizard_steps %} +
+
+

Step {{ forloop.counter }} of {{ wizard_steps|length }}

+

{{ step.title }}

+

{{ step.subtitle }}

+
+ + {% if step.id == "basics" %} +
+ + {{ form.title }} + {{ form.title.errors }} +
+
+ + {{ form.category }} + {{ form.category.errors }} +
+ {% endif %} + + {% if step.id == "scope" %} +
+ + {{ form.description }} +

Describe the project scope, context, and the outcomes you expect.

+ {{ form.description.errors }} +
+ {% endif %} + + {% if step.id == "timeline" %} +
+
+ + {{ form.budget_range }} + {{ form.budget_range.errors }} +
+
+ + {{ form.desired_deadline }} + {{ form.desired_deadline.errors }} +
+
+ {% endif %} + + {% if step.id == "references" %} +
+ + {{ form.reference_links }} +

One URL per line — docs, mockups, repos, etc.

+ {{ form.reference_links.errors }} +
+ +
+ + +

Images, ZIP, or log/text files — up to 3 files, 10 MB each.

+ {{ form.attachments.errors }} +
+ + {% if brief and brief.attachments.exists %} +
+

Existing attachments:

+
    + {% for attachment in brief.attachments.all %} +
  • {{ attachment.original_filename }}
  • + {% endfor %} +
+
+ {% endif %} + + + {% endif %} +
+ {% endfor %} + +
+ + + +
+
+
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/projects/dashboard.html b/templates/projects/dashboard.html new file mode 100644 index 0000000..d77bf7a --- /dev/null +++ b/templates/projects/dashboard.html @@ -0,0 +1,70 @@ +{% extends "base.html" %} + +{% block title %}My Projects{% endblock %} + +{% block content %} +
+
+
+
+
Client Portal
+

My Projects

+

Track briefs you've submitted to the Tecvico team.

+
+ New project brief +
+
+
+ +
+
+ {% if briefs %} +
+ + + + + + + + + + + + + {% for brief in briefs %} + + + + + + + + + {% endfor %} + +
TitleCategoryStatusBudgetSubmitted
{{ brief.title }}{% if brief.category %}{{ brief.get_category_display }}{% else %}—{% endif %}{{ brief.get_status_display }}{% if brief.budget_range %}{{ brief.get_budget_range_display }}{% else %}—{% endif %}{{ brief.submitted_at|date:"M j, Y" }}View
+
+ + {% if page_obj.has_other_pages %} +
+ {% if page_obj.has_previous %} + Previous + {% endif %} + Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }} + {% if page_obj.has_next %} + Next + {% endif %} +
+ {% endif %} + + {% else %} +
+

No project briefs yet

+

Describe what you need and our team will review your request.

+ Submit your first brief +
+ {% endif %} +
+
+{% endblock %} diff --git a/templates/projects/emails/new_brief_admin.txt b/templates/projects/emails/new_brief_admin.txt new file mode 100644 index 0000000..401f9d8 --- /dev/null +++ b/templates/projects/emails/new_brief_admin.txt @@ -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/ diff --git a/templates/projects/emails/status_change_client.txt b/templates/projects/emails/status_change_client.txt new file mode 100644 index 0000000..a0ae19d --- /dev/null +++ b/templates/projects/emails/status_change_client.txt @@ -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 diff --git a/templates/socialaccount/authentication_error.html b/templates/socialaccount/authentication_error.html new file mode 100644 index 0000000..10aac1b --- /dev/null +++ b/templates/socialaccount/authentication_error.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}Sign-in failed{% endblock %} + +{% block content %} +
+
+
+

Sign-in failed

+

We could not complete third-party authentication.

+
+
+
+ +
+
+
+
+ {% trans "An error occurred while attempting to sign in with your third-party account." %} +
+

+ {% trans "Please try again, or sign in with your email and password." %} +

+ +
+
+
+{% endblock %} diff --git a/templates/socialaccount/login.html b/templates/socialaccount/login.html new file mode 100644 index 0000000..d168c33 --- /dev/null +++ b/templates/socialaccount/login.html @@ -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 %} +
+ +
+
+
Client Portal
+

+ {% if process == "connect" %} + Connect {{ provider.name }} + {% else %} + Sign in with {{ provider.name }} + {% endif %} +

+

+ {% 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 %} +

+
+
+
+ +
+
+ +
+
+{% endblock %} diff --git a/templates/socialaccount/login_cancelled.html b/templates/socialaccount/login_cancelled.html new file mode 100644 index 0000000..df1824a --- /dev/null +++ b/templates/socialaccount/login_cancelled.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}Login cancelled{% endblock %} + +{% block content %} +
+
+
+

Login cancelled

+

No changes were made to your account.

+
+
+
+ +
+
+
+

+ {% trans "You cancelled third-party sign-in. You can try again or use email login instead." %} +

+ +
+
+
+{% endblock %} diff --git a/templates/socialaccount/login_redirect.html b/templates/socialaccount/login_redirect.html new file mode 100644 index 0000000..6ee6ab9 --- /dev/null +++ b/templates/socialaccount/login_redirect.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}Redirecting to {{ provider }}{% endblock %} + +{% block content %} +
+
+ +
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/socialaccount/signup.html b/templates/socialaccount/signup.html new file mode 100644 index 0000000..bb48f92 --- /dev/null +++ b/templates/socialaccount/signup.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block title %}Complete sign up{% endblock %} + +{% block content %} +
+ +
+
+
Client Portal
+

Complete your account

+

+ {% blocktrans with provider_name=account.get_provider.name %}Finish setup after signing in with {{ provider_name }}.{% endblocktrans %} +

+
+
+
+ +
+
+
+
+ {% csrf_token %} + {{ redirect_field }} + + {% if form.non_field_errors %} +
{{ form.non_field_errors }}
+ {% endif %} + + {% for field in form %} +
+ + {{ field }} + {{ field.errors }} +
+ {% endfor %} + + +
+ + +
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %}