Skip to content

Commit

Permalink
Merge branch 'design-selection-improvements' into 'main'
Browse files Browse the repository at this point in the history
Design selection improvements

See merge request reportcreator/reportcreator!820
  • Loading branch information
MWedl committed Jan 14, 2025
2 parents cca3fba + 632dae4 commit f803a20
Show file tree
Hide file tree
Showing 18 changed files with 113 additions and 40 deletions.
2 changes: 2 additions & 0 deletions api/src/reportcreator_api/pentests/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
@admin.register(ProjectType)
class ProjectTypeAdmin(SimpleHistoryAdmin, BaseAdmin):
list_display = ['id', 'name', 'created', 'status', 'tags']
readonly_fields = ('default_notes',)

def link_projects(self, obj):
return admin_changelist_url('Projects using this ProjectType', 'pentests', 'pentestproject', {'project_type_id': obj.id})
Expand All @@ -54,6 +55,7 @@ class ProjectMemberInfoInlineAdmin(admin.StackedInline):
class PentestProjectAdmin(SimpleHistoryAdmin, BaseAdmin):
list_display = ['id', 'name', 'created', 'readonly', 'tags']
inlines = [ProjectMemberInfoInlineAdmin]
readonly_fields = ('imported_members',)

def link_findings(self, obj):
return admin_changelist_url('Findings of this project', 'pentests', 'pentestfinding', {'project_id': obj.id})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.1.4 on 2025-01-09 14:32

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('pentests', '0059_alter_findingtemplatetranslation_language_and_more'),
]

operations = [
migrations.AddField(
model_name='projecttype',
name='usage_count',
field=models.PositiveIntegerField(db_index=True, default=0),
),
]
3 changes: 2 additions & 1 deletion api/src/reportcreator_api/pentests/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ class ProjectType(LockableMixin, LanguageMixin, ImportableMixin, BaseModel):
max_length=20, choices=ProjectTypeStatus.choices, default=ProjectTypeStatus.IN_PROGRESS, db_index=True,
)
tags = ArrayField(base_field=models.CharField(max_length=255), default=list, blank=True, db_index=True)
usage_count = models.PositiveIntegerField(default=0, db_index=True)

# PDF Template
report_template = EncryptedField(base_field=models.TextField(default="", blank=True))
Expand Down Expand Up @@ -117,7 +118,7 @@ class ProjectType(LockableMixin, LanguageMixin, ImportableMixin, BaseModel):

copy_of = models.ForeignKey(to="ProjectType", on_delete=models.SET_NULL, null=True, blank=True)

history = HistoricalRecords(cascade_delete_history=True)
history = HistoricalRecords(cascade_delete_history=True, excluded_fields=['usage_count'])
objects = querysets.ProjectTypeManager()

class Meta:
Expand Down
7 changes: 6 additions & 1 deletion api/src/reportcreator_api/pentests/querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,9 @@ def custom_finding_field_definitions(self):
all_fields[f['id']] = f
return parse_field_definition(all_fields.values())

def increment_usage_count(self, by=1):
return self.update(usage_count=models.F('usage_count') + models.Value(by))


class ProjectTypeManager(models.Manager.from_queryset(ProjectTypeQueryset)):
use_in_migrations = True
Expand All @@ -107,6 +110,7 @@ def copy(self, instance, **kwargs):
# Copy model
instance = copy.copy(instance)
instance.created = timezone.now()
instance.usage_count = 0
for k, v in (kwargs or {}).items():
setattr(instance, k, v)
instance.copy_of_id = instance.pk
Expand Down Expand Up @@ -173,6 +177,7 @@ def copy(self, instance, **kwargs):
instance.project_type = instance.project_type.copy(
linked_user=None,
source=SourceEnum.SNAPSHOT if instance.project_type.source not in [SourceEnum.IMPORTED_DEPENDENCY, SourceEnum.CUSTOMIZED] else instance.project_type.source,
usage_count=1,
)
instance.skip_post_create_signal = True
instance.save()
Expand Down Expand Up @@ -386,13 +391,13 @@ def copy(self, instance, **kwargs):
# Copy model
instance = copy.copy(instance)
instance.created = timezone.now()
instance.usage_count = 0
for k, v in (kwargs or {}).items():
setattr(instance, k, v)
instance.copy_of_id = instance.pk
instance.pk = None
instance.main_translation = None
instance.lock_info_data = None
instance.usage_count = 0
instance.skip_post_create_signal = True
instance.save_without_historical_record()

