Deimplement OSINT+ search
This commit is contained in:
@@ -582,11 +582,6 @@ urlpatterns = [
|
|||||||
ais.AIList.as_view(),
|
ais.AIList.as_view(),
|
||||||
name="ais",
|
name="ais",
|
||||||
),
|
),
|
||||||
path(
|
|
||||||
"search/<str:type>/",
|
|
||||||
osint.OSINTSearch.as_view(),
|
|
||||||
name="osint_search",
|
|
||||||
),
|
|
||||||
path(
|
path(
|
||||||
"osint/workspace/",
|
"osint/workspace/",
|
||||||
osint.OSINTWorkspace.as_view(),
|
osint.OSINTWorkspace.as_view(),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
.compose-shell {
|
.compose-shell {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.75rem;
|
gap: 0.5rem;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,13 +281,26 @@
|
|||||||
.compose-shell .compose-form {
|
.compose-shell .compose-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-shell .compose-form-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-wrap: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-send-safety {
|
.compose-shell .compose-send-safety {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: 0.125rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 8.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-shell .compose-form-main .gia-send-composer {
|
||||||
|
flex: 1 1 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.compose-shell .compose-reply-banner {
|
.compose-shell .compose-reply-banner {
|
||||||
@@ -346,6 +359,14 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.compose-shell .compose-form-main {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compose-shell .compose-send-safety {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.compose-shell .compose-thread {
|
.compose-shell .compose-thread {
|
||||||
min-height: 18rem;
|
min-height: 18rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -645,10 +645,6 @@ html.gia-has-workspace-root {
|
|||||||
color: #3273dc;
|
color: #3273dc;
|
||||||
}
|
}
|
||||||
|
|
||||||
.osint-search-form .button.is-fullwidth {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-dropdown .navbar-item.is-current-route {
|
.navbar-dropdown .navbar-item.is-current-route {
|
||||||
background-color: var(--bulma-link-light) !important;
|
background-color: var(--bulma-link-light) !important;
|
||||||
color: var(--bulma-link) !important;
|
color: var(--bulma-link) !important;
|
||||||
|
|||||||
@@ -507,6 +507,37 @@
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (widgetNodes.length === 3) {
|
||||||
|
const dominantNode = widgetNodes.find(function (node) {
|
||||||
|
return node.id === workspaceState.activeWidgetId;
|
||||||
|
}) || widgetNodes[widgetNodes.length - 1];
|
||||||
|
const secondaryNodes = widgetNodes.filter(function (node) {
|
||||||
|
return node !== dominantNode;
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
node: dominantNode,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: GRID_COLUMNS / 2,
|
||||||
|
h: GRID_ROWS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: secondaryNodes[0],
|
||||||
|
x: GRID_COLUMNS / 2,
|
||||||
|
y: 0,
|
||||||
|
w: GRID_COLUMNS / 2,
|
||||||
|
h: GRID_ROWS / 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
node: secondaryNodes[1],
|
||||||
|
x: GRID_COLUMNS / 2,
|
||||||
|
y: GRID_ROWS / 2,
|
||||||
|
w: GRID_COLUMNS / 2,
|
||||||
|
h: GRID_ROWS / 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
return widgetNodes.slice(0, 4).map(function (node, index) {
|
return widgetNodes.slice(0, 4).map(function (node, index) {
|
||||||
return {
|
return {
|
||||||
node: node,
|
node: node,
|
||||||
|
|||||||
@@ -312,9 +312,6 @@
|
|||||||
<a class="navbar-item" href="{% url 'ai_workspace' %}">
|
<a class="navbar-item" href="{% url 'ai_workspace' %}">
|
||||||
AI
|
AI
|
||||||
</a>
|
</a>
|
||||||
<a class="navbar-item" href="{% url 'osint_search' type='page' %}">
|
|
||||||
Search
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
{% if widget_script_srcs %}data-gia-script-srcs="{{ widget_script_srcs|join:'|' }}"{% endif %}>
|
{% if widget_script_srcs %}data-gia-script-srcs="{{ widget_script_srcs|join:'|' }}"{% endif %}>
|
||||||
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
|
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
|
||||||
<div class="grid-stack-item-content">
|
<div class="grid-stack-item-content">
|
||||||
<nav class="panel gia-widget-panel">
|
<section class="gia-widget-panel">
|
||||||
<p class="panel-heading gia-widget-heading">
|
<header class="gia-widget-heading">
|
||||||
<span class="gia-widget-heading-main">
|
<span class="gia-widget-heading-main">
|
||||||
<span class="icon is-small gia-widget-heading-icon">
|
<span class="icon is-small gia-widget-heading-icon">
|
||||||
<i class="{{ widget_icon|default:'fa-solid fa-window-maximize' }}"></i>
|
<i class="{{ widget_icon|default:'fa-solid fa-window-maximize' }}"></i>
|
||||||
@@ -54,15 +54,15 @@
|
|||||||
{% include "mixins/partials/close-widget.html" %}
|
{% include "mixins/partials/close-widget.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</header>
|
||||||
<article class="panel-block is-active gia-widget-body">
|
<div class="gia-widget-body">
|
||||||
<div class="control gia-widget-control{% if widget_control_class %} {{ widget_control_class }}{% endif %}">
|
<div class="control gia-widget-control{% if widget_control_class %} {{ widget_control_class }}{% endif %}">
|
||||||
{% block panel_content %}
|
{% block panel_content %}
|
||||||
{% include window_content %}
|
{% include window_content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
</nav>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<section class="section">
|
|
||||||
<div class="container is-max-desktop">
|
|
||||||
<div class="mb-4">
|
|
||||||
<h1 class="title is-4 mb-2">Search</h1>
|
|
||||||
<p class="subtitle is-6 mb-3">
|
|
||||||
Unified lookup across contacts, identifiers, and messages, with advanced filters for source, date range, sentiment, sort, dedup, and reverse.
|
|
||||||
</p>
|
|
||||||
<div class="tags">
|
|
||||||
<span class="tag is-light">Default Scope: All</span>
|
|
||||||
<span class="tag is-light">Contacts + Messages</span>
|
|
||||||
<span class="tag is-light">SIQTSRSS/ADR Controls</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% include "partials/osint/search-panel.html" %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<div class="box is-shadowless gia-send-composer{% if composer_class %} {{ composer_class }}{% endif %}">
|
<div class="box is-shadowless gia-send-composer p-2 m-0{% if composer_class %} {{ composer_class }}{% endif %}">
|
||||||
<div class="field has-addons gia-send-composer-row">
|
<div class="field has-addons gia-send-composer-row">
|
||||||
<div class="control is-expanded gia-send-composer-input-wrap">
|
<div class="control is-expanded gia-send-composer-input-wrap">
|
||||||
<textarea
|
<textarea
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div
|
<div
|
||||||
id="{{ panel_id }}"
|
id="{{ panel_id }}"
|
||||||
class="compose-shell box"
|
class="compose-shell box p-3 m-0"
|
||||||
data-compose-panel="1"
|
data-compose-panel="1"
|
||||||
data-render-mode="{{ render_mode }}"
|
data-render-mode="{{ render_mode }}"
|
||||||
data-csrf-token="{{ csrf_token }}"
|
data-csrf-token="{{ csrf_token }}"
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
data-initial-typing="{{ typing_state_json|default:'{}'|escape }}"
|
data-initial-typing="{{ typing_state_json|default:'{}'|escape }}"
|
||||||
data-cancel-send-url="{% url 'compose_cancel_send' %}"
|
data-cancel-send-url="{% url 'compose_cancel_send' %}"
|
||||||
data-command-result-url="{% url 'compose_command_result' %}">
|
data-command-result-url="{% url 'compose_command_result' %}">
|
||||||
<div class="compose-shell-head is-flex is-justify-content-space-between is-align-items-flex-start is-flex-wrap-wrap is-gap-2 mb-2">
|
<div class="compose-shell-head is-flex is-justify-content-space-between is-align-items-flex-start is-flex-wrap-wrap is-gap-2 mb-1">
|
||||||
<div>
|
<div>
|
||||||
<p class="compose-shell-eyebrow is-size-7 has-text-weight-semibold mb-1">Manual Text Mode</p>
|
<p class="compose-shell-eyebrow is-size-7 has-text-weight-semibold mb-1">Manual Text Mode</p>
|
||||||
<div class="compose-context-row">
|
<div class="compose-context-row">
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
id="{{ panel_id }}-thread"
|
id="{{ panel_id }}-thread"
|
||||||
class="compose-thread box is-shadowless"
|
class="compose-thread box is-shadowless p-3 m-0"
|
||||||
data-poll-url="{% url 'compose_thread' %}"
|
data-poll-url="{% url 'compose_thread' %}"
|
||||||
data-service="{{ service }}"
|
data-service="{{ service }}"
|
||||||
data-identifier="{{ identifier }}"
|
data-identifier="{{ identifier }}"
|
||||||
@@ -112,26 +112,28 @@
|
|||||||
<input type="hidden" name="reply_to_message_id" value="">
|
<input type="hidden" name="reply_to_message_id" value="">
|
||||||
<input type="hidden" name="failsafe_arm" value="0">
|
<input type="hidden" name="failsafe_arm" value="0">
|
||||||
<input type="hidden" name="failsafe_confirm" value="0">
|
<input type="hidden" name="failsafe_confirm" value="0">
|
||||||
<div class="compose-send-safety">
|
<div class="compose-form-main">
|
||||||
<label class="checkbox is-size-7" for="{{ panel_id }}-manual-confirm">
|
<div class="compose-send-safety">
|
||||||
<input
|
<label class="checkbox is-size-7" for="{{ panel_id }}-manual-confirm">
|
||||||
id="{{ panel_id }}-manual-confirm"
|
<input
|
||||||
type="checkbox"
|
id="{{ panel_id }}-manual-confirm"
|
||||||
class="manual-confirm"
|
type="checkbox"
|
||||||
name="manual_confirm"
|
class="manual-confirm"
|
||||||
value="1"
|
name="manual_confirm"
|
||||||
{% if not capability_send %}disabled{% endif %}>
|
value="1"
|
||||||
Confirm Send
|
{% if not capability_send %}disabled{% endif %}>
|
||||||
</label>
|
Confirm Send
|
||||||
{% if not capability_send %}
|
</label>
|
||||||
<p class="help is-size-7 has-text-grey">Send disabled: {{ capability_send_reason }}</p>
|
{% if not capability_send %}
|
||||||
{% endif %}
|
<p class="help is-size-7 has-text-grey">Send disabled: {{ capability_send_reason }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% include "partials/bulma-send-composer.html" with composer_class="compose-composer-capsule" textarea_id=panel_id|add:"-textarea" textarea_class="compose-textarea" textarea_name="text" textarea_rows="1" textarea_placeholder="Type a message. Enter to send, Shift+Enter for newline." button_class="is-link is-light compose-send-btn" button_type="submit" button_disabled=True button_title=capability_send_reason|default_if_none:"" button_label="Send" button_icon_class=manual_icon_class %}
|
||||||
</div>
|
</div>
|
||||||
<div id="{{ panel_id }}-reply-banner" class="compose-reply-banner is-hidden">
|
<div id="{{ panel_id }}-reply-banner" class="compose-reply-banner is-hidden">
|
||||||
<span class="compose-reply-banner-label">Replying to:</span>
|
<span class="compose-reply-banner-label">Replying to:</span>
|
||||||
<span id="{{ panel_id }}-reply-text" class="compose-reply-banner-text"></span>
|
<span id="{{ panel_id }}-reply-text" class="compose-reply-banner-text"></span>
|
||||||
<button type="button" id="{{ panel_id }}-reply-clear" class="button is-white is-small compose-reply-clear-btn">Clear</button>
|
<button type="button" id="{{ panel_id }}-reply-clear" class="button is-white is-small compose-reply-clear-btn">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
{% include "partials/bulma-send-composer.html" with composer_class="compose-composer-capsule" textarea_id=panel_id|add:"-textarea" textarea_class="compose-textarea" textarea_name="text" textarea_rows="1" textarea_placeholder="Type a message. Enter to send, Shift+Enter for newline." button_class="is-link is-light compose-send-btn" button_type="submit" button_disabled=True button_title=capability_send_reason|default_if_none:"" button_label="Send" button_icon_class=manual_icon_class %}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
<form
|
|
||||||
class="osint-search-form box"
|
|
||||||
method="get"
|
|
||||||
action="{{ osint_search_url }}"
|
|
||||||
hx-get="{{ osint_search_url }}"
|
|
||||||
hx-trigger="change delay:120ms"
|
|
||||||
hx-target="#osint-search-results"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<div class="columns is-multiline">
|
|
||||||
<div class="column is-4">
|
|
||||||
<label class="label">Scope</label>
|
|
||||||
<div class="select">
|
|
||||||
<select name="scope">
|
|
||||||
{% for option in scope_options %}
|
|
||||||
<option
|
|
||||||
value="{{ option.value }}"
|
|
||||||
{% if option.value == selected_scope %}selected{% endif %}>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-4">
|
|
||||||
<label class="label">Field</label>
|
|
||||||
<div class="select">
|
|
||||||
<select name="field">
|
|
||||||
<option value="__all__" {% if selected_field == "__all__" %}selected{% endif %}>
|
|
||||||
All Fields
|
|
||||||
</option>
|
|
||||||
{% for option in field_options %}
|
|
||||||
<option
|
|
||||||
value="{{ option.value }}"
|
|
||||||
{% if option.value == selected_field %}selected{% endif %}>
|
|
||||||
{{ option.label }}
|
|
||||||
</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-4">
|
|
||||||
<label class="label">Rows Per Page</label>
|
|
||||||
<div class="select">
|
|
||||||
<select name="per_page">
|
|
||||||
<option value="10" {% if selected_per_page == 10 %}selected{% endif %}>10</option>
|
|
||||||
<option value="20" {% if selected_per_page == 20 %}selected{% endif %}>20</option>
|
|
||||||
<option value="50" {% if selected_per_page == 50 %}selected{% endif %}>50</option>
|
|
||||||
<option value="100" {% if selected_per_page == 100 %}selected{% endif %}>100</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="column is-9">
|
|
||||||
<label class="label">Search Query</label>
|
|
||||||
<input
|
|
||||||
class="input"
|
|
||||||
type="text"
|
|
||||||
name="q"
|
|
||||||
hx-get="{{ osint_search_url }}"
|
|
||||||
hx-trigger="keyup changed delay:220ms"
|
|
||||||
hx-include="closest form"
|
|
||||||
hx-target="#osint-search-results"
|
|
||||||
hx-swap="innerHTML"
|
|
||||||
value="{{ search_query }}"
|
|
||||||
placeholder="Search contacts, identifiers, and messages...">
|
|
||||||
</div>
|
|
||||||
<div class="column is-3">
|
|
||||||
<label class="label"> </label>
|
|
||||||
<div class="buttons">
|
|
||||||
<button class="button is-link is-light is-fullwidth" type="submit">
|
|
||||||
Search
|
|
||||||
</button>
|
|
||||||
<a class="button is-light is-fullwidth" href="{{ osint_search_url }}" style="margin-bottom: 0.55rem;">
|
|
||||||
Reset
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<details style="margin-top: 0.45rem;">
|
|
||||||
<summary class="has-text-weight-semibold">Advanced Filters (SIQTSRSS/ADR)</summary>
|
|
||||||
<p class="help" style="margin: 0.1rem 0 0.2rem 0;">Tip: use inline tags like <code>tag:messages</code> or <code>tag:people</code> in the query to narrow All-scope results quickly.</p>
|
|
||||||
<p class="help" style="margin: 0 0 0.35rem 0;"><strong>Source Service</strong>, <strong>From Date</strong>, <strong>To Date</strong>, and <strong>Sort Mode</strong> shape which results you see and in what order.</p>
|
|
||||||
<div style="margin-top: 0.2rem;">
|
|
||||||
<div class="columns is-multiline" style="margin-top: 0.3rem;">
|
|
||||||
<div class="column is-12-mobile is-6-tablet is-4-desktop">
|
|
||||||
<label class="label">Source Service</label>
|
|
||||||
<div class="select">
|
|
||||||
<select name="source">
|
|
||||||
<option value="all" {% if selected_source == "all" %}selected{% endif %}>All</option>
|
|
||||||
<option value="web" {% if selected_source == "web" %}selected{% endif %}>Web</option>
|
|
||||||
<option value="xmpp" {% if selected_source == "xmpp" %}selected{% endif %}>XMPP</option>
|
|
||||||
<option value="signal" {% if selected_source == "signal" %}selected{% endif %}>Signal</option>
|
|
||||||
<option value="whatsapp" {% if selected_source == "whatsapp" %}selected{% endif %}>WhatsApp</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-12"></div>
|
|
||||||
|
|
||||||
<div class="column is-12-mobile is-6-tablet is-3-desktop">
|
|
||||||
<label class="label">From Date</label>
|
|
||||||
<input
|
|
||||||
class="input{% if selected_date_from %} date-has-value{% endif %}"
|
|
||||||
type="date"
|
|
||||||
name="date_from"
|
|
||||||
value="{{ selected_date_from }}"
|
|
||||||
aria-label="From date">
|
|
||||||
</div>
|
|
||||||
<div class="column is-12-mobile is-6-tablet is-3-desktop">
|
|
||||||
<label class="label">To Date</label>
|
|
||||||
<input
|
|
||||||
class="input{% if selected_date_to %} date-has-value{% endif %}"
|
|
||||||
type="date"
|
|
||||||
name="date_to"
|
|
||||||
value="{{ selected_date_to }}"
|
|
||||||
aria-label="To date">
|
|
||||||
</div>
|
|
||||||
<div class="column is-12-mobile is-6-tablet is-3-desktop">
|
|
||||||
<label class="label">Sort Mode</label>
|
|
||||||
<div class="select">
|
|
||||||
<select name="sort_mode">
|
|
||||||
<option value="relevance" {% if selected_sort_mode == "relevance" %}selected{% endif %}>Relevance</option>
|
|
||||||
<option value="recent" {% if selected_sort_mode == "recent" %}selected{% endif %}>Recent First</option>
|
|
||||||
<option value="oldest" {% if selected_sort_mode == "oldest" %}selected{% endif %}>Oldest First</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-12"></div>
|
|
||||||
|
|
||||||
<div class="column is-12-mobile is-6-tablet is-2-desktop">
|
|
||||||
<label class="label">Min Sent.</label>
|
|
||||||
<input class="input" type="number" step="0.01" min="-1" max="1" name="sentiment_min" value="{{ selected_sentiment_min }}">
|
|
||||||
</div>
|
|
||||||
<div class="column is-12-mobile is-6-tablet is-2-desktop">
|
|
||||||
<label class="label">Max Sent.</label>
|
|
||||||
<input class="input" type="number" step="0.01" min="-1" max="1" name="sentiment_max" value="{{ selected_sentiment_max }}">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="column is-12"></div>
|
|
||||||
|
|
||||||
<div class="column is-12">
|
|
||||||
<label class="checkbox" style="margin-right: 0.9rem;">
|
|
||||||
<input type="checkbox" name="annotate" value="1" {% if selected_annotate %}checked{% endif %}>
|
|
||||||
Annotate snippets
|
|
||||||
</label>
|
|
||||||
<label class="checkbox" style="margin-right: 0.9rem;">
|
|
||||||
<input type="checkbox" name="dedup" value="1" {% if selected_dedup %}checked{% endif %}>
|
|
||||||
Deduplicate
|
|
||||||
</label>
|
|
||||||
<label class="checkbox">
|
|
||||||
<input type="checkbox" name="reverse" value="1" {% if selected_reverse %}checked{% endif %}>
|
|
||||||
Reverse output
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="content is-size-7" style="margin-top: 0.2rem;">
|
|
||||||
<ul>
|
|
||||||
<li><strong>Min/Max Sent.</strong>: sentiment bounds for people/contact results (-1 to 1).</li>
|
|
||||||
<li><strong>Annotate snippets</strong>: shows contextual snippets around query hits.</li>
|
|
||||||
<li><strong>Deduplicate</strong>: removes near-identical repeated rows.</li>
|
|
||||||
<li><strong>Reverse output</strong>: reverses final result order after sorting.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div id="osint-search-results">
|
|
||||||
{% include "partials/results_table.html" %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.date-has-value::-webkit-calendar-picker-indicator {
|
|
||||||
filter: brightness(0) saturate(100%) invert(38%) sepia(85%) saturate(1820%) hue-rotate(201deg) brightness(96%) contrast(92%);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -125,3 +125,12 @@ class SettingsIntegrityTests(TestCase):
|
|||||||
self.assertEqual(200, response.status_code)
|
self.assertEqual(200, response.status_code)
|
||||||
self.assertContains(response, '<section class="section">', html=False)
|
self.assertContains(response, '<section class="section">', html=False)
|
||||||
self.assertContains(response, '<div class="container">', html=False)
|
self.assertContains(response, '<div class="container">', html=False)
|
||||||
|
|
||||||
|
def test_home_nav_no_longer_shows_search_page_link(self):
|
||||||
|
response = self.client.get(reverse("home"))
|
||||||
|
self.assertEqual(200, response.status_code)
|
||||||
|
self.assertNotContains(response, 'href="/search/page/"', html=False)
|
||||||
|
|
||||||
|
def test_removed_search_page_returns_404(self):
|
||||||
|
response = self.client.get("/search/page/")
|
||||||
|
self.assertEqual(404, response.status_code)
|
||||||
|
|||||||
@@ -2,21 +2,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import date, datetime
|
from datetime import datetime
|
||||||
from datetime import timezone as dt_timezone
|
|
||||||
from decimal import Decimal, InvalidOperation
|
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||||
from django.core.exceptions import FieldDoesNotExist
|
|
||||||
from django.core.paginator import Paginator
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import HttpResponseBadRequest
|
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils import timezone
|
|
||||||
from django.views import View
|
from django.views import View
|
||||||
|
|
||||||
from core.models import Group, Manipulation, Message, Person, Persona, PersonIdentifier
|
from core.models import Group, Manipulation, Message, Person, Persona, PersonIdentifier
|
||||||
@@ -43,11 +37,6 @@ def _safe_page_number(value: Any) -> int:
|
|||||||
return max(1, page_value)
|
return max(1, page_value)
|
||||||
|
|
||||||
|
|
||||||
def _safe_query_param(request, key: str, default: str = "") -> str:
|
|
||||||
raw = request.GET.get(key, default)
|
|
||||||
return str(raw or default).strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _sanitize_query_state(raw: dict[str, Any]) -> dict[str, str]:
|
def _sanitize_query_state(raw: dict[str, Any]) -> dict[str, str]:
|
||||||
cleaned: dict[str, str] = {}
|
cleaned: dict[str, str] = {}
|
||||||
for key, value in (raw or {}).items():
|
for key, value in (raw or {}).items():
|
||||||
@@ -72,27 +61,6 @@ def _context_type(request_type: str) -> str:
|
|||||||
return "modal" if request_type == "page" else request_type
|
return "modal" if request_type == "page" else request_type
|
||||||
|
|
||||||
|
|
||||||
def _parse_bool(raw: str) -> bool | None:
|
|
||||||
lowered = raw.strip().lower()
|
|
||||||
truthy = {"1", "true", "yes", "y", "on", "enabled"}
|
|
||||||
falsy = {"0", "false", "no", "n", "off", "disabled"}
|
|
||||||
if lowered in truthy:
|
|
||||||
return True
|
|
||||||
if lowered in falsy:
|
|
||||||
return False
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _preferred_related_text_field(model: type[models.Model]) -> str | None:
|
|
||||||
preferred = ("name", "alias", "title", "identifier", "model")
|
|
||||||
model_fields = {f.name: f for f in model._meta.get_fields()}
|
|
||||||
for candidate in preferred:
|
|
||||||
field = model_fields.get(candidate)
|
|
||||||
if isinstance(field, (models.CharField, models.TextField)):
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _column_field_name(column: "OsintColumn") -> str:
|
def _column_field_name(column: "OsintColumn") -> str:
|
||||||
if column.search_lookup:
|
if column.search_lookup:
|
||||||
return str(column.search_lookup).split("__", 1)[0]
|
return str(column.search_lookup).split("__", 1)[0]
|
||||||
@@ -853,814 +821,6 @@ class OSINTListBase(ObjectList):
|
|||||||
return context
|
return context
|
||||||
|
|
||||||
|
|
||||||
class OSINTSearch(LoginRequiredMixin, View):
|
|
||||||
allowed_types = {"page", "widget"}
|
|
||||||
page_template = "pages/osint-search.html"
|
|
||||||
widget_template = "mixins/wm/widget.html"
|
|
||||||
panel_template = "partials/osint/search-panel.html"
|
|
||||||
result_template = "partials/results_table.html"
|
|
||||||
per_page_default = 20
|
|
||||||
per_page_max = 100
|
|
||||||
all_scope_keys = ("people", "identifiers", "messages")
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SearchPlan:
|
|
||||||
size: int
|
|
||||||
index: str
|
|
||||||
query: str
|
|
||||||
tags: tuple[str, ...]
|
|
||||||
source: str
|
|
||||||
date_from: str
|
|
||||||
date_to: str
|
|
||||||
sort_mode: str
|
|
||||||
sentiment_min: str
|
|
||||||
sentiment_max: str
|
|
||||||
annotate: bool
|
|
||||||
dedup: bool
|
|
||||||
reverse: bool
|
|
||||||
|
|
||||||
def _prepare_siqtsrss_adr(self, request) -> "OSINTSearch.SearchPlan":
|
|
||||||
"""
|
|
||||||
Parse search controls following the Neptune-style SIQTSRSS/ADR flow.
|
|
||||||
S - Size, I - Index, Q - Query, T - Tags, S - Source, R - Ranges,
|
|
||||||
S - Sort, S - Sentiment, A - Annotate, D - Dedup, R - Reverse.
|
|
||||||
"""
|
|
||||||
query = _sanitize_search_query(_safe_query_param(request, "q", ""))
|
|
||||||
tags = tuple(
|
|
||||||
token[4:].strip()
|
|
||||||
for token in query.split()
|
|
||||||
if token.lower().startswith("tag:")
|
|
||||||
)
|
|
||||||
return self.SearchPlan(
|
|
||||||
size=self._per_page(request.GET.get("per_page")),
|
|
||||||
index=self._scope_key(request.GET.get("scope")),
|
|
||||||
query=query,
|
|
||||||
tags=tags,
|
|
||||||
source=_safe_query_param(request, "source", "all").lower() or "all",
|
|
||||||
date_from=_safe_query_param(request, "date_from", ""),
|
|
||||||
date_to=_safe_query_param(request, "date_to", ""),
|
|
||||||
sort_mode=_safe_query_param(request, "sort_mode", "relevance").lower(),
|
|
||||||
sentiment_min=_safe_query_param(request, "sentiment_min", ""),
|
|
||||||
sentiment_max=_safe_query_param(request, "sentiment_max", ""),
|
|
||||||
annotate=_safe_query_param(request, "annotate", "1")
|
|
||||||
not in {"0", "false", "off"},
|
|
||||||
dedup=_safe_query_param(request, "dedup", "") in {"1", "true", "on"},
|
|
||||||
reverse=_safe_query_param(request, "reverse", "") in {"1", "true", "on"},
|
|
||||||
)
|
|
||||||
|
|
||||||
def _parse_date_boundaries(
|
|
||||||
self, plan: "OSINTSearch.SearchPlan"
|
|
||||||
) -> tuple[datetime | None, datetime | None]:
|
|
||||||
parsed_from = None
|
|
||||||
parsed_to = None
|
|
||||||
if plan.date_from:
|
|
||||||
try:
|
|
||||||
parsed_from = datetime.fromisoformat(plan.date_from)
|
|
||||||
except ValueError:
|
|
||||||
parsed_from = None
|
|
||||||
if plan.date_to:
|
|
||||||
try:
|
|
||||||
parsed_to = datetime.fromisoformat(plan.date_to)
|
|
||||||
except ValueError:
|
|
||||||
parsed_to = None
|
|
||||||
if parsed_to is not None:
|
|
||||||
parsed_to = parsed_to.replace(
|
|
||||||
hour=23, minute=59, second=59, microsecond=999999
|
|
||||||
)
|
|
||||||
return parsed_from, parsed_to
|
|
||||||
|
|
||||||
def _score_hit(self, query: str, primary: str, secondary: str) -> int:
|
|
||||||
if not query:
|
|
||||||
return 0
|
|
||||||
needle = query.lower()
|
|
||||||
return primary.lower().count(needle) * 3 + secondary.lower().count(needle)
|
|
||||||
|
|
||||||
def _identifier_exact_boost(self, query: str, identifier: str) -> int:
|
|
||||||
needle = str(query or "").strip().lower()
|
|
||||||
hay = str(identifier or "").strip().lower()
|
|
||||||
if not needle or not hay:
|
|
||||||
return 0
|
|
||||||
if hay == needle:
|
|
||||||
return 120
|
|
||||||
if hay.startswith(needle):
|
|
||||||
return 45
|
|
||||||
if needle in hay:
|
|
||||||
return 15
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def _message_recency_boost(self, stamp: datetime | None) -> int:
|
|
||||||
if stamp is None:
|
|
||||||
return 0
|
|
||||||
now_ts = timezone.now()
|
|
||||||
if timezone.is_naive(stamp):
|
|
||||||
stamp = timezone.make_aware(stamp, dt_timezone.utc)
|
|
||||||
age = now_ts - stamp
|
|
||||||
if age.days < 1:
|
|
||||||
return 40
|
|
||||||
if age.days < 7:
|
|
||||||
return 25
|
|
||||||
if age.days < 30:
|
|
||||||
return 12
|
|
||||||
if age.days < 90:
|
|
||||||
return 6
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def _snippet(self, text: str, query: str, max_len: int = 180) -> str:
|
|
||||||
value = str(text or "").strip()
|
|
||||||
if not value:
|
|
||||||
return ""
|
|
||||||
if not query:
|
|
||||||
return value[:max_len]
|
|
||||||
lower = value.lower()
|
|
||||||
needle = query.lower()
|
|
||||||
idx = lower.find(needle)
|
|
||||||
if idx < 0:
|
|
||||||
return value[:max_len]
|
|
||||||
start = max(idx - 40, 0)
|
|
||||||
end = min(idx + len(needle) + 90, len(value))
|
|
||||||
snippet = value[start:end]
|
|
||||||
if start > 0:
|
|
||||||
snippet = "…" + snippet
|
|
||||||
if end < len(value):
|
|
||||||
snippet = snippet + "…"
|
|
||||||
return snippet
|
|
||||||
|
|
||||||
def _field_options(self, model_cls: type[models.Model]) -> list[dict[str, str]]:
|
|
||||||
options = []
|
|
||||||
for model_field in model_cls._meta.get_fields():
|
|
||||||
# Skip reverse/accessor relations (e.g. ManyToManyRel) that are not
|
|
||||||
# directly searchable as user-facing fields in this selector.
|
|
||||||
if model_field.auto_created and not model_field.concrete:
|
|
||||||
continue
|
|
||||||
if model_field.name == "user":
|
|
||||||
continue
|
|
||||||
label = getattr(
|
|
||||||
model_field,
|
|
||||||
"verbose_name",
|
|
||||||
str(model_field.name).replace("_", " "),
|
|
||||||
)
|
|
||||||
options.append(
|
|
||||||
{
|
|
||||||
"value": model_field.name,
|
|
||||||
"label": str(label).title(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
options.sort(key=lambda item: item["label"])
|
|
||||||
return options
|
|
||||||
|
|
||||||
def _field_q(
|
|
||||||
self,
|
|
||||||
model_cls: type[models.Model],
|
|
||||||
field_name: str,
|
|
||||||
query: str,
|
|
||||||
) -> tuple[Q | None, bool]:
|
|
||||||
try:
|
|
||||||
field = model_cls._meta.get_field(field_name)
|
|
||||||
except FieldDoesNotExist:
|
|
||||||
return None, False
|
|
||||||
|
|
||||||
if isinstance(field, (models.CharField, models.TextField, models.UUIDField)):
|
|
||||||
return Q(**{f"{field_name}__icontains": query}), False
|
|
||||||
if isinstance(field, models.BooleanField):
|
|
||||||
parsed = _parse_bool(query)
|
|
||||||
if parsed is None:
|
|
||||||
return None, False
|
|
||||||
return Q(**{field_name: parsed}), False
|
|
||||||
if isinstance(field, models.IntegerField):
|
|
||||||
try:
|
|
||||||
return Q(**{field_name: int(query)}), False
|
|
||||||
except ValueError:
|
|
||||||
return None, False
|
|
||||||
if isinstance(field, (models.FloatField, models.DecimalField)):
|
|
||||||
try:
|
|
||||||
value = Decimal(query)
|
|
||||||
except InvalidOperation:
|
|
||||||
return None, False
|
|
||||||
return Q(**{field_name: value}), False
|
|
||||||
if isinstance(field, models.DateField):
|
|
||||||
try:
|
|
||||||
parsed_date = date.fromisoformat(query)
|
|
||||||
except ValueError:
|
|
||||||
return None, False
|
|
||||||
return Q(**{field_name: parsed_date}), False
|
|
||||||
if isinstance(field, models.DateTimeField):
|
|
||||||
try:
|
|
||||||
parsed_dt = datetime.fromisoformat(query)
|
|
||||||
except ValueError:
|
|
||||||
try:
|
|
||||||
parsed_date = date.fromisoformat(query)
|
|
||||||
except ValueError:
|
|
||||||
return None, False
|
|
||||||
return Q(**{f"{field_name}__date": parsed_date}), False
|
|
||||||
return Q(**{field_name: parsed_dt}), False
|
|
||||||
if isinstance(field, models.ForeignKey):
|
|
||||||
related_text_field = _preferred_related_text_field(field.related_model)
|
|
||||||
if related_text_field:
|
|
||||||
return (
|
|
||||||
Q(**{f"{field_name}__{related_text_field}__icontains": query}),
|
|
||||||
False,
|
|
||||||
)
|
|
||||||
return Q(**{f"{field_name}__id__icontains": query}), False
|
|
||||||
if isinstance(field, models.ManyToManyField):
|
|
||||||
related_text_field = _preferred_related_text_field(field.related_model)
|
|
||||||
if related_text_field:
|
|
||||||
return (
|
|
||||||
Q(**{f"{field_name}__{related_text_field}__icontains": query}),
|
|
||||||
True,
|
|
||||||
)
|
|
||||||
return Q(**{f"{field_name}__id__icontains": query}), True
|
|
||||||
return None, False
|
|
||||||
|
|
||||||
def _search_queryset(
|
|
||||||
self,
|
|
||||||
queryset: models.QuerySet,
|
|
||||||
model_cls: type[models.Model],
|
|
||||||
query: str,
|
|
||||||
field_name: str,
|
|
||||||
field_options: list[dict[str, str]],
|
|
||||||
) -> models.QuerySet:
|
|
||||||
if not query:
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
if field_name != "__all__":
|
|
||||||
field_q, use_distinct = self._field_q(model_cls, field_name, query)
|
|
||||||
if field_q is None:
|
|
||||||
return queryset.none()
|
|
||||||
queryset = queryset.filter(field_q)
|
|
||||||
return queryset.distinct() if use_distinct else queryset
|
|
||||||
|
|
||||||
condition = Q()
|
|
||||||
use_distinct = False
|
|
||||||
for option in field_options:
|
|
||||||
field_q, field_distinct = self._field_q(
|
|
||||||
model_cls,
|
|
||||||
option["value"],
|
|
||||||
query,
|
|
||||||
)
|
|
||||||
if field_q is None:
|
|
||||||
continue
|
|
||||||
condition |= field_q
|
|
||||||
use_distinct = use_distinct or field_distinct
|
|
||||||
if not condition.children:
|
|
||||||
return queryset.none()
|
|
||||||
queryset = queryset.filter(condition)
|
|
||||||
return queryset.distinct() if use_distinct else queryset
|
|
||||||
|
|
||||||
def _per_page(self, raw_value: str | None) -> int:
|
|
||||||
if not raw_value:
|
|
||||||
return self.per_page_default
|
|
||||||
try:
|
|
||||||
value = int(raw_value)
|
|
||||||
except ValueError:
|
|
||||||
return self.per_page_default
|
|
||||||
if value < 1:
|
|
||||||
return self.per_page_default
|
|
||||||
return min(value, self.per_page_max)
|
|
||||||
|
|
||||||
def _scope_key(self, raw_scope: str | None) -> str:
|
|
||||||
if raw_scope == "all":
|
|
||||||
return "all"
|
|
||||||
if raw_scope in OSINT_SCOPES:
|
|
||||||
return raw_scope
|
|
||||||
return "all"
|
|
||||||
|
|
||||||
def _query_state(self, request) -> dict[str, Any]:
|
|
||||||
return _sanitize_query_state(
|
|
||||||
{k: v for k, v in request.GET.items() if v not in {None, ""}}
|
|
||||||
)
|
|
||||||
|
|
||||||
def _apply_common_filters(
|
|
||||||
self,
|
|
||||||
queryset: models.QuerySet,
|
|
||||||
scope_key: str,
|
|
||||||
plan: "OSINTSearch.SearchPlan",
|
|
||||||
) -> models.QuerySet:
|
|
||||||
date_from, date_to = self._parse_date_boundaries(plan)
|
|
||||||
|
|
||||||
if plan.source and plan.source != "all":
|
|
||||||
if scope_key == "messages":
|
|
||||||
queryset = queryset.filter(source_service=plan.source)
|
|
||||||
elif scope_key == "identifiers":
|
|
||||||
queryset = queryset.filter(service=plan.source)
|
|
||||||
elif scope_key == "people":
|
|
||||||
queryset = queryset.filter(
|
|
||||||
personidentifier__service=plan.source
|
|
||||||
).distinct()
|
|
||||||
|
|
||||||
if scope_key == "messages":
|
|
||||||
if date_from is not None:
|
|
||||||
queryset = queryset.filter(ts__gte=int(date_from.timestamp() * 1000))
|
|
||||||
if date_to is not None:
|
|
||||||
queryset = queryset.filter(ts__lte=int(date_to.timestamp() * 1000))
|
|
||||||
elif scope_key == "people":
|
|
||||||
if date_from is not None:
|
|
||||||
queryset = queryset.filter(last_interaction__gte=date_from)
|
|
||||||
if date_to is not None:
|
|
||||||
queryset = queryset.filter(last_interaction__lte=date_to)
|
|
||||||
if plan.sentiment_min:
|
|
||||||
try:
|
|
||||||
queryset = queryset.filter(sentiment__gte=float(plan.sentiment_min))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
if plan.sentiment_max:
|
|
||||||
try:
|
|
||||||
queryset = queryset.filter(sentiment__lte=float(plan.sentiment_max))
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
return queryset
|
|
||||||
|
|
||||||
def _search_all_rows(
|
|
||||||
self,
|
|
||||||
request,
|
|
||||||
plan: "OSINTSearch.SearchPlan",
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
rows: list[dict[str, Any]] = []
|
|
||||||
query = plan.query
|
|
||||||
date_from, date_to = self._parse_date_boundaries(plan)
|
|
||||||
per_scope_limit = max(40, min(plan.size * 3, 250))
|
|
||||||
allowed_scopes = set(self.all_scope_keys)
|
|
||||||
tag_scopes = {
|
|
||||||
tag.strip().lower()
|
|
||||||
for tag in plan.tags
|
|
||||||
if tag.strip().lower() in allowed_scopes
|
|
||||||
}
|
|
||||||
if tag_scopes:
|
|
||||||
allowed_scopes = tag_scopes
|
|
||||||
|
|
||||||
if "people" in allowed_scopes:
|
|
||||||
people_qs = self._apply_common_filters(
|
|
||||||
Person.objects.filter(user=request.user),
|
|
||||||
"people",
|
|
||||||
plan,
|
|
||||||
)
|
|
||||||
if query:
|
|
||||||
people_qs = people_qs.filter(
|
|
||||||
Q(name__icontains=query)
|
|
||||||
| Q(summary__icontains=query)
|
|
||||||
| Q(profile__icontains=query)
|
|
||||||
| Q(revealed__icontains=query)
|
|
||||||
| Q(likes__icontains=query)
|
|
||||||
| Q(dislikes__icontains=query)
|
|
||||||
)
|
|
||||||
for item in people_qs.order_by("-last_interaction", "name")[
|
|
||||||
:per_scope_limit
|
|
||||||
]:
|
|
||||||
secondary = self._snippet(
|
|
||||||
f"{item.summary or ''} {item.profile or ''}".strip(),
|
|
||||||
query if plan.annotate else "",
|
|
||||||
)
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"id": f"person:{item.id}",
|
|
||||||
"scope": "Contact",
|
|
||||||
"primary": item.name,
|
|
||||||
"secondary": secondary or (item.timezone or ""),
|
|
||||||
"service": "-",
|
|
||||||
"when": item.last_interaction,
|
|
||||||
"score": self._score_hit(
|
|
||||||
query, item.name or "", secondary or ""
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if "identifiers" in allowed_scopes:
|
|
||||||
identifiers_qs = self._apply_common_filters(
|
|
||||||
PersonIdentifier.objects.filter(user=request.user).select_related(
|
|
||||||
"person"
|
|
||||||
),
|
|
||||||
"identifiers",
|
|
||||||
plan,
|
|
||||||
)
|
|
||||||
if query:
|
|
||||||
identifiers_qs = identifiers_qs.filter(
|
|
||||||
Q(identifier__icontains=query)
|
|
||||||
| Q(person__name__icontains=query)
|
|
||||||
| Q(service__icontains=query)
|
|
||||||
)
|
|
||||||
for item in identifiers_qs.order_by("person__name", "identifier")[
|
|
||||||
:per_scope_limit
|
|
||||||
]:
|
|
||||||
primary = item.person.name if item.person_id else item.identifier
|
|
||||||
secondary = item.identifier if item.person_id else ""
|
|
||||||
base_score = self._score_hit(query, primary or "", secondary or "")
|
|
||||||
exact_boost = self._identifier_exact_boost(query, item.identifier)
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"id": f"identifier:{item.id}",
|
|
||||||
"scope": "Identifier",
|
|
||||||
"primary": primary,
|
|
||||||
"secondary": secondary,
|
|
||||||
"service": item.service or "",
|
|
||||||
"when": None,
|
|
||||||
"score": base_score + exact_boost,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if "messages" in allowed_scopes:
|
|
||||||
messages_qs = self._apply_common_filters(
|
|
||||||
Message.objects.filter(user=request.user),
|
|
||||||
"messages",
|
|
||||||
plan,
|
|
||||||
)
|
|
||||||
if query:
|
|
||||||
messages_qs = messages_qs.filter(
|
|
||||||
Q(text__icontains=query)
|
|
||||||
| Q(custom_author__icontains=query)
|
|
||||||
| Q(sender_uuid__icontains=query)
|
|
||||||
| Q(source_chat_id__icontains=query)
|
|
||||||
| Q(source_message_id__icontains=query)
|
|
||||||
)
|
|
||||||
for item in messages_qs.order_by("-ts")[:per_scope_limit]:
|
|
||||||
when_dt = datetime.fromtimestamp(item.ts / 1000.0) if item.ts else None
|
|
||||||
if date_from and when_dt and when_dt < date_from:
|
|
||||||
continue
|
|
||||||
if date_to and when_dt and when_dt > date_to:
|
|
||||||
continue
|
|
||||||
primary = (
|
|
||||||
item.custom_author
|
|
||||||
or item.sender_uuid
|
|
||||||
or (item.source_chat_id or "Message")
|
|
||||||
)
|
|
||||||
secondary = self._snippet(
|
|
||||||
item.text or "", query if plan.annotate else ""
|
|
||||||
)
|
|
||||||
base_score = self._score_hit(query, primary or "", item.text or "")
|
|
||||||
recency_boost = self._message_recency_boost(when_dt)
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"id": f"message:{item.id}",
|
|
||||||
"scope": "Message",
|
|
||||||
"primary": primary,
|
|
||||||
"secondary": secondary,
|
|
||||||
"service": item.source_service or "-",
|
|
||||||
"when": when_dt,
|
|
||||||
"score": base_score + recency_boost,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if plan.dedup:
|
|
||||||
seen = set()
|
|
||||||
deduped = []
|
|
||||||
for row in rows:
|
|
||||||
key = (
|
|
||||||
row["scope"],
|
|
||||||
str(row["primary"]).strip().lower(),
|
|
||||||
str(row["secondary"]).strip().lower(),
|
|
||||||
str(row["service"]).strip().lower(),
|
|
||||||
)
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
deduped.append(row)
|
|
||||||
rows = deduped
|
|
||||||
|
|
||||||
def row_time_key(row: dict[str, Any]) -> float:
|
|
||||||
stamp = row.get("when")
|
|
||||||
if stamp is None:
|
|
||||||
return 0.0
|
|
||||||
if timezone.is_aware(stamp):
|
|
||||||
return float(stamp.timestamp())
|
|
||||||
return float(stamp.replace(tzinfo=dt_timezone.utc).timestamp())
|
|
||||||
|
|
||||||
if plan.sort_mode == "oldest":
|
|
||||||
rows.sort(key=row_time_key)
|
|
||||||
elif plan.sort_mode == "recent":
|
|
||||||
rows.sort(key=row_time_key, reverse=True)
|
|
||||||
else:
|
|
||||||
rows.sort(
|
|
||||||
key=lambda row: (
|
|
||||||
row["score"],
|
|
||||||
row_time_key(row),
|
|
||||||
),
|
|
||||||
reverse=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
if plan.reverse:
|
|
||||||
rows.reverse()
|
|
||||||
return rows
|
|
||||||
|
|
||||||
def _active_sort(self, scope: OsintScopeConfig) -> tuple[str, str]:
|
|
||||||
direction = self.request.GET.get("dir", "asc").lower()
|
|
||||||
if direction not in {"asc", "desc"}:
|
|
||||||
direction = "asc"
|
|
||||||
allowed = {col.sort_field for col in scope.columns if col.sort_field}
|
|
||||||
sort_field = self.request.GET.get("sort")
|
|
||||||
if sort_field not in allowed:
|
|
||||||
sort_field = scope.default_sort
|
|
||||||
return sort_field, direction
|
|
||||||
|
|
||||||
def _build_column_context(
|
|
||||||
self,
|
|
||||||
scope: OsintScopeConfig,
|
|
||||||
list_url: str,
|
|
||||||
query_state: dict[str, Any],
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
sort_field, direction = self._active_sort(scope)
|
|
||||||
columns = []
|
|
||||||
for column in scope.columns:
|
|
||||||
if not column.sort_field:
|
|
||||||
columns.append(
|
|
||||||
{
|
|
||||||
"key": column.key,
|
|
||||||
"field_name": _column_field_name(column),
|
|
||||||
"label": column.label,
|
|
||||||
"sortable": False,
|
|
||||||
"kind": column.kind,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
is_sorted = sort_field == column.sort_field
|
|
||||||
next_direction = "desc" if is_sorted and direction == "asc" else "asc"
|
|
||||||
sort_query = _merge_query(
|
|
||||||
query_state,
|
|
||||||
sort=column.sort_field,
|
|
||||||
dir=next_direction,
|
|
||||||
page=1,
|
|
||||||
)
|
|
||||||
columns.append(
|
|
||||||
{
|
|
||||||
"key": column.key,
|
|
||||||
"field_name": _column_field_name(column),
|
|
||||||
"label": column.label,
|
|
||||||
"sortable": True,
|
|
||||||
"kind": column.kind,
|
|
||||||
"is_sorted": is_sorted,
|
|
||||||
"is_desc": is_sorted and direction == "desc",
|
|
||||||
"sort_url": _url_with_query(list_url, sort_query),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return columns
|
|
||||||
|
|
||||||
def _build_rows(
|
|
||||||
self,
|
|
||||||
scope: OsintScopeConfig,
|
|
||||||
object_list: list[Any],
|
|
||||||
request_type: str,
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
rows = []
|
|
||||||
for item in object_list:
|
|
||||||
row = {"id": str(item.pk), "cells": [], "actions": []}
|
|
||||||
for column in scope.columns:
|
|
||||||
row["cells"].append(
|
|
||||||
{
|
|
||||||
"kind": column.kind,
|
|
||||||
"value": column.accessor(item),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
rows.append(row)
|
|
||||||
return rows
|
|
||||||
|
|
||||||
def _build_pagination(
|
|
||||||
self,
|
|
||||||
page_obj: Any,
|
|
||||||
list_url: str,
|
|
||||||
query_state: dict[str, Any],
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
if page_obj is None:
|
|
||||||
return {"enabled": False}
|
|
||||||
|
|
||||||
pagination = {
|
|
||||||
"enabled": page_obj.paginator.num_pages > 1,
|
|
||||||
"count": page_obj.paginator.count,
|
|
||||||
"current": page_obj.number,
|
|
||||||
"total": page_obj.paginator.num_pages,
|
|
||||||
"has_previous": page_obj.has_previous(),
|
|
||||||
"has_next": page_obj.has_next(),
|
|
||||||
"previous_url": None,
|
|
||||||
"next_url": None,
|
|
||||||
"pages": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
if page_obj.has_previous():
|
|
||||||
previous_page = _safe_page_number(page_obj.previous_page_number())
|
|
||||||
pagination["previous_url"] = _url_with_query(
|
|
||||||
list_url,
|
|
||||||
{"page": previous_page},
|
|
||||||
)
|
|
||||||
if page_obj.has_next():
|
|
||||||
next_page = _safe_page_number(page_obj.next_page_number())
|
|
||||||
pagination["next_url"] = _url_with_query(
|
|
||||||
list_url,
|
|
||||||
{"page": next_page},
|
|
||||||
)
|
|
||||||
|
|
||||||
for entry in page_obj.paginator.get_elided_page_range(page_obj.number):
|
|
||||||
if entry == "…":
|
|
||||||
pagination["pages"].append({"ellipsis": True})
|
|
||||||
continue
|
|
||||||
pagination["pages"].append(
|
|
||||||
{
|
|
||||||
"ellipsis": False,
|
|
||||||
"number": entry,
|
|
||||||
"current": entry == page_obj.number,
|
|
||||||
"url": _url_with_query(
|
|
||||||
list_url,
|
|
||||||
{"page": _safe_page_number(entry)},
|
|
||||||
),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return pagination
|
|
||||||
|
|
||||||
def get(self, request, type):
|
|
||||||
if type not in self.allowed_types:
|
|
||||||
return HttpResponseBadRequest("Invalid type specified.")
|
|
||||||
|
|
||||||
plan = self._prepare_siqtsrss_adr(request)
|
|
||||||
scope_key = plan.index
|
|
||||||
query = plan.query
|
|
||||||
list_url = reverse("osint_search", kwargs={"type": type})
|
|
||||||
query_state = self._query_state(request)
|
|
||||||
field_name = request.GET.get("field", "__all__")
|
|
||||||
|
|
||||||
if scope_key == "all":
|
|
||||||
rows_raw = self._search_all_rows(request, plan)
|
|
||||||
paginator = Paginator(rows_raw, plan.size)
|
|
||||||
page_obj = paginator.get_page(request.GET.get("page"))
|
|
||||||
|
|
||||||
column_context = [
|
|
||||||
{
|
|
||||||
"key": "scope",
|
|
||||||
"field_name": "scope",
|
|
||||||
"label": "Type",
|
|
||||||
"sortable": False,
|
|
||||||
"kind": "text",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "primary",
|
|
||||||
"field_name": "primary",
|
|
||||||
"label": "Primary",
|
|
||||||
"sortable": False,
|
|
||||||
"kind": "text",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "secondary",
|
|
||||||
"field_name": "secondary",
|
|
||||||
"label": "Details",
|
|
||||||
"sortable": False,
|
|
||||||
"kind": "text",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "service",
|
|
||||||
"field_name": "service",
|
|
||||||
"label": "Service",
|
|
||||||
"sortable": False,
|
|
||||||
"kind": "text",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "when",
|
|
||||||
"field_name": "when",
|
|
||||||
"label": "When",
|
|
||||||
"sortable": False,
|
|
||||||
"kind": "datetime",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
rows = []
|
|
||||||
for item in list(page_obj.object_list):
|
|
||||||
rows.append(
|
|
||||||
{
|
|
||||||
"id": item["id"],
|
|
||||||
"cells": [
|
|
||||||
{"kind": "text", "value": item.get("scope")},
|
|
||||||
{"kind": "text", "value": item.get("primary")},
|
|
||||||
{"kind": "text", "value": item.get("secondary")},
|
|
||||||
{"kind": "text", "value": item.get("service")},
|
|
||||||
{"kind": "datetime", "value": item.get("when")},
|
|
||||||
],
|
|
||||||
"actions": [],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
pagination = self._build_pagination(page_obj, list_url, query_state)
|
|
||||||
field_options: list[dict[str, str]] = []
|
|
||||||
selected_scope_key = "all"
|
|
||||||
osint_title = "Search Everything"
|
|
||||||
result_count = paginator.count
|
|
||||||
else:
|
|
||||||
scope = OSINT_SCOPES[scope_key]
|
|
||||||
field_options = self._field_options(scope.model)
|
|
||||||
if field_name != "__all__":
|
|
||||||
allowed_fields = {option["value"] for option in field_options}
|
|
||||||
if field_name not in allowed_fields:
|
|
||||||
field_name = "__all__"
|
|
||||||
|
|
||||||
queryset = scope.model.objects.filter(user=request.user)
|
|
||||||
if scope.select_related:
|
|
||||||
queryset = queryset.select_related(*scope.select_related)
|
|
||||||
if scope.prefetch_related:
|
|
||||||
queryset = queryset.prefetch_related(*scope.prefetch_related)
|
|
||||||
|
|
||||||
queryset = self._apply_common_filters(queryset, scope.key, plan)
|
|
||||||
queryset = self._search_queryset(
|
|
||||||
queryset,
|
|
||||||
scope.model,
|
|
||||||
query,
|
|
||||||
field_name,
|
|
||||||
field_options,
|
|
||||||
)
|
|
||||||
if plan.dedup:
|
|
||||||
queryset = queryset.distinct()
|
|
||||||
|
|
||||||
sort_field = request.GET.get("sort", scope.default_sort)
|
|
||||||
direction = request.GET.get("dir", "asc").lower()
|
|
||||||
allowed_sort_fields = {
|
|
||||||
column.sort_field for column in scope.columns if column.sort_field
|
|
||||||
}
|
|
||||||
if sort_field not in allowed_sort_fields:
|
|
||||||
sort_field = scope.default_sort
|
|
||||||
if direction not in {"asc", "desc"}:
|
|
||||||
direction = "asc"
|
|
||||||
if sort_field:
|
|
||||||
order_by = sort_field if direction == "asc" else f"-{sort_field}"
|
|
||||||
queryset = queryset.order_by(order_by)
|
|
||||||
if plan.reverse:
|
|
||||||
queryset = queryset.reverse()
|
|
||||||
|
|
||||||
paginator = Paginator(queryset, plan.size)
|
|
||||||
page_obj = paginator.get_page(request.GET.get("page"))
|
|
||||||
object_list = list(page_obj.object_list)
|
|
||||||
|
|
||||||
column_context = self._build_column_context(
|
|
||||||
scope,
|
|
||||||
list_url,
|
|
||||||
query_state,
|
|
||||||
)
|
|
||||||
rows = self._build_rows(
|
|
||||||
scope,
|
|
||||||
object_list,
|
|
||||||
type,
|
|
||||||
)
|
|
||||||
pagination = self._build_pagination(
|
|
||||||
page_obj,
|
|
||||||
list_url,
|
|
||||||
query_state,
|
|
||||||
)
|
|
||||||
selected_scope_key = scope.key
|
|
||||||
osint_title = f"Search {scope.title}"
|
|
||||||
result_count = paginator.count
|
|
||||||
|
|
||||||
context = {
|
|
||||||
"osint_scope": selected_scope_key,
|
|
||||||
"osint_title": osint_title,
|
|
||||||
"osint_table_id": "osint-search-table",
|
|
||||||
"osint_event_name": "",
|
|
||||||
"osint_refresh_url": _url_with_query(list_url, query_state),
|
|
||||||
"osint_columns": column_context,
|
|
||||||
"osint_rows": rows,
|
|
||||||
"osint_pagination": pagination,
|
|
||||||
"osint_show_search": False,
|
|
||||||
"osint_show_actions": False,
|
|
||||||
"osint_shell_borderless": True,
|
|
||||||
"osint_result_count": result_count,
|
|
||||||
"osint_search_url": list_url,
|
|
||||||
"scope_options": [
|
|
||||||
{"value": "all", "label": "All (Contacts + Messages)"},
|
|
||||||
*[
|
|
||||||
{"value": key, "label": conf.title}
|
|
||||||
for key, conf in OSINT_SCOPES.items()
|
|
||||||
],
|
|
||||||
],
|
|
||||||
"field_options": field_options,
|
|
||||||
"selected_scope": selected_scope_key,
|
|
||||||
"selected_field": field_name,
|
|
||||||
"search_query": query,
|
|
||||||
"selected_per_page": plan.size,
|
|
||||||
"selected_source": plan.source,
|
|
||||||
"selected_date_from": plan.date_from,
|
|
||||||
"selected_date_to": plan.date_to,
|
|
||||||
"selected_sort_mode": plan.sort_mode,
|
|
||||||
"selected_sentiment_min": plan.sentiment_min,
|
|
||||||
"selected_sentiment_max": plan.sentiment_max,
|
|
||||||
"selected_annotate": plan.annotate,
|
|
||||||
"selected_dedup": plan.dedup,
|
|
||||||
"selected_reverse": plan.reverse,
|
|
||||||
"search_page_url": reverse("osint_search", kwargs={"type": "page"}),
|
|
||||||
"search_widget_url": reverse("osint_search", kwargs={"type": "widget"}),
|
|
||||||
}
|
|
||||||
|
|
||||||
hx_target = request.headers.get("HX-Target")
|
|
||||||
if request.htmx and hx_target in {"osint-search-results", "osint-search-table"}:
|
|
||||||
response = render(request, self.result_template, context)
|
|
||||||
if type == "page":
|
|
||||||
response["HX-Replace-Url"] = _url_with_query(list_url, query_state)
|
|
||||||
return response
|
|
||||||
|
|
||||||
if type == "widget":
|
|
||||||
widget_context = {
|
|
||||||
"title": "Search",
|
|
||||||
"unique": "osint-search-widget",
|
|
||||||
"window_content": self.panel_template,
|
|
||||||
"widget_options": 'gs-w="8" gs-h="14" gs-x="0" gs-y="0" gs-min-w="5"',
|
|
||||||
"widget_icon": _safe_icon_class(
|
|
||||||
request.GET.get("widget_icon"),
|
|
||||||
"fa-solid fa-magnifying-glass",
|
|
||||||
),
|
|
||||||
**context,
|
|
||||||
}
|
|
||||||
return render(request, self.widget_template, widget_context)
|
|
||||||
|
|
||||||
return render(request, self.page_template, context)
|
|
||||||
|
|
||||||
|
|
||||||
class OSINTWorkspace(LoginRequiredMixin, View):
|
class OSINTWorkspace(LoginRequiredMixin, View):
|
||||||
template_name = "pages/osint-workspace.html"
|
template_name = "pages/osint-workspace.html"
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
{% if widget_script_srcs %}data-gia-script-srcs="{{ widget_script_srcs|join:'|' }}"{% endif %}>
|
{% if widget_script_srcs %}data-gia-script-srcs="{{ widget_script_srcs|join:'|' }}"{% endif %}>
|
||||||
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
|
<div id="widget-{{ unique }}" class="grid-stack-item" {% block widget_options %}{% if widget_options is None %}gs-w="6" gs-h="1" gs-y="20" gs-x="0"{% else %}{% autoescape off %}{{ widget_options }}{% endautoescape %}{% endif %}{% endblock %}>
|
||||||
<div class="grid-stack-item-content">
|
<div class="grid-stack-item-content">
|
||||||
<nav class="panel gia-widget-panel">
|
<section class="gia-widget-panel">
|
||||||
<p class="panel-heading gia-widget-heading">
|
<header class="gia-widget-heading">
|
||||||
<span class="gia-widget-heading-main">
|
<span class="gia-widget-heading-main">
|
||||||
<span class="icon is-small gia-widget-heading-icon">
|
<span class="icon is-small gia-widget-heading-icon">
|
||||||
<i class="{{ widget_icon|default:'fa-solid fa-window-maximize' }}"></i>
|
<i class="{{ widget_icon|default:'fa-solid fa-window-maximize' }}"></i>
|
||||||
@@ -54,15 +54,15 @@
|
|||||||
{% include 'mixins/partials/close-widget.html' %}
|
{% include 'mixins/partials/close-widget.html' %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</header>
|
||||||
<article class="panel-block is-active gia-widget-body">
|
<div class="gia-widget-body">
|
||||||
<div class="control gia-widget-control{% if widget_control_class %} {{ widget_control_class }}{% endif %}">
|
<div class="control gia-widget-control{% if widget_control_class %} {{ widget_control_class }}{% endif %}">
|
||||||
{% block panel_content %}
|
{% block panel_content %}
|
||||||
{% include window_content %}
|
{% include window_content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</div>
|
||||||
</nav>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user