diff --git a/CHANGELOG.md b/CHANGELOG.md index 25bb334..712518a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Password input field in connector creation form (both standalone page and modal) - `migrate_connector_secrets` management command to migrate existing env-var-based credentials into encrypted secrets - `cryptography>=42.0` dependency +- **Elasticsearch connector** — connect to Elasticsearch clusters and ingest documents + - API key, basic auth, bearer token, and Elastic Cloud (`cloud_id`) authentication + - PIT + `search_after` pagination with automatic fallback to `helpers.scan()` for older ES versions + - Configurable field mapping (`content_field`, `title_field`, `author_field`, `date_field`) + - Custom Elasticsearch Query DSL filter support + - TLS certificate verification control + - HTML content auto-detection + - Fallback content extraction when configured content field is missing + - 35 unit tests covering all connector functionality - Apache 2.0 LICENSE file - `CONTRIBUTING.md`, `CHANGELOG.md`, `SECURITY.md` - `SECRET_KEY` startup validation (refuses to start with placeholder key in production) @@ -55,6 +64,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SCORE 0-100 scoring with A-E letter grades - PDF report generation with radar charts - RAG chat interface -- SharePoint, Confluence, and Elasticsearch connectors +- SharePoint and Confluence connectors - Docker deployment support - French and English internationalization diff --git a/connectors/generic.py b/connectors/generic.py index 534fd9f..4dfaba7 100644 --- a/connectors/generic.py +++ b/connectors/generic.py @@ -147,6 +147,9 @@ def _fetch_http(self, source_id: str) -> RawDocument: resp = httpx.get(source_id, timeout=60, follow_redirects=True) resp.raise_for_status() + if not resp.content: + raise ValueError(f"Empty response from {source_id} (HTTP {resp.status_code})") + content_type = resp.headers.get("content-type", "").split(";")[0].strip() etag = resp.headers.get("etag", "") version = etag or hashlib.sha256(resp.content).hexdigest()[:16] diff --git a/connectors/templates/connectors/_connector_cards.html b/connectors/templates/connectors/_connector_cards.html index 244ae54..68d24e9 100644 --- a/connectors/templates/connectors/_connector_cards.html +++ b/connectors/templates/connectors/_connector_cards.html @@ -78,6 +78,8 @@

{% trans "Connecteurs" %}

{% elif conn.connector_type == 'elasticsearch' %} + {% elif conn.connector_type == 'zipupload' %} + {% else %} {% endif %} @@ -166,6 +168,8 @@

{% trans "Connecteurs" %}

{% elif val == 'elasticsearch' %} + {% elif val == 'zipupload' %} + {% else %} {% endif %} @@ -173,7 +177,7 @@

{% trans "Connecteurs" %}

{{ label }}
- {% if val == 'sharepoint' %}{% trans "Bibliothèques SharePoint Online" %}{% elif val == 'confluence' %}{% trans "Espaces Confluence Cloud" %}{% elif val == 'elasticsearch' %}{% trans "Index Elasticsearch" %}{% else %}{% trans "Fichiers locaux ou URL" %}{% endif %} + {% if val == 'sharepoint' %}{% trans "Bibliothèques SharePoint Online" %}{% elif val == 'confluence' %}{% trans "Espaces Confluence Cloud" %}{% elif val == 'elasticsearch' %}{% trans "Index Elasticsearch" %}{% elif val == 'zipupload' %}{% trans "Archive ZIP de documents" %}{% else %}{% trans "Fichiers locaux ou URL" %}{% endif %}
@@ -212,7 +216,7 @@

{% trans "Connecteurs" %}

{% trans "Connecter SharePoint à SCORE" %}
{% trans "Configurez les paramètres de connexion pour indexer automatiquement vos documents dans SCORE." %}
-
+ {% csrf_token %} @@ -319,6 +323,15 @@

{% trans "Connecteurs" %}

+ + +
@@ -393,13 +406,15 @@

{% trans "Connecteurs" %}

sharepoint: '', confluence: '', elasticsearch: '', - generic: '' + generic: '', + zipupload: '' }; var titles = { sharepoint: 'Connecter SharePoint \u00e0 SCORE', confluence: 'Connecter Confluence \u00e0 SCORE', elasticsearch: 'Connecter Elasticsearch \u00e0 SCORE', - generic: 'Connecter une source \u00e0 SCORE' + generic: 'Connecter une source \u00e0 SCORE', + zipupload: 'Importer un fichier ZIP dans SCORE' }; var select = document.getElementById('cf-connector-type'); @@ -410,9 +425,16 @@

{% trans "Connecteurs" %}

