feat: admin panel enrichment

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