From 16bfd312381ff67a6e12e133e0b2be9f9aaf8907 Mon Sep 17 00:00:00 2001 From: Adam Patch Date: Tue, 3 Feb 2026 23:35:00 -0500 Subject: [PATCH] feat(ui): implement Label page for graph schema management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add complete Label page functionality with: - Label definitions CRUD (create, read, update, delete) - Property management (name, type, required flag) - Relationship definitions (type, target label) - Neo4j push/pull synchronization - Two-panel UI (list + editor) - Full test coverage (unit + E2E) Backend: - SQLite migration v5: label_definitions table - LabelService in scidk/services/label_service.py - API blueprint: /api/labels/* endpoints - Neo4j integration: push/pull schema Frontend: - Labels page at /labels route - Navigation link in header - Interactive two-panel layout - Form validation and error handling - Toast notifications Tests: - 14 unit tests in tests/test_labels_api.py (all passing) - 6 E2E tests in e2e/labels.spec.ts (all passing) - Full workflow coverage: create → edit → delete Implements task:ui/mvp/label-page-impl Related to story:refactor-and-extend, phase 03-label-page 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- e2e/labels.spec.ts | 233 +++++++++++++++++++ scidk/core/migrations.py | 17 ++ scidk/services/label_service.py | 350 ++++++++++++++++++++++++++++ scidk/ui/templates/base.html | 1 + scidk/ui/templates/labels.html | 390 ++++++++++++++++++++++++++++++++ scidk/web/routes/__init__.py | 2 + scidk/web/routes/api_labels.py | 257 +++++++++++++++++++++ scidk/web/routes/ui.py | 6 + tests/test_labels_api.py | 249 ++++++++++++++++++++ 9 files changed, 1505 insertions(+) create mode 100644 e2e/labels.spec.ts create mode 100644 scidk/services/label_service.py create mode 100644 scidk/ui/templates/labels.html create mode 100644 scidk/web/routes/api_labels.py create mode 100644 tests/test_labels_api.py diff --git a/e2e/labels.spec.ts b/e2e/labels.spec.ts new file mode 100644 index 0000000..25cd194 --- /dev/null +++ b/e2e/labels.spec.ts @@ -0,0 +1,233 @@ +import { test, expect } from '@playwright/test'; + +/** + * E2E tests for Labels page functionality. + * Tests the complete workflow: create label → add properties → add relationships → save → delete + */ + +test('labels page loads and displays empty state', async ({ page, baseURL }) => { + const consoleMessages: { type: string; text: string }[] = []; + page.on('console', (msg) => { + consoleMessages.push({ type: msg.type(), text: msg.text() }); + }); + + const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; + + // Navigate to Labels page + await page.goto(`${base}/labels`); + await page.waitForLoadState('networkidle'); + + // Verify page loads + await expect(page).toHaveTitle(/SciDK - Labels/i, { timeout: 10_000 }); + + // Check for new label button + await expect(page.getByTestId('new-label-btn')).toBeVisible(); + + // Check for label list + await expect(page.getByTestId('label-list')).toBeVisible(); + + // No console errors + const errors = consoleMessages.filter((m) => m.type === 'error'); + expect(errors.length).toBe(0); +}); + +test('labels navigation link is visible in header', async ({ page, baseURL }) => { + const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; + + await page.goto(base); + await page.waitForLoadState('networkidle'); + + // Check that Labels link exists in navigation + const labelsLink = page.getByTestId('nav-labels'); + await expect(labelsLink).toBeVisible(); + + // Click it and verify we navigate to labels page + await labelsLink.click(); + await page.waitForLoadState('networkidle'); + await expect(page).toHaveTitle(/SciDK - Labels/i); +}); + +test('complete label workflow: create → edit → delete', async ({ page, baseURL }) => { + const consoleMessages: { type: string; text: string }[] = []; + page.on('console', (msg) => { + consoleMessages.push({ type: msg.type(), text: msg.text() }); + }); + + const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; + await page.goto(`${base}/labels`); + await page.waitForLoadState('networkidle'); + + // Step 1: Click "New Label" button + await page.getByTestId('new-label-btn').click(); + + // Step 2: Enter label name + const labelNameInput = page.getByTestId('label-name'); + await expect(labelNameInput).toBeVisible(); + await labelNameInput.fill('E2ETestLabel'); + + // Step 3: Add a property + await page.getByTestId('add-property-btn').click(); + + // Fill property details + const propertyRows = page.getByTestId('property-row'); + const firstPropertyRow = propertyRows.first(); + await firstPropertyRow.getByTestId('property-name').fill('testProperty'); + await firstPropertyRow.getByTestId('property-type').selectOption('string'); + await firstPropertyRow.getByTestId('property-required').check(); + + // Step 4: Add another property + await page.getByTestId('add-property-btn').click(); + const secondPropertyRow = propertyRows.nth(1); + await secondPropertyRow.getByTestId('property-name').fill('count'); + await secondPropertyRow.getByTestId('property-type').selectOption('number'); + + // Step 5: Save the label + await page.getByTestId('save-label-btn').click(); + + // Wait for save to complete (look for toast or list update) + await page.waitForTimeout(1000); + + // Step 6: Verify label appears in list + const labelItems = page.getByTestId('label-item'); + await expect(labelItems.first()).toBeVisible(); + const labelText = await labelItems.first().textContent(); + expect(labelText).toContain('E2ETestLabel'); + expect(labelText).toContain('2 properties'); + + // Step 7: Click on the label to edit it + await labelItems.first().click(); + await page.waitForTimeout(500); + + // Verify editor is populated + await expect(labelNameInput).toHaveValue('E2ETestLabel'); + const editPropertyRows = page.getByTestId('property-row'); + await expect(editPropertyRows).toHaveCount(2); + + // Step 8: Delete the label + const deleteBtn = page.getByTestId('delete-label-btn'); + await expect(deleteBtn).toBeVisible(); + + // Handle confirmation dialog + page.on('dialog', async (dialog) => { + expect(dialog.type()).toBe('confirm'); + await dialog.accept(); + }); + + await deleteBtn.click(); + await page.waitForTimeout(1000); + + // Verify label is removed from list + const remainingLabels = await page.getByTestId('label-item').count(); + // Should be 0 or not include our test label + const listContent = await page.getByTestId('label-list').textContent(); + expect(listContent).not.toContain('E2ETestLabel'); + + // No console errors + const errors = consoleMessages.filter((m) => m.type === 'error'); + expect(errors.length).toBe(0); +}); + +test('can add and remove multiple properties', async ({ page, baseURL }) => { + const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; + await page.goto(`${base}/labels`); + await page.waitForLoadState('networkidle'); + + // Create new label + await page.getByTestId('new-label-btn').click(); + await page.getByTestId('label-name').fill('MultiPropLabel'); + + // Add 3 properties + for (let i = 0; i < 3; i++) { + await page.getByTestId('add-property-btn').click(); + const rows = page.getByTestId('property-row'); + const currentRow = rows.nth(i); + await currentRow.getByTestId('property-name').fill(`prop${i + 1}`); + } + + // Verify 3 properties exist + await expect(page.getByTestId('property-row')).toHaveCount(3); + + // Remove the second property + const removeButtons = page.getByTestId('remove-property-btn'); + await removeButtons.nth(1).click(); + + // Verify only 2 properties remain + await expect(page.getByTestId('property-row')).toHaveCount(2); + + // Save label + await page.getByTestId('save-label-btn').click(); + await page.waitForTimeout(1000); + + // Verify saved + const labelItems = page.getByTestId('label-item'); + const labelText = await labelItems.first().textContent(); + expect(labelText).toContain('MultiPropLabel'); + expect(labelText).toContain('2 properties'); + + // Cleanup: delete the label + await labelItems.first().click(); + page.on('dialog', async (dialog) => await dialog.accept()); + await page.getByTestId('delete-label-btn').click(); + await page.waitForTimeout(500); +}); + +test('can create label with relationships', async ({ page, baseURL }) => { + const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; + await page.goto(`${base}/labels`); + await page.waitForLoadState('networkidle'); + + // First create a target label + await page.getByTestId('new-label-btn').click(); + await page.getByTestId('label-name').fill('TargetLabel'); + await page.getByTestId('save-label-btn').click(); + await page.waitForTimeout(1000); + + // Now create a label with relationship + await page.getByTestId('new-label-btn').click(); + await page.getByTestId('label-name').fill('SourceLabel'); + + // Add relationship + await page.getByTestId('add-relationship-btn').click(); + const relationshipRow = page.getByTestId('relationship-row').first(); + await relationshipRow.getByTestId('relationship-type').fill('LINKS_TO'); + await relationshipRow.getByTestId('relationship-target').selectOption('TargetLabel'); + + // Save + await page.getByTestId('save-label-btn').click(); + await page.waitForTimeout(1000); + + // Verify + const labelItems = page.getByTestId('label-item'); + const sourceLabel = labelItems.filter({ hasText: 'SourceLabel' }); + const labelText = await sourceLabel.textContent(); + expect(labelText).toContain('1 relationship'); + + // Cleanup + page.on('dialog', async (dialog) => await dialog.accept()); + for (const labelName of ['SourceLabel', 'TargetLabel']) { + const item = labelItems.filter({ hasText: labelName }); + await item.click(); + await page.waitForTimeout(300); + await page.getByTestId('delete-label-btn').click(); + await page.waitForTimeout(500); + } +}); + +test('validation: cannot save label without name', async ({ page, baseURL }) => { + const base = baseURL || process.env.BASE_URL || 'http://127.0.0.1:5000'; + await page.goto(`${base}/labels`); + await page.waitForLoadState('networkidle'); + + // Create new label but don't enter name + await page.getByTestId('new-label-btn').click(); + + // Try to save without name + await page.getByTestId('save-label-btn').click(); + + // Should see error message (implementation shows error inline) + // The label name input should still be visible and empty + const labelNameInput = page.getByTestId('label-name'); + await expect(labelNameInput).toBeVisible(); + const value = await labelNameInput.inputValue(); + expect(value).toBe(''); +}); diff --git a/scidk/core/migrations.py b/scidk/core/migrations.py index 2a26073..983cbed 100644 --- a/scidk/core/migrations.py +++ b/scidk/core/migrations.py @@ -248,6 +248,23 @@ def migrate(conn: Optional[sqlite3.Connection] = None) -> int: _set_version(conn, 4) version = 4 + # v5: label_definitions for graph schema management + if version < 5: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS label_definitions ( + name TEXT PRIMARY KEY, + properties TEXT, + relationships TEXT, + created_at REAL, + updated_at REAL + ); + """ + ) + conn.commit() + _set_version(conn, 5) + version = 5 + return version finally: if own: diff --git a/scidk/services/label_service.py b/scidk/services/label_service.py new file mode 100644 index 0000000..ca01eb7 --- /dev/null +++ b/scidk/services/label_service.py @@ -0,0 +1,350 @@ +""" +Label service for managing graph schema definitions. + +This service provides operations for: +- CRUD operations on label definitions (stored in SQLite) +- Push/pull schema synchronization with Neo4j +- Schema introspection and validation +""" +from __future__ import annotations +from typing import Dict, List, Any, Optional +import json +import time +import sqlite3 + + +class LabelService: + """Service for managing label definitions and Neo4j schema sync.""" + + def __init__(self, app): + self.app = app + from ..core import path_index_sqlite as pix + self.conn = pix.connect() + + def list_labels(self) -> List[Dict[str, Any]]: + """ + Get all label definitions from SQLite. + + Returns: + List of label definition dicts with keys: name, properties, relationships, created_at, updated_at + """ + cursor = self.conn.cursor() + cursor.execute( + """ + SELECT name, properties, relationships, created_at, updated_at + FROM label_definitions + ORDER BY name + """ + ) + rows = cursor.fetchall() + + labels = [] + for row in rows: + name, props_json, rels_json, created_at, updated_at = row + labels.append({ + 'name': name, + 'properties': json.loads(props_json) if props_json else [], + 'relationships': json.loads(rels_json) if rels_json else [], + 'created_at': created_at, + 'updated_at': updated_at + }) + return labels + + def get_label(self, name: str) -> Optional[Dict[str, Any]]: + """ + Get a specific label definition by name. + + Args: + name: Label name + + Returns: + Label definition dict or None if not found + """ + cursor = self.conn.cursor() + cursor.execute( + """ + SELECT name, properties, relationships, created_at, updated_at + FROM label_definitions + WHERE name = ? + """, + (name,) + ) + row = cursor.fetchone() + + if not row: + return None + + name, props_json, rels_json, created_at, updated_at = row + return { + 'name': name, + 'properties': json.loads(props_json) if props_json else [], + 'relationships': json.loads(rels_json) if rels_json else [], + 'created_at': created_at, + 'updated_at': updated_at + } + + def save_label(self, definition: Dict[str, Any]) -> Dict[str, Any]: + """ + Create or update a label definition. + + Args: + definition: Dict with keys: name, properties (list), relationships (list) + + Returns: + Updated label definition + """ + name = definition.get('name', '').strip() + if not name: + raise ValueError("Label name is required") + + properties = definition.get('properties', []) + relationships = definition.get('relationships', []) + + # Validate property structure + for prop in properties: + if not isinstance(prop, dict) or 'name' not in prop or 'type' not in prop: + raise ValueError(f"Invalid property structure: {prop}") + + # Validate relationship structure + for rel in relationships: + if not isinstance(rel, dict) or 'type' not in rel or 'target_label' not in rel: + raise ValueError(f"Invalid relationship structure: {rel}") + + props_json = json.dumps(properties) + rels_json = json.dumps(relationships) + now = time.time() + + # Check if label exists + existing = self.get_label(name) + + cursor = self.conn.cursor() + if existing: + # Update + cursor.execute( + """ + UPDATE label_definitions + SET properties = ?, relationships = ?, updated_at = ? + WHERE name = ? + """, + (props_json, rels_json, now, name) + ) + created_at = existing['created_at'] + else: + # Insert + cursor.execute( + """ + INSERT INTO label_definitions (name, properties, relationships, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + """, + (name, props_json, rels_json, now, now) + ) + created_at = now + + self.conn.commit() + + return { + 'name': name, + 'properties': properties, + 'relationships': relationships, + 'created_at': created_at, + 'updated_at': now + } + + def delete_label(self, name: str) -> bool: + """ + Delete a label definition. + + Args: + name: Label name + + Returns: + True if deleted, False if not found + """ + cursor = self.conn.cursor() + cursor.execute("DELETE FROM label_definitions WHERE name = ?", (name,)) + self.conn.commit() + return cursor.rowcount > 0 + + def push_to_neo4j(self, name: str) -> Dict[str, Any]: + """ + Push label definition to Neo4j (create constraints/indexes). + + Args: + name: Label name + + Returns: + Dict with status and details + """ + label_def = self.get_label(name) + if not label_def: + raise ValueError(f"Label '{name}' not found") + + try: + from .neo4j_client import get_neo4j_client + neo4j_client = get_neo4j_client() + + if not neo4j_client: + raise Exception("Neo4j client not configured") + + # Create constraints for required properties + constraints_created = [] + indexes_created = [] + + for prop in label_def.get('properties', []): + prop_name = prop.get('name') + required = prop.get('required', False) + + if required and prop_name: + # Create unique constraint + try: + constraint_name = f"constraint_{name}_{prop_name}" + query = f""" + CREATE CONSTRAINT {constraint_name} IF NOT EXISTS + FOR (n:{name}) + REQUIRE n.{prop_name} IS UNIQUE + """ + neo4j_client.execute_write(query) + constraints_created.append(prop_name) + except Exception as e: + # Constraint might already exist, continue + pass + + return { + 'status': 'success', + 'label': name, + 'constraints_created': constraints_created, + 'indexes_created': indexes_created + } + except Exception as e: + return { + 'status': 'error', + 'error': str(e) + } + + def pull_from_neo4j(self) -> Dict[str, Any]: + """ + Pull label schema from Neo4j and import as label definitions. + + Returns: + Dict with status and imported labels + """ + try: + from .neo4j_client import get_neo4j_client + neo4j_client = get_neo4j_client() + + if not neo4j_client: + raise Exception("Neo4j client not configured") + + # Query for node labels and their properties + query = """ + CALL db.schema.nodeTypeProperties() + YIELD nodeType, propertyName, propertyTypes + RETURN nodeType, propertyName, propertyTypes + """ + + results = neo4j_client.execute_read(query) + + # Group by label + labels_map = {} + for record in results: + node_type = record.get('nodeType') + if not node_type or not node_type.startswith(':'): + continue + + label_name = node_type[1:] # Remove leading ':' + prop_name = record.get('propertyName') + prop_types = record.get('propertyTypes', []) + + if label_name not in labels_map: + labels_map[label_name] = [] + + # Map Neo4j types to our property types + prop_type = 'string' + if prop_types: + first_type = prop_types[0].lower() + if 'int' in first_type or 'long' in first_type: + prop_type = 'number' + elif 'bool' in first_type: + prop_type = 'boolean' + elif 'date' in first_type: + prop_type = 'date' + elif 'datetime' in first_type or 'localdatetime' in first_type: + prop_type = 'datetime' + + labels_map[label_name].append({ + 'name': prop_name, + 'type': prop_type, + 'required': False # Can't determine from schema introspection + }) + + # Save imported labels + imported = [] + for label_name, properties in labels_map.items(): + try: + self.save_label({ + 'name': label_name, + 'properties': properties, + 'relationships': [] + }) + imported.append(label_name) + except Exception as e: + # Continue with other labels + pass + + return { + 'status': 'success', + 'imported_labels': imported, + 'count': len(imported) + } + except Exception as e: + return { + 'status': 'error', + 'error': str(e) + } + + def get_neo4j_schema(self) -> Dict[str, Any]: + """ + Get current Neo4j schema information. + + Returns: + Dict with schema details + """ + try: + from .neo4j_client import get_neo4j_client + neo4j_client = get_neo4j_client() + + if not neo4j_client: + return { + 'status': 'error', + 'error': 'Neo4j client not configured' + } + + # Get labels + labels_query = "CALL db.labels() YIELD label RETURN label" + labels_results = neo4j_client.execute_read(labels_query) + labels = [r.get('label') for r in labels_results] + + # Get relationship types + rels_query = "CALL db.relationshipTypes() YIELD relationshipType RETURN relationshipType" + rels_results = neo4j_client.execute_read(rels_query) + rel_types = [r.get('relationshipType') for r in rels_results] + + # Get constraints + constraints_query = "SHOW CONSTRAINTS YIELD name, type RETURN name, type" + try: + constraints_results = neo4j_client.execute_read(constraints_query) + constraints = [{'name': r.get('name'), 'type': r.get('type')} for r in constraints_results] + except: + constraints = [] + + return { + 'status': 'success', + 'labels': labels, + 'relationship_types': rel_types, + 'constraints': constraints + } + except Exception as e: + return { + 'status': 'error', + 'error': str(e) + } diff --git a/scidk/ui/templates/base.html b/scidk/ui/templates/base.html index dd569bd..f012c6f 100644 --- a/scidk/ui/templates/base.html +++ b/scidk/ui/templates/base.html @@ -35,6 +35,7 @@

Files Maps Chats + Labels Settings diff --git a/scidk/ui/templates/labels.html b/scidk/ui/templates/labels.html new file mode 100644 index 0000000..29d629e --- /dev/null +++ b/scidk/ui/templates/labels.html @@ -0,0 +1,390 @@ +{% extends 'base.html' %} +{% block title %}SciDK - Labels{% endblock %} +{% block content %} + + +

Labels

+

Define and manage graph schema labels with properties and relationships.

+ +
+ +
+
+

Labels

+ +
+
+
No labels defined
+
+
+ + + +
+ + +{% endblock %} diff --git a/scidk/web/routes/__init__.py b/scidk/web/routes/__init__.py index d9dfecf..094be0b 100644 --- a/scidk/web/routes/__init__.py +++ b/scidk/web/routes/__init__.py @@ -34,6 +34,7 @@ def register_blueprints(app): from . import api_interpreters from . import api_providers from . import api_annotations + from . import api_labels # Register UI blueprint app.register_blueprint(ui.bp) @@ -48,3 +49,4 @@ def register_blueprints(app): app.register_blueprint(api_interpreters.bp) app.register_blueprint(api_providers.bp) app.register_blueprint(api_annotations.bp) + app.register_blueprint(api_labels.bp) diff --git a/scidk/web/routes/api_labels.py b/scidk/web/routes/api_labels.py new file mode 100644 index 0000000..e207e41 --- /dev/null +++ b/scidk/web/routes/api_labels.py @@ -0,0 +1,257 @@ +""" +Blueprint for Labels API routes. + +Provides REST endpoints for: +- Label definitions CRUD +- Neo4j schema push/pull synchronization +- Schema introspection +""" +from flask import Blueprint, jsonify, request, current_app + +bp = Blueprint('labels', __name__, url_prefix='/api') + + +def _get_label_service(): + """Get or create LabelService instance.""" + from ...services.label_service import LabelService + if 'label_service' not in current_app.extensions.get('scidk', {}): + if 'scidk' not in current_app.extensions: + current_app.extensions['scidk'] = {} + current_app.extensions['scidk']['label_service'] = LabelService(current_app) + return current_app.extensions['scidk']['label_service'] + + +@bp.route('/labels', methods=['GET']) +def list_labels(): + """ + Get all label definitions. + + Returns: + { + "status": "success", + "labels": [ + { + "name": "Project", + "properties": [{"name": "name", "type": "string", "required": true}], + "relationships": [{"type": "HAS_FILE", "target_label": "File", "properties": []}], + "created_at": 1234567890.123, + "updated_at": 1234567890.123 + } + ] + } + """ + try: + service = _get_label_service() + labels = service.list_labels() + return jsonify({ + 'status': 'success', + 'labels': labels + }), 200 + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': str(e) + }), 500 + + +@bp.route('/labels/', methods=['GET']) +def get_label(name): + """ + Get a specific label definition by name. + + Returns: + { + "status": "success", + "label": {...} + } + """ + try: + service = _get_label_service() + label = service.get_label(name) + + if not label: + return jsonify({ + 'status': 'error', + 'error': f'Label "{name}" not found' + }), 404 + + return jsonify({ + 'status': 'success', + 'label': label + }), 200 + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': str(e) + }), 500 + + +@bp.route('/labels', methods=['POST']) +def create_or_update_label(): + """ + Create or update a label definition. + + Request body: + { + "name": "Project", + "properties": [ + {"name": "name", "type": "string", "required": true}, + {"name": "budget", "type": "number", "required": false} + ], + "relationships": [ + {"type": "HAS_FILE", "target_label": "File", "properties": []} + ] + } + + Returns: + { + "status": "success", + "label": {...} + } + """ + try: + data = request.get_json(force=True, silent=True) or {} + + if not data.get('name'): + return jsonify({ + 'status': 'error', + 'error': 'Label name is required' + }), 400 + + service = _get_label_service() + label = service.save_label(data) + + return jsonify({ + 'status': 'success', + 'label': label + }), 200 + except ValueError as e: + return jsonify({ + 'status': 'error', + 'error': str(e) + }), 400 + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': str(e) + }), 500 + + +@bp.route('/labels/', methods=['DELETE']) +def delete_label(name): + """ + Delete a label definition. + + Returns: + { + "status": "success", + "message": "Label deleted" + } + """ + try: + service = _get_label_service() + deleted = service.delete_label(name) + + if not deleted: + return jsonify({ + 'status': 'error', + 'error': f'Label "{name}" not found' + }), 404 + + return jsonify({ + 'status': 'success', + 'message': f'Label "{name}" deleted' + }), 200 + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': str(e) + }), 500 + + +@bp.route('/labels//push', methods=['POST']) +def push_label_to_neo4j(name): + """ + Push label definition to Neo4j (create constraints/indexes). + + Returns: + { + "status": "success", + "label": "Project", + "constraints_created": ["name"], + "indexes_created": [] + } + """ + try: + service = _get_label_service() + result = service.push_to_neo4j(name) + + if result.get('status') == 'error': + return jsonify(result), 500 + + return jsonify(result), 200 + except ValueError as e: + return jsonify({ + 'status': 'error', + 'error': str(e) + }), 404 + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': str(e) + }), 500 + + +@bp.route('/labels/pull', methods=['POST']) +def pull_labels_from_neo4j(): + """ + Pull label schema from Neo4j and import as label definitions. + + Returns: + { + "status": "success", + "imported_labels": ["Project", "File"], + "count": 2 + } + """ + try: + service = _get_label_service() + result = service.pull_from_neo4j() + + if result.get('status') == 'error': + return jsonify(result), 500 + + return jsonify(result), 200 + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': str(e) + }), 500 + + +@bp.route('/labels/neo4j/schema', methods=['GET']) +def get_neo4j_schema(): + """ + Get current Neo4j schema information. + + Returns: + { + "status": "success", + "labels": ["Project", "File"], + "relationship_types": ["HAS_FILE"], + "constraints": [{"name": "constraint_Project_name", "type": "UNIQUENESS"}] + } + """ + try: + service = _get_label_service() + result = service.get_neo4j_schema() + + if result.get('status') == 'error': + return jsonify(result), 500 + + return jsonify(result), 200 + except Exception as e: + return jsonify({ + 'status': 'error', + 'error': str(e) + }), 500 diff --git a/scidk/web/routes/ui.py b/scidk/web/routes/ui.py index b1b492b..90ba156 100644 --- a/scidk/web/routes/ui.py +++ b/scidk/web/routes/ui.py @@ -190,6 +190,12 @@ def rocrate_view(): return render_template('rocrate_view.html', metadata_url=metadata_url, embed_mode=embed_mode, prov_id=prov_id, root_id=root_id, path=sel_path) +@bp.get('/labels') +def labels(): + """Label definitions page for graph schema management.""" + return render_template('labels.html') + + @bp.get('/settings') def settings(): """Basic settings from environment and current in-memory sizes.""" diff --git a/tests/test_labels_api.py b/tests/test_labels_api.py new file mode 100644 index 0000000..5c8e770 --- /dev/null +++ b/tests/test_labels_api.py @@ -0,0 +1,249 @@ +""" +Tests for Labels API endpoints. + +Tests cover: +- GET /api/labels - list all labels +- GET /api/labels/ - get label definition +- POST /api/labels - create/update label +- DELETE /api/labels/ - delete label +- POST /api/labels//push - push label to Neo4j +- POST /api/labels/pull - pull labels from Neo4j +- GET /api/labels/neo4j/schema - get Neo4j schema +""" +import json + + +def test_list_labels_empty(client): + """Test listing labels when none exist.""" + response = client.get('/api/labels') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'success' + assert 'labels' in data + assert isinstance(data['labels'], list) + + +def test_create_label_success(client): + """Test creating a label with properties and relationships.""" + payload = { + 'name': 'Project', + 'properties': [ + {'name': 'name', 'type': 'string', 'required': True}, + {'name': 'budget', 'type': 'number', 'required': False} + ], + 'relationships': [ + {'type': 'HAS_FILE', 'target_label': 'File', 'properties': []} + ] + } + + response = client.post('/api/labels', json=payload) + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'success' + assert 'label' in data + assert data['label']['name'] == 'Project' + assert len(data['label']['properties']) == 2 + assert len(data['label']['relationships']) == 1 + + +def test_create_label_missing_name(client): + """Test creating label without name fails.""" + payload = {'properties': []} + + response = client.post('/api/labels', json=payload) + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + assert 'name' in data['error'].lower() + + +def test_create_label_invalid_property(client): + """Test creating label with invalid property structure.""" + payload = { + 'name': 'BadLabel', + 'properties': [ + {'name': 'valid', 'type': 'string', 'required': True}, + {'invalid': 'structure'} # Missing 'name' and 'type' + ] + } + + response = client.post('/api/labels', json=payload) + assert response.status_code == 400 + data = response.get_json() + assert data['status'] == 'error' + + +def test_get_label_success(client): + """Test retrieving an existing label.""" + # First create a label + payload = { + 'name': 'TestLabel', + 'properties': [{'name': 'prop1', 'type': 'string', 'required': False}], + 'relationships': [] + } + client.post('/api/labels', json=payload) + + # Now get it + response = client.get('/api/labels/TestLabel') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'success' + assert data['label']['name'] == 'TestLabel' + assert len(data['label']['properties']) == 1 + + +def test_get_label_not_found(client): + """Test retrieving non-existent label.""" + response = client.get('/api/labels/NonExistent') + assert response.status_code == 404 + data = response.get_json() + assert data['status'] == 'error' + + +def test_update_label(client): + """Test updating an existing label.""" + # Create initial label + payload = { + 'name': 'UpdateTest', + 'properties': [{'name': 'old_prop', 'type': 'string', 'required': False}], + 'relationships': [] + } + client.post('/api/labels', json=payload) + + # Update it + updated_payload = { + 'name': 'UpdateTest', + 'properties': [ + {'name': 'old_prop', 'type': 'string', 'required': False}, + {'name': 'new_prop', 'type': 'number', 'required': True} + ], + 'relationships': [] + } + response = client.post('/api/labels', json=updated_payload) + assert response.status_code == 200 + data = response.get_json() + assert len(data['label']['properties']) == 2 + + +def test_delete_label_success(client): + """Test deleting an existing label.""" + # Create label + payload = { + 'name': 'DeleteTest', + 'properties': [], + 'relationships': [] + } + client.post('/api/labels', json=payload) + + # Delete it + response = client.delete('/api/labels/DeleteTest') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'success' + + # Verify it's gone + get_response = client.get('/api/labels/DeleteTest') + assert get_response.status_code == 404 + + +def test_delete_label_not_found(client): + """Test deleting non-existent label.""" + response = client.delete('/api/labels/NonExistent') + assert response.status_code == 404 + data = response.get_json() + assert data['status'] == 'error' + + +def test_list_multiple_labels(client): + """Test listing multiple labels.""" + # Create multiple labels + labels = ['Label1', 'Label2', 'Label3'] + for name in labels: + payload = { + 'name': name, + 'properties': [{'name': 'test', 'type': 'string', 'required': False}], + 'relationships': [] + } + client.post('/api/labels', json=payload) + + # List all labels + response = client.get('/api/labels') + assert response.status_code == 200 + data = response.get_json() + assert data['status'] == 'success' + assert len(data['labels']) >= 3 + label_names = [l['name'] for l in data['labels']] + for name in labels: + assert name in label_names + + +def test_label_with_all_property_types(client): + """Test creating label with all supported property types.""" + payload = { + 'name': 'AllTypes', + 'properties': [ + {'name': 'str_prop', 'type': 'string', 'required': False}, + {'name': 'num_prop', 'type': 'number', 'required': False}, + {'name': 'bool_prop', 'type': 'boolean', 'required': False}, + {'name': 'date_prop', 'type': 'date', 'required': False}, + {'name': 'datetime_prop', 'type': 'datetime', 'required': False} + ], + 'relationships': [] + } + + response = client.post('/api/labels', json=payload) + assert response.status_code == 200 + data = response.get_json() + assert len(data['label']['properties']) == 5 + + # Verify types are preserved + types = {p['name']: p['type'] for p in data['label']['properties']} + assert types['str_prop'] == 'string' + assert types['num_prop'] == 'number' + assert types['bool_prop'] == 'boolean' + assert types['date_prop'] == 'date' + assert types['datetime_prop'] == 'datetime' + + +def test_label_with_multiple_relationships(client): + """Test creating label with multiple relationships.""" + # Create target labels first + for name in ['File', 'Directory', 'User']: + client.post('/api/labels', json={ + 'name': name, + 'properties': [], + 'relationships': [] + }) + + payload = { + 'name': 'Project', + 'properties': [], + 'relationships': [ + {'type': 'HAS_FILE', 'target_label': 'File', 'properties': []}, + {'type': 'HAS_DIRECTORY', 'target_label': 'Directory', 'properties': []}, + {'type': 'OWNED_BY', 'target_label': 'User', 'properties': []} + ] + } + + response = client.post('/api/labels', json=payload) + assert response.status_code == 200 + data = response.get_json() + assert len(data['label']['relationships']) == 3 + + +def test_push_to_neo4j_label_not_found(client): + """Test pushing non-existent label to Neo4j.""" + response = client.post('/api/labels/NonExistent/push') + # Should return 404 since label doesn't exist + assert response.status_code == 404 + data = response.get_json() + assert data['status'] == 'error' + + +def test_get_neo4j_schema(client): + """Test getting Neo4j schema (will fail if Neo4j not configured).""" + response = client.get('/api/labels/neo4j/schema') + # Either success (if Neo4j configured) or error (if not) + assert response.status_code in [200, 500] + data = response.get_json() + assert 'status' in data