Expand Down
12 changes: 9 additions & 3 deletions api/src/reportcreator_api/pentests/serializers/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
PentestProject,
ProjectMemberInfo,
ProjectMemberRole,
ProjectType,
ReportSection,
SourceEnum,
UploadedImage,
Expand Down Expand Up @@ -376,7 +377,8 @@ def validate_project_type(self, value):
@transaction.atomic
@history_context()
def create(self, validated_data):
project_type = validated_data.pop('project_type').copy(linked_user=None, source=SourceEnum.SNAPSHOT)
project_type_original = validated_data.pop('project_type')
project_type = project_type_original.copy(linked_user=None, source=SourceEnum.SNAPSHOT, usage_count=1)
validated_data.pop('force_change_project_type')
members = validated_data.pop('members', [])

Expand All @@ -400,6 +402,7 @@ def create(self, validated_data):

project_type.linked_project = project
project_type.save(update_fields=['linked_project'])
ProjectType.objects.filter(id=project_type_original.id).increment_usage_count()
sysreptor_signals.post_create.send(sender=project.__class__, instance=project)

return project
Expand All @@ -409,10 +412,12 @@ def update(self, instance, validated_data):
if (imported_members := validated_data.get('imported_members')) is not None:
validated_data['imported_members'] = self.fields['imported_members'].update(instance.imported_members, imported_members)
if (project_type := validated_data.get('project_type')) and instance.project_type != project_type and project_type.linked_project != instance:
ProjectType.objects.filter(id=project_type.id).increment_usage_count()
validated_data['project_type'] = project_type.copy(
linked_project=instance,
linked_user=None,
source=SourceEnum.SNAPSHOT)
source=SourceEnum.SNAPSHOT,
usage_count=1)

instance = super().update(instance, validated_data)
if members is not None:
Expand Down Expand Up @@ -510,7 +515,8 @@ def update(self, instance, validated_data):
name='Customization of ' + instance.project_type.name,
source=SourceEnum.CUSTOMIZED,
linked_project=instance,
linked_user=None)
linked_user=None,
usage_count=1)
instance.save()
return instance

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@ class Meta:
model = ProjectType
fields = [
'id', 'created', 'updated', 'source', 'scope',
'name', 'language', 'status', 'tags',
'name', 'language', 'status', 'tags', 'usage_count',
'details', 'assets',
]
read_only_fields = ['usage_count']


