diff --git a/ghostwriter/api/views.py b/ghostwriter/api/views.py index d80ab7514..fa338c9dc 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,6 +1374,8 @@ class CheckEditPermissions(JwtRequiredMixin, HasuraActionView): "report_finding_link": ReportFindingLink, "report": Report, "project": Project, + "project_collab_note": ProjectCollabNote, + "project_tree_sync": Project, } def post(self, request): diff --git a/ghostwriter/factories.py b/ghostwriter/factories.py index 7b54396d1..5a55911b0 100644 --- a/ghostwriter/factories.py +++ b/ghostwriter/factories.py @@ -579,6 +579,32 @@ class Meta: operator = factory.SubFactory(UserFactory) +class ProjectCollabNoteFactory(factory.django.DjangoModelFactory): + class Meta: + model = "rolodex.ProjectCollabNote" + + title = Faker("sentence", nb_words=3) + node_type = "note" + content = Faker("rich_text") + position = factory.Sequence(lambda n: n * 1000) + project = factory.SubFactory(ProjectFactory) + + +class ProjectCollabNoteFolderFactory(ProjectCollabNoteFactory): + node_type = "folder" + content = "" + + +class ProjectCollabNoteFieldFactory(factory.django.DjangoModelFactory): + class Meta: + model = "rolodex.ProjectCollabNoteField" + + field_type = "rich_text" + content = Faker("rich_text") + position = factory.Sequence(lambda n: n) + note = factory.SubFactory(ProjectCollabNoteFactory) + + class ClientInviteFactory(factory.django.DjangoModelFactory): class Meta: model = "rolodex.ClientInvite" diff --git a/ghostwriter/rolodex/migrations/0062_projectcollabnote_projectcollabnotefield.py b/ghostwriter/rolodex/migrations/0062_projectcollabnote_projectcollabnotefield.py new file mode 100644 index 000000000..8d587f3ec --- /dev/null +++ b/ghostwriter/rolodex/migrations/0062_projectcollabnote_projectcollabnotefield.py @@ -0,0 +1,194 @@ +import django.db.models.deletion +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("rolodex", "0061_projectrole_position_ordering"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectCollabNote", + fields=[ + ( + "id", + models.AutoField( + 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(default=django.utils.timezone.now), + ), + ( + "updated_at", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "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"), ("content", ""), _connector="OR"), + name="folder_has_no_content", + ), + ), + migrations.CreateModel( + name="ProjectCollabNoteField", + fields=[ + ( + "id", + models.AutoField( + 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, + help_text="Image file for image fields", + height_field="image_height", + 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(default=django.utils.timezone.now), + ), + ( + "updated_at", + models.DateTimeField(default=django.utils.timezone.now), + ), + ( + "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"], + }, + ), + ] diff --git a/ghostwriter/rolodex/migrations/0063_migrate_collab_notes.py b/ghostwriter/rolodex/migrations/0063_migrate_collab_notes.py new file mode 100644 index 000000000..714f2c7df --- /dev/null +++ b/ghostwriter/rolodex/migrations/0063_migrate_collab_notes.py @@ -0,0 +1,39 @@ +"""Data migration to move content from Project.collab_note to ProjectCollabNote.""" + +from django.db import migrations + + +def migrate_collab_notes(apps, schema_editor): + Project = apps.get_model("rolodex", "Project") + ProjectCollabNote = apps.get_model("rolodex", "ProjectCollabNote") + ProjectCollabNoteField = apps.get_model("rolodex", "ProjectCollabNoteField") + + for project in Project.objects.exclude(collab_note=""): + note = ProjectCollabNote.objects.create( + project=project, + title="Notes", + node_type="note", + content=project.collab_note, + position=0, + ) + ProjectCollabNoteField.objects.create( + note=note, + field_type="rich_text", + content=project.collab_note, + position=0, + ) + + +def reverse_migrate(apps, schema_editor): + ProjectCollabNote = apps.get_model("rolodex", "ProjectCollabNote") + ProjectCollabNote.objects.all().delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("rolodex", "0062_projectcollabnote_projectcollabnotefield"), + ] + + operations = [ + migrations.RunPython(migrate_collab_notes, reverse_migrate), + ] diff --git a/ghostwriter/rolodex/migrations/0064_alter_projectcollabnote_id_and_more.py b/ghostwriter/rolodex/migrations/0064_alter_projectcollabnote_id_and_more.py new file mode 100644 index 000000000..454663257 --- /dev/null +++ b/ghostwriter/rolodex/migrations/0064_alter_projectcollabnote_id_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.16 on 2026-03-31 21:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("rolodex", "0063_migrate_collab_notes"), + ] + + operations = [ + migrations.AlterField( + model_name="projectcollabnote", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + migrations.AlterField( + model_name="projectcollabnotefield", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ] diff --git a/ghostwriter/rolodex/migrations/0065_set_collabnote_timestamp_defaults.py b/ghostwriter/rolodex/migrations/0065_set_collabnote_timestamp_defaults.py new file mode 100644 index 000000000..02a7eea4a --- /dev/null +++ b/ghostwriter/rolodex/migrations/0065_set_collabnote_timestamp_defaults.py @@ -0,0 +1,33 @@ +"""Set database-level defaults for collab note timestamp columns. + +Django 4.2's ``default=timezone.now`` only applies at the ORM level. +Hasura and the collab server insert rows directly, bypassing the ORM, +so database-level defaults are required. +""" + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("rolodex", "0064_alter_projectcollabnote_id_and_more"), + ] + + operations = [ + migrations.RunSQL( + sql="ALTER TABLE rolodex_projectcollabnote ALTER COLUMN created_at SET DEFAULT NOW();", + reverse_sql="ALTER TABLE rolodex_projectcollabnote ALTER COLUMN created_at DROP DEFAULT;", + ), + migrations.RunSQL( + sql="ALTER TABLE rolodex_projectcollabnote ALTER COLUMN updated_at SET DEFAULT NOW();", + reverse_sql="ALTER TABLE rolodex_projectcollabnote ALTER COLUMN updated_at DROP DEFAULT;", + ), + 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 7729dc39d..0ec759f6c 100644 --- a/ghostwriter/rolodex/models.py +++ b/ghostwriter/rolodex/models.py @@ -3,6 +3,8 @@ # Standard Libraries from datetime import time, timedelta +from django.utils import timezone + # Django Imports from django.conf import settings from django.contrib.auth import get_user_model @@ -847,6 +849,178 @@ 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`. + """ + + 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)", + ) + 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)", + ) + position = models.PositiveIntegerField( + "Position", + default=0, + help_text="Order within parent (lower values first)", + ) + created_at = models.DateTimeField(default=timezone.now) + updated_at = models.DateTimeField(default=timezone.now) + + class Meta: + ordering = ["position", "title"] + verbose_name = "Project collaborative note" + verbose_name_plural = "Project collaborative notes" + constraints = [ + 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 save(self, *args, **kwargs): + self.updated_at = timezone.now() + super().save(*args, **kwargs) + + 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(default=timezone.now) + updated_at = models.DateTimeField(default=timezone.now) + + class Meta: + ordering = ["note", "position"] + verbose_name = "Project collaborative note field" + verbose_name_plural = "Project collaborative note fields" + + def __str__(self): + return f"{self.note.title} - {self.field_type} field #{self.position}" + + def save(self, *args, **kwargs): + self.updated_at = timezone.now() + super().save(*args, **kwargs) + + 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 91a3798cc..c3c5d8b9f 100644 --- a/ghostwriter/rolodex/templates/rolodex/project_detail.html +++ b/ghostwriter/rolodex/templates/rolodex/project_detail.html @@ -2152,5 +2152,7 @@