Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
f6d6e4e
Add hierarchical collaborative notes for projects
BlaiseOfGlory Jan 16, 2026
f861047
Update package-lock.json and make Docker ports configurable
BlaiseOfGlory Jan 16, 2026
b9500d3
Add drag-and-drop reorganization for hierarchical notes
BlaiseOfGlory Jan 16, 2026
4f822ab
Increase indentation for hierarchical collab notes
BlaiseOfGlory Jan 16, 2026
bf20336
Add multi-field support for collaborative notes with images
BlaiseOfGlory Jan 17, 2026
5b64e6a
Fix scrambled tree items during drag-and-drop
BlaiseOfGlory Jan 17, 2026
22ea786
Fix uploaded images not rendering in collab notes editor
BlaiseOfGlory Jan 19, 2026
f528761
Fix image URLs missing /media/ prefix when loading from database
BlaiseOfGlory Jan 19, 2026
9dbfe5d
Fix modal positioning in CreateModal for collab notes
BlaiseOfGlory Jan 19, 2026
8f48ebb
Add real-time tree sync for collaborative notes
BlaiseOfGlory Jan 20, 2026
b211a96
Add confirmation dialog for deleting note fields
BlaiseOfGlory Jan 20, 2026
7170fa6
Fix real-time sync of field additions between users
BlaiseOfGlory Jan 20, 2026
db5e366
Fix AddFieldToolbar dark mode support
BlaiseOfGlory Jan 20, 2026
759d8eb
Add image field placeholder for collab notes
BlaiseOfGlory Jan 20, 2026
f0fe990
Add delete confirmation modal for notes and folders
BlaiseOfGlory Jan 20, 2026
7cab167
Fix field reorder sync and improve tree item styling
BlaiseOfGlory Jan 20, 2026
67b2332
Add collab notes zip export feature
BlaiseOfGlory Jan 21, 2026
fb87929
Reduce collab notes debounce from 5s to 2s
BlaiseOfGlory Jan 21, 2026
c367ef3
Update package-lock.json
BlaiseOfGlory Jan 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion ghostwriter/api/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
from ghostwriter.reporting.views2.report_finding_link import get_position
from ghostwriter.rolodex.models import (
Project,
ProjectCollabNote,
ProjectContact,
ProjectObjective,
ProjectSubTask,
Expand Down Expand Up @@ -1373,10 +1374,24 @@ class CheckEditPermissions(JwtRequiredMixin, HasuraActionView):
"report_finding_link": ReportFindingLink,
"report": Report,
"project": Project,
"project_collab_note": ProjectCollabNote,
}

def post(self, request):
cls = self.available_models.get(self.input["model"])
model = self.input["model"]

# Special case: project_tree_sync uses project_id as id
# and checks if user can access the project
if model == "project_tree_sync":
try:
project = Project.objects.get(id=self.input["id"])
except ObjectDoesNotExist:
return JsonResponse(utils.generate_hasura_error_payload("Not Found", "ModelDoesNotExist"), status=404)
if not project.user_can_edit(self.user_obj):
return JsonResponse(utils.generate_hasura_error_payload("Not allowed to edit", "Unauthorized"), status=403)
return JsonResponse(self.user_obj.username, status=200, safe=False)
Comment on lines +1383 to +1392
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add user_can_edit method to ProjectSyncTree instead of special casing


cls = self.available_models.get(model)
if cls is None:
return JsonResponse(utils.generate_hasura_error_payload("Unrecognized model type", "InvalidRequestBody"), status=401)

Expand Down
97 changes: 97 additions & 0 deletions ghostwriter/rolodex/migrations/0060_projectcollabnote.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# Generated manually for ProjectCollabNote model

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
("rolodex", "0059_merge_20251027_1706"),
]

operations = [
migrations.CreateModel(
name="ProjectCollabNote",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"title",
models.CharField(
help_text="Title of the note or folder",
max_length=255,
verbose_name="Title",
),
),
(
"node_type",
models.CharField(
choices=[("folder", "Folder"), ("note", "Note")],
default="note",
help_text="Whether this is a folder or a note",
max_length=10,
verbose_name="Type",
),
),
(
"content",
models.TextField(
blank=True,
default="",
help_text="Rich text content (for notes only, empty for folders)",
verbose_name="Content",
),
),
(
"position",
models.PositiveIntegerField(
default=0,
help_text="Order within parent (lower values first)",
verbose_name="Position",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"parent",
models.ForeignKey(
blank=True,
help_text="Parent folder (null for root-level items)",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="children",
to="rolodex.projectcollabnote",
),
),
(
"project",
models.ForeignKey(
help_text="The project this note belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="collab_notes",
to="rolodex.project",
),
),
],
options={
"verbose_name": "Project collaborative note",
"verbose_name_plural": "Project collaborative notes",
"ordering": ["position", "title"],
},
),
migrations.AddConstraint(
model_name="projectcollabnote",
constraint=models.CheckConstraint(
check=models.Q(("node_type", "note")) | models.Q(("content", "")),
name="folder_has_no_content",
),
),
]
45 changes: 45 additions & 0 deletions ghostwriter/rolodex/migrations/0061_migrate_collab_notes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Data migration: Migrate existing Project.collab_note content to ProjectCollabNote

from django.db import migrations


def migrate_existing_notes(apps, schema_editor):
"""Migrate existing collab_note content to new hierarchical model."""
Project = apps.get_model("rolodex", "Project")
ProjectCollabNote = apps.get_model("rolodex", "ProjectCollabNote")

for project in Project.objects.exclude(collab_note="").exclude(collab_note__isnull=True):
ProjectCollabNote.objects.create(
project=project,
parent=None,
title="Migrated Notes",
node_type="note",
content=project.collab_note,
position=0,
)