iconBox.className = 'cf-icon-box ' + (type || 'generic'); iconBox.innerHTML = icons[type] || icons.generic; titleEl.textContent = titles[type] || titles.generic; - modalEl.querySelectorAll('.cf-connector-config').forEach(function(el) { el.style.display = 'none'; }); + /* Hide all config panels and disable their required fields */ + modalEl.querySelectorAll('.cf-connector-config').forEach(function(el) { + el.style.display = 'none'; + el.querySelectorAll('[required]').forEach(function(inp) { inp.disabled = true; }); + }); var target = document.getElementById('cf-config-' + type); - if (target) target.style.display = 'block'; + if (target) { + target.style.display = 'block'; + target.querySelectorAll('[disabled]').forEach(function(inp) { inp.disabled = false; }); + } } select.addEventListener('change', function() { updateModal(this.value); }); diff --git a/connectors/templates/connectors/create.html b/connectors/templates/connectors/create.html index 5e96ab3..ddb1ce4 100644 --- a/connectors/templates/connectors/create.html +++ b/connectors/templates/connectors/create.html @@ -53,6 +53,7 @@ .cf-icon-box.confluence { background: rgba(13,202,240,0.12); color: #22d3ee; } .cf-icon-box.elasticsearch { background: rgba(254,210,48,0.12); color: #fed230; } .cf-icon-box.generic { background: rgba(108,113,126,0.12); color: #9ca3af; } + .cf-icon-box.zipupload { background: rgba(168,85,247,0.12); color: #a855f7; } .cf-icon-box.score-icon { background: rgba(13,110,253,0.12); color: #0d6efd; } .cf-arrow { color: var(--ds-text-muted); } .cf-arrow svg { width: 20px; height: 20px; } @@ -96,6 +97,19 @@ border-color: #0d6efd; } .cf-input::placeholder { color: var(--ds-text-muted); } + .cf-input:invalid:not(:placeholder-shown) { + border-color: #dc3545; + } + .cf-error { + font-size: 11px; + color: #dc3545; + margin-top: 4px; + display: none; + } + .cf-input:invalid:not(:placeholder-shown) + .cf-error, + .was-validated .cf-input:invalid + .cf-error { + display: block; + } .cf-hint { font-size: 11px; color: var(--ds-text-muted); @@ -246,13 +260,15 @@
{% trans "Connecter SharePoint à SCORE" %}
{% trans "Configurez les paramètres de connexion pour indexer automatiquement vos documents dans SCORE." %}
- + {% if error %}
{{ error }}
{% endif %} + {% csrf_token %}
+
{% trans "Veuillez saisir un nom pour le connecteur." %}
@@ -354,6 +370,15 @@
+ + +
@@ -409,19 +434,22 @@ sharepoint: '', confluence: '', elasticsearch: '', - generic: '' + generic: '', + zipupload: '' }; var classes = { sharepoint: 'sharepoint', confluence: 'confluence', elasticsearch: 'elasticsearch', - generic: 'generic' + generic: 'generic', + zipupload: 'zipupload' }; var titles = { sharepoint: 'Connecter SharePoint \u00e0 SCORE', confluence: 'Connecter Confluence \u00e0 SCORE', elasticsearch: 'Connecter Elasticsearch \u00e0 SCORE', - generic: 'Connecter une source \u00e0 SCORE' + generic: 'Connecter une source \u00e0 SCORE', + zipupload: 'Importer un fichier ZIP dans SCORE' }; var select = document.getElementById('connector_type'); @@ -434,14 +462,34 @@ iconBox.innerHTML = icons[type] || icons.generic; /* Title */ titleEl.textContent = titles[type] || titles.generic; - /* Config panels */ - document.querySelectorAll('.connector-config').forEach(function(el) { el.style.display = 'none'; }); + /* Config panels – hide all, disable their required fields */ + document.querySelectorAll('.connector-config').forEach(function(el) { + el.style.display = 'none'; + el.querySelectorAll('[required]').forEach(function(inp) { inp.disabled = true; }); + }); var target = document.getElementById('config-' + type); - if (target) target.style.display = 'block'; + if (target) { + target.style.display = 'block'; + target.querySelectorAll('[disabled]').forEach(function(inp) { inp.disabled = false; }); + } } select.addEventListener('change', function() { update(this.value); }); + /* Form validation feedback */ + var form = document.getElementById('create-form'); + form.addEventListener('submit', function(e) { + if (!form.checkValidity()) { + e.preventDefault(); + form.classList.add('was-validated'); + var firstInvalid = form.querySelector(':invalid:not(fieldset)'); + if (firstInvalid) { + firstInvalid.focus(); + firstInvalid.scrollIntoView({behavior: 'smooth', block: 'center'}); + } + } + }); + /* Init from query param or default */ var params = new URLSearchParams(window.location.search); var initial = params.get('type'); diff --git a/ingestion/pipeline.py b/ingestion/pipeline.py index 1b562ff..19ea774 100644 --- a/ingestion/pipeline.py +++ b/ingestion/pipeline.py @@ -85,8 +85,28 @@ def run(self): try: self._process_document(doc_info) except Exception as e: - logger.error("Error processing document %s: %s", doc_info.get("source_id"), e) + source_id = doc_info.get("source_id", "") + logger.error("Error processing document %s: %s", source_id, e) self._stats["errors"] += 1 + # Record the document with ERROR status + if ( + source_id + and not Document.objects.filter( + project=self.project, + connector=self.connector_config, + source_id=source_id, + ).exists() + ): + Document.objects.create( + tenant=self.tenant, + project=self.project, + connector=self.connector_config, + source_id=source_id, + title=doc_info.get("title", source_id), + source_url=doc_info.get("source_url", ""), + status=Document.Status.ERROR, + error_message=str(e)[:1000], + ) # Update progress self.job.processed_documents = i + 1 + len(deleted_ids) @@ -136,6 +156,25 @@ def _process_document(self, doc_info: dict): extracted = extract_text(raw_doc.content, raw_doc.content_type) if not extracted.text: logger.warning("Empty text extraction for source_id=%s", source_id) + # Record the document with ERROR status so the user can see what happened + existing = Document.objects.filter( + project=self.project, + connector=self.connector_config, + source_id=source_id, + ).first() + if not existing: + Document.objects.create( + tenant=self.tenant, + project=self.project, + connector=self.connector_config, + source_id=source_id, + title=raw_doc.title or doc_info.get("title", ""), + source_url=raw_doc.source_url, + doc_type=raw_doc.doc_type or doc_info.get("content_type", ""), + status=Document.Status.ERROR, + error_message="Text extraction returned empty content", + ) + self._stats["errors"] += 1 return # Hash for change detection