diff --git a/ghostwriter/api/views.py b/ghostwriter/api/views.py index bf30ed2ca..73633e5b1 100644 --- a/ghostwriter/api/views.py +++ b/ghostwriter/api/views.py @@ -53,6 +53,7 @@ from ghostwriter.reporting.views2.report_finding_link import get_position from ghostwriter.rolodex.models import ( Project, + ProjectCollabNote, ProjectContact, ProjectObjective, ProjectSubTask, @@ -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) + + cls = self.available_models.get(model) if cls is None: return JsonResponse(utils.generate_hasura_error_payload("Unrecognized model type", "InvalidRequestBody"), status=401) diff --git a/ghostwriter/rolodex/migrations/0060_projectcollabnote.py b/ghostwriter/rolodex/migrations/0060_projectcollabnote.py new file mode 100644 index 000000000..e5f69ddcb --- /dev/null +++ b/ghostwriter/rolodex/migrations/0060_projectcollabnote.py @@ -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", + ), + ), + ] diff --git a/ghostwriter/rolodex/migrations/0061_migrate_collab_notes.py b/ghostwriter/rolodex/migrations/0061_migrate_collab_notes.py new file mode 100644 index 000000000..10a2bcc16 --- /dev/null +++ b/ghostwriter/rolodex/migrations/0061_migrate_collab_notes.py @@ -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), + ] diff --git a/ghostwriter/rolodex/migrations/0062_projectcollabnote_timestamps_defaults.py b/ghostwriter/rolodex/migrations/0062_projectcollabnote_timestamps_defaults.py new file mode 100644 index 000000000..100efac07 --- /dev/null +++ b/ghostwriter/rolodex/migrations/0062_projectcollabnote_timestamps_defaults.py @@ -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; + """, + ), + ] diff --git a/ghostwriter/rolodex/migrations/0063_projectcollabnotefield_and_more.py b/ghostwriter/rolodex/migrations/0063_projectcollabnotefield_and_more.py new file mode 100644 index 000000000..6adfc9bfa --- /dev/null +++ b/ghostwriter/rolodex/migrations/0063_projectcollabnotefield_and_more.py @@ -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", + ), + ), + ] diff --git a/ghostwriter/rolodex/migrations/0064_migrate_note_content_to_fields.py b/ghostwriter/rolodex/migrations/0064_migrate_note_content_to_fields.py new file mode 100644 index 000000000..ea044f40e --- /dev/null +++ b/ghostwriter/rolodex/migrations/0064_migrate_note_content_to_fields.py @@ -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), + ] diff --git a/ghostwriter/rolodex/migrations/0065_add_defaults_to_timestamps.py b/ghostwriter/rolodex/migrations/0065_add_defaults_to_timestamps.py new file mode 100644 index 000000000..f003e8c02 --- /dev/null +++ b/ghostwriter/rolodex/migrations/0065_add_defaults_to_timestamps.py @@ -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;", + ), + ] diff --git a/ghostwriter/rolodex/models.py b/ghostwriter/rolodex/models.py index bd7d63dd7..931122ad5 100644 --- a/ghostwriter/rolodex/models.py +++ b/ghostwriter/rolodex/models.py @@ -747,6 +747,186 @@ def __str__(self): return f"{self.project}: {self.timestamp} - {self.note}" +class ProjectCollabNoteType(models.TextChoices): + """Choices for the type of collaborative note node.""" + + FOLDER = "folder", "Folder" + NOTE = "note", "Note" + + +class ProjectCollabNote(models.Model): + """ + Stores hierarchical collaborative notes for a project. + + Folders are containers only; notes are leaf nodes with rich text content. + Related to :model:`rolodex.Project`. + """ + + # Parent relationships + project = models.ForeignKey( + Project, + on_delete=models.CASCADE, + related_name="collab_notes", + help_text="The project this note belongs to", + ) + parent = models.ForeignKey( + "self", + on_delete=models.CASCADE, + null=True, + blank=True, + related_name="children", + help_text="Parent folder (null for root-level items)", + ) + + # Node properties + title = models.CharField( + "Title", + max_length=255, + help_text="Title of the note or folder", + ) + node_type = models.CharField( + "Type", + max_length=10, + choices=ProjectCollabNoteType.choices, + default=ProjectCollabNoteType.NOTE, + help_text="Whether this is a folder or a note", + ) + content = models.TextField( + "Content", + default="", + blank=True, + help_text="Rich text content (for notes only, empty for folders)", + ) + + # Ordering + position = models.PositiveIntegerField( + "Position", + default=0, + help_text="Order within parent (lower values first)", + ) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["position", "title"] + verbose_name = "Project collaborative note" + verbose_name_plural = "Project collaborative notes" + constraints = [ + # Ensure folders have no content + models.CheckConstraint( + check=Q(node_type="note") | Q(content=""), + name="folder_has_no_content", + ), + ] + + def __str__(self): + return f"{self.title} ({self.node_type})" + + def get_absolute_url(self): + return reverse("rolodex:project_detail", args=[str(self.project.id)]) + + def user_can_view(self, user) -> bool: + return self.project.user_can_view(user) + + def user_can_edit(self, user) -> bool: + return self.project.user_can_edit(user) + + def user_can_delete(self, user) -> bool: + return self.project.user_can_delete(user) + + +class ProjectCollabNoteFieldType(models.TextChoices): + """Choices for the type of collaborative note field.""" + + RICH_TEXT = "rich_text", "Rich Text" + IMAGE = "image", "Image" + + +class ProjectCollabNoteField(models.Model): + """ + Stores individual fields within a collaborative note. + + Each ProjectCollabNote can have multiple fields that are reorderable. + Fields can be rich text or images. + Related to :model:`rolodex.ProjectCollabNote`. + """ + + note = models.ForeignKey( + ProjectCollabNote, + on_delete=models.CASCADE, + related_name="fields", + help_text="The note this field belongs to", + ) + field_type = models.CharField( + "Field Type", + max_length=10, + choices=ProjectCollabNoteFieldType.choices, + default=ProjectCollabNoteFieldType.RICH_TEXT, + help_text="Type of content in this field", + ) + content = models.TextField( + "Content", + default="", + blank=True, + help_text="HTML content for rich text fields", + ) + image_width = models.IntegerField( + "Image Width", + blank=True, + null=True, + editable=False, + ) + image_height = models.IntegerField( + "Image Height", + blank=True, + null=True, + editable=False, + ) + image = models.ImageField( + "Image", + upload_to="collab_note_images/%Y/%m/%d/", + blank=True, + null=True, + width_field="image_width", + height_field="image_height", + help_text="Image file for image fields", + ) + position = models.PositiveIntegerField( + "Position", + default=0, + help_text="Order within note (lower values first)", + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["note", "position"] + verbose_name = "Project collaborative note field" + verbose_name_plural = "Project collaborative note fields" + constraints = [ + # Ensure image fields have an image and rich_text fields don't + models.CheckConstraint( + check=(Q(field_type="rich_text") & Q(image="")) + | (Q(field_type="image") & ~Q(image="")), + name="field_type_matches_content", + ), + ] + + def __str__(self): + return f"{self.note.title} - {self.field_type} field #{self.position}" + + def user_can_view(self, user) -> bool: + return self.note.user_can_view(user) + + def user_can_edit(self, user) -> bool: + return self.note.user_can_edit(user) + + def user_can_delete(self, user) -> bool: + return self.note.user_can_delete(user) + + class ProjectScope(models.Model): """Stores an individual scope list, related to an individual :model:`rolodex.Project`.""" diff --git a/ghostwriter/rolodex/templates/rolodex/project_detail.html b/ghostwriter/rolodex/templates/rolodex/project_detail.html index fe14d0107..9a8ae3f98 100644 --- a/ghostwriter/rolodex/templates/rolodex/project_detail.html +++ b/ghostwriter/rolodex/templates/rolodex/project_detail.html @@ -2135,5 +2135,5 @@