Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions ckanext/selfinfo/assets/js/reset-module-last-check.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ this.ckan.module('reset-module-last-check', function($) {
monthNames: ["January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
],
action: null
},
initialize: function () {
$.proxyAll(this, /_on/);
Expand All @@ -15,9 +16,9 @@ this.ckan.module('reset-module-last-check', function($) {
$(item).on('click', function(event){
event.preventDefault();
const target = this.getAttribute('data-target');
if (target) {
if (target && _this.options.action) {
var client = _this.sandbox.client;
client.call('POST', "update_last_module_check", { module : target }, _this._onClickLoaded);
client.call('POST', _this.options.action, { module : target }, _this._onClickLoaded);
}
});
})
Expand Down
39 changes: 36 additions & 3 deletions ckanext/selfinfo/templates/selfinfo/snippets/self_errors.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@
<input type="text" class="form-control mb-3" placeholder="Search items..." data-module="search-through-content" data-module-target="#self-errors-parent .list-group-item">
</div>
{% if data.errors|length and profile == 'default' %}
<form action="">
<button class="btn selfinfo-button-main-color-bg text-white" type="submit" name="drop_errors" value="true">{{ _('Clear Errors') }}</button>
</form>
<div>
<button class="btn selfinfo-button-main-color-bg text-white"
type="button"
data-bs-toggle="modal"
data-bs-target="#clearErrorsModal">
{{ _('Clear Errors') }}
</button>
</div>
{% endif %}
</div>
<div class="list-group" id="self-errors-parent">
Expand Down Expand Up @@ -46,3 +51,31 @@
</p>
{% endif %}
</div>

{% if data.errors|length and profile == 'default' %}
<div class="modal fade" id="clearErrorsModal" tabindex="-1" aria-labelledby="clearErrorsModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="clearErrorsModalLabel">{{ _('Confirm Clear Errors') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{{ _('Are you sure you want to clear all errors?') }}</p>
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
{{ _('This will permanently delete') }} <strong>{{ data.errors|length }}</strong> {{ _('error(s)') }}.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ _('Cancel') }}</button>
<form action="" method="get" style="display: inline;">
<button type="submit" name="drop_errors" value="true" class="btn btn-danger">
{{ _('Yes, Clear All Errors') }}
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ <h2 class="accordion-header" id="{{group}}-headinginfo">
</h2>
<div id="{{group}}-collapseinfo" class="accordion-collapse collapse" aria-labelledby="{{group}}-headinginfo">
<div class="accordion-body">
<table class="table" data-module="reset-module-last-check">
<table class="table" data-module="reset-module-last-check" data-module-action="{{ h.selfinfo_action_name('update_last_module_check') }}">
<thead>
<tr>
<th scope="col">Name</th>
Expand Down
157 changes: 153 additions & 4 deletions ckanext/selftools/logic/action.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,14 @@
from sqlalchemy import desc, exc as sql_exceptions, text
from sqlalchemy.inspection import inspect
import redis
import psycopg2.errors
from typing import Any, Literal
from datetime import datetime

from ckan import types
import ckan.model as model
import ckan.plugins.toolkit as tk
from ckan.common import request
from ckan.lib.search.common import (
is_available as solr_available,
make_connection as solr_connection,
Expand All @@ -34,6 +36,125 @@
log = logging.getLogger(__name__)


def apply_db_filter(
query: Any,
model_class: Any,
field: str,
value: str,
operator: str = "equal",
) -> Any:
"""
Apply filter to SQLAlchemy query based on operator type.

Args:
query: SQLAlchemy query object
model_class: Model class to filter
field: Field name to filter on
value: Value to filter by
operator: Comparison operator (equal, not_equal, starts_with, ends_with, contains)

Returns:
Modified query object

Raises:
ValueError: If operator is not supported for the field type
AttributeError: If field does not exist in model
"""
# Check if field exists in model (protection against non-existent fields)
if not hasattr(model_class, field):
raise AttributeError(
f"Field '{field}' does not exist in model '{model_class.__name__}'"
)

column = getattr(model_class, field)

# Get column type to determine which operators are supported
try:
column_type = str(column.type).lower()
except Exception:
column_type = "unknown"

# Handle NULL/empty values
if value.lower() in ["null", "none", ""]:
if operator == "equal":
return query.filter(column.is_(None))
elif operator == "not_equal":
return query.filter(column.isnot(None))
else:
raise ValueError(
"Only 'equal' and 'not_equal' operators are supported for NULL values"
)

# Check if this is a JSON/JSONB type
is_json = any(t in column_type for t in ["json", "jsonb"])

# For JSON/JSONB fields, use text casting and pattern matching
if is_json:
if operator == "not_equal":
raise ValueError(
f"Operator '{operator}' is not supported for JSON field '{field}'. "
f"Use 'equal', 'contains', 'starts_with', or 'ends_with' instead."
)
# Use SQLAlchemy's cast to avoid SQL injection
from sqlalchemy import cast, String

if operator == "starts_with":
return query.filter(cast(column, String).ilike(f"{value}%"))
elif operator == "ends_with":
return query.filter(cast(column, String).ilike(f"%{value}"))
elif operator == "contains":
return query.filter(cast(column, String).ilike(f"%{value}%"))
else: # equal - search as contains for JSON
return query.filter(cast(column, String).ilike(f"%{value}%"))

# Check if this is a non-string type (expanded list)
is_non_string = any(
t in column_type
for t in [
"boolean",
"bool",
"integer",
"int",
"bigint",
"smallint",
"float",
"numeric",
"decimal",
"real",
"double",
"date",
"time",
"timestamp",
"uuid",
"array",
"[]",
]
)

# Pattern matching operators are only supported for string types
if is_non_string and operator in ["starts_with", "ends_with", "contains"]:
raise ValueError(
f"Operator '{operator}' is not supported for field '{field}' "
f"with type '{column_type}'. Use 'equal' or 'not_equal' instead."
)

# Apply the filter for regular types
if operator == "not_equal":
return query.filter(column != value)
elif operator == "starts_with":
return query.filter(column.ilike(f"{value}%"))
elif operator == "ends_with":
return query.filter(column.ilike(f"%{value}"))
elif operator == "contains":
return query.filter(column.ilike(f"%{value}%"))
else: # equal (default)
# For boolean, convert string to boolean
if "bool" in column_type:
bool_value = value.lower() in ["true", "1", "yes", "t"]
return query.filter(column == bool_value)
return query.filter(column == value)


def selftools_solr_query(
context: types.Context, data_dict: dict[str, Any]
) -> dict[str, Any] | Literal[False]:
Expand Down Expand Up @@ -115,10 +236,14 @@ def selftools_db_query(

q_model = data_dict.get("model")
limit = data_dict.get("limit")
field = data_dict.get("field")
value = data_dict.get("value")
order = data_dict.get("order")
order_by = data_dict.get("order_by")

# Get multiple WHERE conditions from field[], value[], and operator[] arrays
where_fields = request.form.getlist("field[]")
where_values = request.form.getlist("value[]")
where_operators = request.form.getlist("operator[]")

if q_model:
model_fields_blacklist = [
b.strip().split(".")
Expand Down Expand Up @@ -158,8 +283,20 @@ def _get_db_row_values(
model_class = curr_model[0]["model"]
q = model.Session.query(model_class)

if field and value:
q = q.filter(getattr(model_class, field) == value)
# Apply multiple WHERE conditions
if where_fields and where_values:
for i, (field, value) in enumerate(
zip(where_fields, where_values)
):
if field and value: # Skip empty conditions
operator = (
where_operators[i]
if i < len(where_operators)
else "equal"
)
q = apply_db_filter(
q, model_class, field, value, operator
)

if order_by and order:
if order == "desc":
Expand Down Expand Up @@ -188,11 +325,23 @@ def _get_db_row_values(
AttributeError,
sql_exceptions.CompileError,
sql_exceptions.ArgumentError,
ValueError,
) as e:
return {
"success": False,
"message": str(e),
}
except psycopg2.errors.InvalidTextRepresentation as e:
return {
"success": False,
"message": f"Invalid value for field type. {str(e).split('DETAIL:')[0].strip()}",
}
except Exception as e:
log.error("DB Query error: %s", repr(e))
return {
"success": False,
"message": f"Database error: {str(e)}",
}
return False


Expand Down
24 changes: 16 additions & 8 deletions ckanext/selftools/templates/selftools/tools/db/db_query.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,23 @@
<div>
{{ form.select('model', label=_('Model'), options=extras.model_options, error='', attrs={'class': 'select2', 'data-module': 'autocomplete'}) }}
</div>
<div class="row">
<div class="col-lg-6">
{{ form.input('field', label=_('Field'), error='') }}
</div>
<div class="col-lg-6">
{{ form.input('value', label=_('Value'), error='') }}
</div>
</div>

<fieldset class="border p-3 mb-3">
<legend class="w-auto">
<a class="btn selfinfo-button-main-color-bg text-white" data-bs-toggle="collapse" href="#db_q_where_collapse" role="button" aria-expanded="false" aria-controls="db_q_where_collapse">
WHERE Conditions
</a>
</legend>
<div class="collapse" id="db_q_where_collapse">
<div>
<button class="btn selfinfo-button-main-color-bg text-white" title="Add Condition" hx-post="{{ h.url_for('selftools_htmx.selftools_db_query_where_fields') }}" hx-trigger="click" hx-target="#db_q_where_collapse" hx-swap="beforeend">
{{ _('Add Condition') }}
</button>
</div>
<div class="where-conditions-map">
</div>
</div>
</fieldset>

<fieldset class="border p-3 mb-3">
<legend class="w-auto">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{% import 'macros/form.html' as form %}

<div class="row align-items-center where-condition-item mt-2">
<div class="col-lg-4">
{{ form.input('field[]', label=_('Field'), error='') }}
</div>
<div class="col-lg-3">
{{ form.select('operator[]', label=_('Operator'), options=[
{"value": "equal", "text": "Equal"},
{"value": "not_equal", "text": "Not Equal"},
{"value": "contains", "text": "Contains"},
{"value": "starts_with", "text": "Starts With"},
{"value": "ends_with", "text": "Ends With"}
], selected="equal", error='') }}
</div>
<div class="col-lg-4">
{{ form.input('value[]', label=_('Value'), error='') }}
</div>
<div class="col-lg-1">
<button class="btn btn-danger" type="button" onclick="this.closest('.where-condition-item').remove()"><i class="fas fa-minus"></i></button>
</div>
</div>
8 changes: 8 additions & 0 deletions ckanext/selftools/views/selftools_htmx.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ def selftools_solr_index() -> Any | str:
return _("Indexed.")


@selftools_htmx.route("/selftools/db-query-where-fields", methods=["POST"])
def selftools_db_query_where_fields() -> Any | str:
return tk.render(
"/selftools/tools/db/db_query_where_fields.html",
extra_vars={},
)


@selftools_htmx.route("/selftools/db-query", methods=["POST"])
def selftools_db_query() -> Any | str:
context: types.Context = cast(
Expand Down
Loading