class ProjectTypeDetailSerializer(ProjectTypeShortSerializer):
Expand Down Expand Up @@ -102,6 +103,7 @@ def update(self, instance, validated_data):
return instance.copy(
name='Copy of ' + instance.name,
source=SourceEnum.CREATED,
usage_count=instance.usage_count,
**({
ProjectTypeScope.GLOBAL: {'linked_user': None, 'linked_project': None},
ProjectTypeScope.PRIVATE: {'linked_user': self.context['request'].user, 'linked_project': None},
Expand Down
6 changes: 5 additions & 1 deletion api/src/reportcreator_api/pentests/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ def filter_linked_project(self, queryset, name, value):


class ProjectTypeOrderingFilter(OrderingFilter):
ordering_fields = ['created', 'updated', 'name', 'scope', 'status']
ordering_fields = ['created', 'updated', 'name', 'scope', 'status', 'usage']

def get_queryset_ordering(self, request, queryset, view):
out = []
Expand All @@ -522,6 +522,10 @@ def get_queryset_ordering(self, request, queryset, view):
out.append('status_order')
elif o == '-status':
out.append('-status_order')
elif o == 'usage':
out.append('usage_count')
elif o == '-usage':
out.append('-usage_count')
else:
out.append(o)
return out
Expand Down
11 changes: 11 additions & 0 deletions api/src/reportcreator_api/tests/test_api2.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ def test_create_project(self):
# ProjectType copied on create
assert p['project_type'] != str(self.project_type.id)
assert self.client.get(reverse('projecttype-detail', kwargs={'pk': p['project_type']})).json()['source'] == SourceEnum.SNAPSHOT
# ProjectType.usage_count incremented
self.project_type.refresh_from_db()
assert self.project_type.usage_count == 1

def test_copy_project(self):
project = create_project(project_type=self.project_type, members=[self.user])
Expand Down Expand Up @@ -81,6 +84,10 @@ def test_change_design(self):
assert pt.source == SourceEnum.SNAPSHOT
assert pt.linked_project == project

# ProjectType.usage_count incremented
self.project_type.refresh_from_db()
assert self.project_type.usage_count == 1

def test_change_imported_members(self):
project = create_project(members=[self.user], imported_members=[{
'id': uuid4(),
Expand Down Expand Up @@ -318,6 +325,10 @@ def test_create_finding_from_template(self):
assert f2.data['data']['title'] == 'title translation'
assert f2.data['data']['description'] == 'description main'

# Template.usage_count incremented
self.template.refresh_from_db()
assert self.template.usage_count == 2

def test_create_finding_from_template_images(self):
template = create_template(data={'description': '![image](/images/name/image.png)'}, images_kwargs=[{'name': 'image.png'}])
project = create_project(members=[self.user], findings_kwargs=[], images_kwargs=[])
Expand Down
4 changes: 2 additions & 2 deletions api/src/reportcreator_api/tests/test_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -393,7 +393,7 @@ def add_history_test():
for h in history:
res_pt = self.client.get(reverse('projecttypehistory-detail', kwargs={'projecttype_pk': pt.id, 'history_date': h['history_date'].isoformat()}))
assert res_pt.status_code == 200
assert omit_keys(res_pt.data, ['updated', 'lock_info']) == omit_keys(h['instance'], ['updated', 'lock_info'])
assert omit_keys(res_pt.data, ['updated', 'lock_info', 'usage_count']) == omit_keys(h['instance'], ['updated', 'lock_info', 'usage_count'])
for name, content in h['assets'].items():
res_i = self.client.get(reverse('projecttypehistory-asset-by-name', kwargs={'projecttype_pk': pt.id, 'history_date': h['history_date'].isoformat(), 'filename': name}))
assert res_i.status_code == 200
Expand Down Expand Up @@ -577,7 +577,7 @@ def add_history_test():
assert {f['id']: omit_keys(f, ['updated']) for f in res_p.data['findings']} == {f['id']: omit_keys(f, ['updated']) for f in h['project']['findings']}

res_pt = self.client.get(reverse('projecttypehistory-detail', kwargs={'projecttype_pk': h['project_type']['id'], 'history_date': h['history_date']}))
assert omit_keys(res_pt.data, ['updated', 'lock_info']) == omit_keys(h['project_type'], ['updated', 'lock_info'])
assert omit_keys(res_pt.data, ['updated', 'lock_info', 'usage_count']) == omit_keys(h['project_type'], ['updated', 'lock_info', 'usage_count'])

for fh in h['findings']:
res_f = self.client.get(reverse('pentestprojecthistory-finding', kwargs=url_kwargs | {'id': fh['id']}))
Expand Down
2 changes: 2 additions & 0 deletions api/src/reportcreator_api/tests/test_import_export.py
Original file line number Diff line number Diff line change
Expand Up @@ -558,6 +558,7 @@ def test_copy_project(self):
self.assert_project_type_copy_equal(p.project_type, cp.project_type, exclude_fields=['source', 'linked_project'])
assert cp.project_type.source == SourceEnum.SNAPSHOT
assert cp.project_type.linked_project == cp
assert cp.project_type.usage_count == 1
assert members_equal(p.members, cp.members)

assert set(p.images.values_list('id', flat=True)).intersection(cp.images.values_list('id', flat=True)) == set()
Expand Down Expand Up @@ -593,6 +594,7 @@ def test_copy_project_type(self):
cp = pt.copy()

self.assert_project_type_copy_equal(pt, cp)
assert cp.usage_count == 0

def test_copy_template(self):
user = create_user()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
:query-filters="{scope: [ProjectTypeScope.GLOBAL]}"
:additional-items="([{id: 'all', name: 'All Designs'}] as ProjectType[])"
:disabled="disabled"
:required="true"
label="Show fields of"
variant="underlined"
/>
Expand Down
3 changes: 3 additions & 0 deletions packages/frontend/src/pages/projects/[projectId]/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
:error-messages="serverErrors?.project_type || []"
:readonly="readonly"
:append-link="true"
:required="true"
return-object
class="mt-4"
>
Expand Down Expand Up @@ -146,6 +147,7 @@ import { ProjectTypeScope } from '#imports';
const route = useRoute();
const auth = useAuth();
const projectStore = useProjectStore();
const projectTypeStore = useProjectTypeStore();
const project = await useFetchE<PentestProject>(`/api/v1/pentestprojects/${route.params.projectId}/`, { method: 'GET', key: 'projectSettings:project', deep: true });
const serverErrors = ref<any|null>(null);
Expand Down Expand Up @@ -179,6 +181,7 @@ const { toolbarAttrs, readonly, editMode } = useLockEdit<PentestProject>({
project.value = await projectStore.partialUpdateProject(project.value,
['name', 'project_type', 'force_change_project_type', 'language', 'tags', 'members', 'imported_members']);
serverErrors.value = null;
projectType.value = await projectTypeStore.getById(project.value.project_type);
} catch (error: any) {
if (error?.status === 400 && error?.data) {
serverErrors.value = error.data;
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/src/pages/projects/new.vue
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<s-project-type-selection
v-model="projectForm.project_type"
:error-messages="serverErrors?.project_type || []"
:required="true"
class="mt-4"
data-testid="project-type"
/>
Expand Down
13 changes: 3 additions & 10 deletions packages/frontend/test/e2e/integration/designs.test.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { expect, test } from '@playwright/test';
import { DemoDataState, DemoDataType } from '../util/demo_data';
import { createProject } from '../util/helpers';

const designName = 'My Test Design';
test('A User can create an Design with a Name', async ({ page }) => {
Expand Down Expand Up @@ -45,16 +46,8 @@ test('A User can create an Design with a Name', async ({ page }) => {

test('Design Settings are reflected in a Project', async ({ page }) => {
const testState = new DemoDataState();
await page.goto('/projects');
await page.waitForSelector('text=Projects');
await page.getByTestId('create-button').click();
await page.getByLabel('Name').fill('My Design Test Project');
await page.getByTestId('project-type').getByRole('textbox').fill(designName);
await page.getByTestId('page-loader').waitFor({ state: 'hidden' });
await page.getByText('No data found').waitFor({ state: 'hidden' });
const designId = testState.designs.at(testState.designs.length - 1);
await page.getByTestId(`design-${designId}`).click();
await page.getByTestId('submit-project').click();
await createProject(page, { projectName: 'My Design Test Project', designId: testState.designs.at(testState.designs.length - 1)!, designName: designName });

await page.getByRole('link', { name: 'Executive Summary' }).click();
await page.getByLabel('Assignee', { exact: true }).waitFor();
expect(await page.getByRole('textbox').getByText('This is a test executive summary').isVisible()).toBeTruthy();
Expand Down
28 changes: 8 additions & 20 deletions packages/frontend/test/e2e/integration/projects.spec.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,17 @@
import { expect, test } from '@playwright/test';
import { DemoDataState } from '../util/demo_data';
const projectName = 'My Test Project';
import { createProject } from '../util/helpers';
const projectName = 'Updated Project Name';
test('A User can create an Project with a Name', async ({ page }) => {
await page.goto('/projects');
await page.waitForSelector('text=Projects');

expect(await page.title()).toBe('Projects | SysReptor');
// Create New Design Modal
await page.getByTestId('create-button').click();
// Create New Design Modal
await page.getByLabel('Name').fill(projectName);
const designId = new DemoDataState().designs[0];

await page.getByTestId('project-type').getByRole('textbox').fill('Demo Matrix');
await page.getByTestId('page-loader').waitFor({ state: 'hidden' });
await page.getByText('No data found').waitFor({ state: 'hidden' });
await page.getByTestId('design-' + designId).locator('nth=0').click();
await page.getByTestId('submit-project').click();
const oldProjectName = 'My Test Project'
await createProject(page, { projectName: oldProjectName, designId: new DemoDataState().designs[0]!, designName: 'Demo Matrix' });

// Verify Project Name
await expect(page.getByText(projectName)).toBeVisible();
await expect(page.getByText(oldProjectName)).toBeVisible();

await page.getByTestId('project-settings-tab').click();
await page.getByLabel('Name').waitFor();
await page.getByLabel('Name').fill('Updated Project Name');
await page.getByLabel('Name').fill(projectName);
await page.getByLabel('Tags').fill('Updated Tags');
await page.keyboard.press('Enter');
await page.getByRole('button', { name: 'Badge' }).click();
Expand All @@ -33,11 +21,11 @@ test('A User can create an Project with a Name', async ({ page }) => {
test('A User can delete a Project', async ({ page }) => {
await page.goto('/projects');
await page.waitForSelector('text=Projects');
await page.getByRole('link', { name: 'Updated Project Name' }).click();
await page.getByRole('link', { name: projectName }).click();
await page.getByTestId('project-settings-tab').click();
await page.getByTestId('options-dots').click();
await page.getByText('Delete').click();
await page.getByTestId('confirm-input').getByRole('textbox').fill('Updated Project Name');
await page.getByTestId('confirm-input').getByRole('textbox').fill(projectName);
await page.getByRole('button', { name: 'Delete' }).click();
await page.waitForSelector('text=Projects');

Expand Down
22 changes: 22 additions & 0 deletions packages/frontend/test/e2e/util/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Page } from "@playwright/test";

export async function createProject(page: Page, options: { projectName: string, designId: string, designName: string }) {
// Click create project button
await page.goto('/projects');
await page.waitForSelector('text=Projects');
await page.getByTestId('create-button').click();

// Fill project name
await page.getByLabel('Name').fill(options.projectName);

// Select design
const textbox = await page.getByTestId('project-type').getByRole('textbox');
textbox.clear();
textbox.fill(options.designName);
await page.getByTestId('page-loader').waitFor({ state: 'hidden' });
await page.getByText('No data found').waitFor({ state: 'hidden' });
await page.getByTestId(`design-${options.designId}`).click();

// Create
await page.getByTestId('submit-project').click();
}
Loading

0 comments on commit f803a20

Please sign in to comment.