def reverse_migration(apps, schema_editor):
"""Reverse: copy first note back to collab_note field."""
Project = apps.get_model("rolodex", "Project")
ProjectCollabNote = apps.get_model("rolodex", "ProjectCollabNote")

for project in Project.objects.all():
first_note = ProjectCollabNote.objects.filter(
project=project,
node_type="note",
).order_by("position", "title").first()
if first_note:
project.collab_note = first_note.content
project.save(update_fields=["collab_note"])


class Migration(migrations.Migration):

dependencies = [
("rolodex", "0060_projectcollabnote"),
]

operations = [
migrations.RunPython(migrate_existing_notes, reverse_migration),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Add database-level defaults for timestamp columns
# This is needed because GraphQL/Hasura inserts bypass Django ORM

from django.db import migrations


class Migration(migrations.Migration):

dependencies = [
("rolodex", "0061_migrate_collab_notes"),
]

operations = [
migrations.RunSQL(
sql="""
ALTER TABLE rolodex_projectcollabnote
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP;

ALTER TABLE rolodex_projectcollabnote
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
""",
reverse_sql="""
ALTER TABLE rolodex_projectcollabnote
ALTER COLUMN created_at DROP DEFAULT;

ALTER TABLE rolodex_projectcollabnote
ALTER COLUMN updated_at DROP DEFAULT;
""",
),
]
113 changes: 113 additions & 0 deletions ghostwriter/rolodex/migrations/0063_projectcollabnotefield_and_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Generated by Django 4.2.16 on 2026-01-16 21:05

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
dependencies = [
("rolodex", "0062_projectcollabnote_timestamps_defaults"),
]

operations = [
migrations.CreateModel(
name="ProjectCollabNoteField",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"field_type",
models.CharField(
choices=[("rich_text", "Rich Text"), ("image", "Image")],
default="rich_text",
help_text="Type of content in this field",
max_length=10,
verbose_name="Field Type",
),
),
(
"content",
models.TextField(
blank=True,
default="",
help_text="HTML content for rich text fields",
verbose_name="Content",
),
),
(
"image_width",
models.IntegerField(
blank=True,
editable=False,
null=True,
verbose_name="Image Width",
),
),
(
"image_height",
models.IntegerField(
blank=True,
editable=False,
null=True,
verbose_name="Image Height",
),
),
(
"image",
models.ImageField(
blank=True,
height_field="image_height",
help_text="Image file for image fields",
null=True,
upload_to="collab_note_images/%Y/%m/%d/",
verbose_name="Image",
width_field="image_width",
),
),
(
"position",
models.PositiveIntegerField(
default=0,
help_text="Order within note (lower values first)",
verbose_name="Position",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"note",
models.ForeignKey(
help_text="The note this field belongs to",
on_delete=django.db.models.deletion.CASCADE,
related_name="fields",
to="rolodex.projectcollabnote",
),
),
],
options={
"verbose_name": "Project collaborative note field",
"verbose_name_plural": "Project collaborative note fields",
"ordering": ["note", "position"],
},
),
migrations.AddConstraint(
model_name="projectcollabnotefield",
constraint=models.CheckConstraint(
check=models.Q(
models.Q(("field_type", "rich_text"), ("image", "")),
models.Q(
("field_type", "image"), models.Q(("image", ""), _negated=True)
),
_connector="OR",
),
name="field_type_matches_content",
),
),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Generated by Django 4.2.16 on 2026-01-16 21:05

from django.db import migrations


def migrate_note_content_to_fields(apps, schema_editor):
"""
Migrate existing content from ProjectCollabNote.content to a new
ProjectCollabNoteField for each note that has content.
"""
ProjectCollabNote = apps.get_model("rolodex", "ProjectCollabNote")
ProjectCollabNoteField = apps.get_model("rolodex", "ProjectCollabNoteField")

notes_with_content = ProjectCollabNote.objects.filter(node_type="note").exclude(content="")

for note in notes_with_content:
# Create a rich_text field with the existing content at position 0
ProjectCollabNoteField.objects.create(
note=note,
field_type="rich_text",
content=note.content,
position=0,
)


def reverse_migration(apps, schema_editor):
"""
Reverse migration: copy field content back to note.content.
Only copies the first rich_text field.
"""
ProjectCollabNote = apps.get_model("rolodex", "ProjectCollabNote")
ProjectCollabNoteField = apps.get_model("rolodex", "ProjectCollabNoteField")

for note in ProjectCollabNote.objects.filter(node_type="note"):
# Get the first rich_text field
first_field = note.fields.filter(field_type="rich_text").order_by("position").first()
if first_field:
note.content = first_field.content
note.save()


class Migration(migrations.Migration):
dependencies = [
("rolodex", "0063_projectcollabnotefield_and_more"),
]

operations = [
migrations.RunPython(migrate_note_content_to_fields, reverse_migration),
]
20 changes: 20 additions & 0 deletions ghostwriter/rolodex/migrations/0065_add_defaults_to_timestamps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Generated migration to add database defaults for timestamps

from django.db import migrations


class Migration(migrations.Migration):
dependencies = [
("rolodex", "0064_migrate_note_content_to_fields"),
]

operations = [
migrations.RunSQL(
sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN created_at SET DEFAULT NOW();",
reverse_sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN created_at DROP DEFAULT;",
),
migrations.RunSQL(
sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN updated_at SET DEFAULT NOW();",
reverse_sql="ALTER TABLE rolodex_projectcollabnotefield ALTER COLUMN updated_at DROP DEFAULT;",
),
]
Comment on lines +1 to +20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is raw sql needed here? Django should have an ORM option for this

Loading