diff --git a/.gitignore b/.gitignore index b1d8cacc..53598004 100644 --- a/.gitignore +++ b/.gitignore @@ -5,9 +5,10 @@ /dist *.egg-info .DS_Store +tmp /build *# *~ .coverage /htmlcov/ -*.orig \ No newline at end of file +*.orig diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..89d8ff78 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# Use an official Python runtime as a parent image +FROM python:3.5-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +# Set the working directory in the container +WORKDIR /app + +# Copy the requirements file into the container at /app +COPY requirements.txt /app/ +COPY optional-requirements.txt /app/ + +# Install any needed packages specified in requirements.txt +RUN pip install Django==1.11.17 +RUN pip install -r requirements.txt +RUN pip install -r optional-requirements.txt + +# Copy the entire Django project directory into the container at /app +COPY . /app/ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..53f98a9c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3' +services: + db: + image: postgres:13 + environment: + POSTGRES_DB: mydb + POSTGRES_USER: myuser + POSTGRES_PASSWORD: mypassword + + web: + build: . + command: python manage.py runserver 0.0.0.0:8000 + volumes: + - .:/app + ports: + - "8000:8000" + depends_on: + - db + environment: + DEBUG: "True" # Set to "False" in production + DJANGO_DB_HOST: db + DJANGO_DB_PORT: 5432 + DJANGO_DB_NAME: mydb + DJANGO_DB_USER: myuser + DJANGO_DB_PASSWORD: mypassword + links: + - db diff --git a/explorer/__init__.py b/explorer/__init__.py index fac665c0..ce120d90 100644 --- a/explorer/__init__.py +++ b/explorer/__init__.py @@ -1,7 +1,7 @@ __version_info__ = { 'major': 0, 'minor': 9, - 'micro': 2, + 'micro': 23, 'releaselevel': 'final', 'serial': 0 } diff --git a/explorer/admin.py b/explorer/admin.py index 3f4f66f5..bc661001 100644 --- a/explorer/admin.py +++ b/explorer/admin.py @@ -1,3 +1,4 @@ + from django.contrib import admin from explorer.models import Query from explorer.actions import generate_report_action @@ -7,7 +8,8 @@ class QueryAdmin(admin.ModelAdmin): list_display = ('title', 'description', 'created_by_user',) list_filter = ('title',) raw_id_fields = ('created_by_user',) - + actions = [generate_report_action()] + admin.site.register(Query, QueryAdmin) diff --git a/explorer/app_settings.py b/explorer/app_settings.py index 004bb058..ef64be67 100644 --- a/explorer/app_settings.py +++ b/explorer/app_settings.py @@ -2,28 +2,49 @@ # Required EXPLORER_CONNECTION_NAME = getattr(settings, 'EXPLORER_CONNECTION_NAME', None) +EXPLORER_CONNECTION_PII_NAME = getattr( + settings, 'EXPLORER_CONNECTION_PII_NAME', None) +EXPLORER_CONNECTION_ASYNC_API_DB_NAME = getattr( + settings, 'EXPLORER_CONNECTION_ASYNC_API_DB_NAME', None) +EXPLORER_MASTER_DB_CONNECTION_NAME = getattr(settings, 'EXPLORER_MASTER_DB_CONNECTION', None) # Change the behavior of explorer -EXPLORER_SQL_BLACKLIST = getattr(settings, 'EXPLORER_SQL_BLACKLIST', ('ALTER', 'RENAME ', 'DROP', 'TRUNCATE', 'INSERT INTO', 'UPDATE', 'REPLACE', 'DELETE', 'CREATE TABLE', 'SCHEMA', 'GRANT', 'OWNER TO')) -EXPLORER_SQL_WHITELIST = getattr(settings, 'EXPLORER_SQL_WHITELIST', ('CREATED', 'DELETED', 'REGEXP_REPLACE')) +EXPLORER_SQL_BLACKLIST = getattr(settings, 'EXPLORER_SQL_BLACKLIST', ('ALTER', 'RENAME ', 'DROP', 'TRUNCATE', + 'INSERT INTO', 'UPDATE', 'REPLACE', 'DELETE', 'CREATE TABLE', 'SCHEMA', 'GRANT', 'OWNER TO')) +EXPLORER_SQL_WHITELIST = getattr( + settings, 'EXPLORER_SQL_WHITELIST', ('CREATED', 'DELETED', 'REGEXP_REPLACE')) +TABLE_NAMES_FOR_PII_MASKING = getattr( + settings, 'TABLE_NAMES_FOR_PII_MASKING', None) EXPLORER_DEFAULT_ROWS = getattr(settings, 'EXPLORER_DEFAULT_ROWS', 1000) -EXPLORER_SCHEMA_EXCLUDE_APPS = getattr(settings, 'EXPLORER_SCHEMA_EXCLUDE_APPS', ('django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.admin')) +EXPLORER_SCHEMA_EXCLUDE_APPS = getattr(settings, 'EXPLORER_SCHEMA_EXCLUDE_APPS', ( + 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.admin')) EXPLORER_TRANSFORMS = getattr(settings, 'EXPLORER_TRANSFORMS', []) -EXPLORER_PERMISSION_VIEW = getattr(settings, 'EXPLORER_PERMISSION_VIEW', lambda u: u.is_staff) -EXPLORER_PERMISSION_CHANGE = getattr(settings, 'EXPLORER_PERMISSION_CHANGE', lambda u: u.is_staff) -EXPLORER_RECENT_QUERY_COUNT = getattr(settings, 'EXPLORER_RECENT_QUERY_COUNT', 10) +EXPLORER_PERMISSION_VIEW = getattr( + settings, 'EXPLORER_PERMISSION_VIEW', lambda u: u.is_staff) +EXPLORER_PERMISSION_CHANGE = getattr( + settings, 'EXPLORER_PERMISSION_CHANGE', lambda u: u.is_staff) +EXPLORER_RECENT_QUERY_COUNT = getattr( + settings, 'EXPLORER_RECENT_QUERY_COUNT', 10) CSV_DELIMETER = getattr(settings, "EXPLORER_CSV_DELIMETER", ",") # API access EXPLORER_TOKEN = getattr(settings, 'EXPLORER_TOKEN', 'CHANGEME') # These are callable to aid testability by dodging the settings cache. # There is surely a better pattern for this, but this'll hold for now. -EXPLORER_GET_USER_QUERY_VIEWS = lambda: getattr(settings, 'EXPLORER_USER_QUERY_VIEWS', {}) -EXPLORER_TOKEN_AUTH_ENABLED = lambda: getattr(settings, 'EXPLORER_TOKEN_AUTH_ENABLED', False) + + +def EXPLORER_GET_USER_QUERY_VIEWS(): return getattr( + settings, 'EXPLORER_USER_QUERY_VIEWS', {}) + + +def EXPLORER_TOKEN_AUTH_ENABLED(): return getattr( + settings, 'EXPLORER_TOKEN_AUTH_ENABLED', False) + # Async task related. Note that the EMAIL_HOST settings must be set up for email to work. ENABLE_TASKS = getattr(settings, "EXPLORER_TASKS_ENABLED", False) S3_ACCESS_KEY = getattr(settings, "EXPLORER_S3_ACCESS_KEY", None) S3_SECRET_KEY = getattr(settings, "EXPLORER_S3_SECRET_KEY", None) S3_BUCKET = getattr(settings, "EXPLORER_S3_BUCKET", None) -FROM_EMAIL = getattr(settings, 'EXPLORER_FROM_EMAIL', 'django-sql-explorer@example.com') \ No newline at end of file +FROM_EMAIL = getattr(settings, 'EXPLORER_FROM_EMAIL', + 'django-sql-explorer@example.com') diff --git a/explorer/constants.py b/explorer/constants.py new file mode 100644 index 00000000..0f246950 --- /dev/null +++ b/explorer/constants.py @@ -0,0 +1,15 @@ +PII_MASKING_PATTERN_REPLACEMENT_DICT = { + r"(?:\+?\d{1,3}|0)?([6-9]\d{9})\b": "XXXXXXXXXXX", # For phone number + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b": "XXX@XXX.com", # For email +} + +TYPE_CODE_FOR_JSON = 3802 +TYPE_CODE_FOR_TEXT = 25 +TYPE_CODE_FOR_CHAR = 1043 + +PLAYER_PHONE_NUMBER_MASKING_TYPE_CODES = [TYPE_CODE_FOR_CHAR] + +ALLOW_PHONE_NUMBER_MASKING_GROUP_ID = 10439 + +PATTERN_FOR_FINDING_PHONE_NUMBER = r"\+?\d{0,3}?([6-9]\d{9})(?:_\w+)?\b" +PATTERN_FOR_FINDING_EMAIL = r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b" diff --git a/explorer/forms.py b/explorer/forms.py index 302fd9d5..5a044a33 100644 --- a/explorer/forms.py +++ b/explorer/forms.py @@ -2,8 +2,12 @@ from django.forms.widgets import CheckboxInput from explorer.models import Query, MSG_FAILED_BLACKLIST from django.db import DatabaseError +import logging +import re +def _(x): return x -_ = lambda x: x + +logger = logging.getLogger(__name__) class SqlField(Field): @@ -14,20 +18,27 @@ def validate(self, value): :param value: The SQL for this Query model. """ - + super().validate(value) query = Query(sql=value) passes_blacklist, failing_words = query.passes_blacklist() - error = MSG_FAILED_BLACKLIST % ', '.join(failing_words) if not passes_blacklist else None + error = MSG_FAILED_BLACKLIST % ', '.join( + failing_words) if not passes_blacklist else None if not error and not query.available_params(): try: query.execute_query_only() except DatabaseError as e: - error = str(e) + + logger.info("error executing query: %s", e) + if (re.search("permission denied for table", str(e))): + error = None + else: + error = e if error: + raise ValidationError( _(error), code="InvalidSql" @@ -54,4 +65,4 @@ def created_by_user_id(self): class Meta: model = Query - fields = ['title', 'sql', 'description', 'created_by_user', 'snapshot'] \ No newline at end of file + fields = ['title', 'sql', 'description', 'created_by_user', 'snapshot'] diff --git a/explorer/migrations/0001_initial.py b/explorer/migrations/0001_initial.py index 38e78089..428a96f1 100644 --- a/explorer/migrations/0001_initial.py +++ b/explorer/migrations/0001_initial.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -from __future__ import unicode_literals from django.db import models, migrations import django.db.models.deletion @@ -14,34 +13,74 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Query', + name="Query", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('title', models.CharField(max_length=255)), - ('sql', models.TextField()), - ('description', models.TextField(null=True, blank=True)), - ('created_at', models.DateTimeField(auto_now_add=True)), - ('last_run_date', models.DateTimeField(auto_now=True)), - ('created_by_user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("title", models.CharField(max_length=255)), + ("sql", models.TextField()), + ("description", models.TextField(null=True, blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("last_run_date", models.DateTimeField(auto_now=True)), + ( + "created_by_user", + models.ForeignKey( + blank=True, + to=settings.AUTH_USER_MODEL, + null=True, + on_delete=django.db.models.deletion.CASCADE, + ), + ), ], options={ - 'ordering': ['title'], - 'verbose_name_plural': 'Queries', + "ordering": ["title"], + "verbose_name_plural": "Queries", }, bases=(models.Model,), ), migrations.CreateModel( - name='QueryLog', + name="QueryLog", fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('sql', models.TextField()), - ('is_playground', models.BooleanField(default=False)), - ('run_at', models.DateTimeField(auto_now_add=True)), - ('query', models.ForeignKey(on_delete=django.db.models.deletion.SET_NULL, blank=True, to='explorer.Query', null=True)), - ('run_by_user', models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ( + "id", + models.AutoField( + verbose_name="ID", + serialize=False, + auto_created=True, + primary_key=True, + ), + ), + ("sql", models.TextField()), + ("is_playground", models.BooleanField(default=False)), + ("run_at", models.DateTimeField(auto_now_add=True)), + ( + "query", + models.ForeignKey( + on_delete=django.db.models.deletion.SET_NULL, + blank=True, + to="explorer.Query", + null=True, + ), + ), + ( + "run_by_user", + models.ForeignKey( + blank=True, + to=settings.AUTH_USER_MODEL, + null=True, + on_delete=django.db.models.deletion.CASCADE, + ), + ), ], options={ - 'ordering': ['-run_at'], + "ordering": ["-run_at"], }, bases=(models.Model,), ), diff --git a/explorer/migrations/0002_auto_20150501_1515.py b/explorer/migrations/0002_auto_20150501_1515.py index 1bd48cb9..68eb9b43 100644 --- a/explorer/migrations/0002_auto_20150501_1515.py +++ b/explorer/migrations/0002_auto_20150501_1515.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -from __future__ import unicode_literals from django.db import models, migrations diff --git a/explorer/migrations/0003_query_snapshot.py b/explorer/migrations/0003_query_snapshot.py index 02afa491..9daa0723 100644 --- a/explorer/migrations/0003_query_snapshot.py +++ b/explorer/migrations/0003_query_snapshot.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -from __future__ import unicode_literals from django.db import models, migrations diff --git a/explorer/migrations/0004_querylog_duration.py b/explorer/migrations/0004_querylog_duration.py index 10daa311..214482d3 100644 --- a/explorer/migrations/0004_querylog_duration.py +++ b/explorer/migrations/0004_querylog_duration.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -from __future__ import unicode_literals from django.db import migrations, models diff --git a/explorer/migrations/0005_querychangelog.py b/explorer/migrations/0005_querychangelog.py new file mode 100644 index 00000000..e93fa323 --- /dev/null +++ b/explorer/migrations/0005_querychangelog.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9 on 2022-03-25 05:46 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('explorer', '0004_querylog_duration'), + ] + + operations = [ + migrations.CreateModel( + name='QueryChangeLog', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('old_sql', models.TextField(blank=True, null=True)), + ('new_sql', models.TextField(blank=True, null=True)), + ('run_at', models.DateTimeField(auto_now_add=True)), + ('query', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='explorer.Query')), + ('run_by_user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-run_at'], + }, + ), + ] diff --git a/explorer/migrations/0006_auto_20230207_0103.py b/explorer/migrations/0006_auto_20230207_0103.py new file mode 100644 index 00000000..42149f42 --- /dev/null +++ b/explorer/migrations/0006_auto_20230207_0103.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('explorer', '0005_querychangelog'), + ] + + operations = [ + migrations.AlterField( + model_name='query', + name='snapshot', + field=models.BooleanField(default=False, help_text='Include in snapshot task (if enabled)'), + ), + ] diff --git a/explorer/models.py b/explorer/models.py index 93f38330..7362e595 100644 --- a/explorer/models.py +++ b/explorer/models.py @@ -1,12 +1,37 @@ -from explorer.utils import passes_blacklist, swap_params, extract_params, shared_dict_update, get_connection, get_s3_connection +from explorer.utils import ( + passes_blacklist, + swap_params, + extract_params, + shared_dict_update, + get_connection, + get_s3_connection, + get_connection_pii, + get_explorer_master_db_connection, + get_connection_asyncapi_db, + should_route_to_asyncapi_db, + mask_string, + is_pii_masked_for_user, + mask_player_pii, +) from django.db import models, DatabaseError from time import time -from django.core.urlresolvers import reverse +from django.urls import reverse from django.conf import settings +from django.contrib import messages +from django.contrib.messages import constants as messages_constants from . import app_settings import logging +import re +import json import six +from explorer.constants import ( + TYPE_CODE_FOR_JSON, + TYPE_CODE_FOR_TEXT, + PLAYER_PHONE_NUMBER_MASKING_TYPE_CODES, + TYPE_CODE_FOR_CHAR, +) + MSG_FAILED_BLACKLIST = "Query failed the SQL blacklist: %s" @@ -17,28 +42,32 @@ class Query(models.Model): title = models.CharField(max_length=255) sql = models.TextField() description = models.TextField(null=True, blank=True) - created_by_user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True) + created_by_user = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.CASCADE + ) created_at = models.DateTimeField(auto_now_add=True) last_run_date = models.DateTimeField(auto_now=True) - snapshot = models.BooleanField(default=False, help_text="Include in snapshot task (if enabled)") + snapshot = models.BooleanField( + default=False, help_text="Include in snapshot task (if enabled)" + ) def __init__(self, *args, **kwargs): - self.params = kwargs.get('params') - kwargs.pop('params', None) + self.params = kwargs.get("params") + kwargs.pop("params", None) super(Query, self).__init__(*args, **kwargs) class Meta: - ordering = ['title'] - verbose_name_plural = 'Queries' + ordering = ["title"] + verbose_name_plural = "Queries" - def __unicode__(self): - return six.text_type(self.title) + def __str__(self): + return self.title def get_run_count(self): return self.querylog_set.count() def avg_duration(self): - return self.querylog_set.aggregate(models.Avg('duration'))['duration__avg'] + return self.querylog_set.aggregate(models.Avg("duration"))["duration__avg"] def passes_blacklist(self): return passes_blacklist(self.final_sql()) @@ -46,18 +75,41 @@ def passes_blacklist(self): def final_sql(self): return swap_params(self.sql, self.available_params()) - def execute_query_only(self): - return QueryResult(self.final_sql()) + def execute_query_only( + self, + is_connection_type_pii=None, + executing_user=None, + is_connection_for_explorer_master_db=False, + ): + return QueryResult( + self.final_sql(), + self.title, + is_connection_type_pii, + executing_user if executing_user else self.created_by_user, + is_connection_for_explorer_master_db, + ) def execute_with_logging(self, executing_user): ql = self.log(executing_user) - ret = self.execute() + ret = self.execute(executing_user) ql.duration = ret.duration ql.save() return ret, ql - def execute(self): - ret = self.execute_query_only() + def execute(self, executing_user=None): + ret = self.execute_query_only(False, executing_user) + ret.process() + return ret + + def execute_pii(self, executing_user=None): + ret = self.execute_query_only(True, executing_user) + ret.process() + return ret + + def execute_on_explorer_with_master_db(self, executing_user=None): + ret = self.execute_query_only( + False, executing_user, is_connection_for_explorer_master_db=True + ) ret.process() return ret @@ -75,32 +127,44 @@ def available_params(self): return p def get_absolute_url(self): - return reverse("query_detail", kwargs={'query_id': self.id}) + return reverse("query_detail", kwargs={"query_id": self.id}) def log(self, user=None): - if user and user.is_anonymous(): - user = None - ql = QueryLog(sql=self.final_sql(), query_id=self.id, run_by_user=user) + + if user: + # In Django<1.10, is_anonymous was a method. + if user.is_anonymous: + user = None + ql = QueryLog( + sql=self.final_sql(), + query_id=self.id, + run_by_user=user, + ) ql.save() return ql + @property def shared(self): - return self.id in set(sum(app_settings.EXPLORER_GET_USER_QUERY_VIEWS().values(), [])) + return self.id in set( + sum(app_settings.EXPLORER_GET_USER_QUERY_VIEWS().values(), []) + ) @property def snapshots(self): if app_settings.ENABLE_TASKS: conn = get_s3_connection() - res = conn.list('query-%s.snap-' % self.id) - return sorted(res, key=lambda s: s['last_modified']) + res = conn.list("query-%s.snap-" % self.id) + return sorted(res, key=lambda s: s["last_modified"]) class QueryLog(models.Model): sql = models.TextField(null=True, blank=True) query = models.ForeignKey(Query, null=True, blank=True, on_delete=models.SET_NULL) - run_by_user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True) + run_by_user = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.CASCADE + ) run_at = models.DateTimeField(auto_now_add=True) duration = models.FloatField(blank=True, null=True) # milliseconds @@ -109,21 +173,157 @@ def is_playground(self): return self.query_id is None class Meta: - ordering = ['-run_at'] + ordering = ["-run_at"] -class QueryResult(object): +class QueryChangeLog(models.Model): + + old_sql = models.TextField(null=True, blank=True) + new_sql = models.TextField(null=True, blank=True) + query = models.ForeignKey(Query, null=True, blank=True, on_delete=models.SET_NULL) + run_by_user = models.ForeignKey( + settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.CASCADE + ) + run_at = models.DateTimeField(auto_now_add=True) - def __init__(self, sql): + @property + def is_playground(self): + return self.query_id is None - self.sql = sql + class Meta: + ordering = ["-run_at"] + + +class QueryResult(object): + def get_type_code_and_column_indices_to_be_masked_dict(self): + """ + Returns a dictionary of type code and column indices to be masked + Return type: + { + type_code: [column indices that match the type code] + } + Eg. + Say a table has three fields id, data, random_text. id is of type INT, data is of type JSON, and random_text is of type TEXT. + Then the return value will be: + { + TYPE_CODE_FOR_JSON: [1], + TYPE_CODE_FOR_TEXT: [2] + } + as 1 is the column index for JSON and 2 is the column index for TEXT + """ + type_code_and_column_indices_to_be_masked_dict = { + TYPE_CODE_FOR_JSON: [], + TYPE_CODE_FOR_TEXT: [], + } + phone_number_masking_indexes = [] + + # Collect the indices for JSON and text columns + for index, column in enumerate(self._description): + if ( + hasattr(column, "type_code") + and column.type_code in type_code_and_column_indices_to_be_masked_dict + ): + type_code_and_column_indices_to_be_masked_dict[column.type_code].append( + index + ) + + # Masking for player phone numbers + if ( + self.used_by_user + and is_pii_masked_for_user(self.used_by_user) + and hasattr(column, "type_code") + and column.type_code in PLAYER_PHONE_NUMBER_MASKING_TYPE_CODES + ): + phone_number_masking_indexes.append(index) + + # Masking for PII data in char fields if specific tables are used in SQL + if app_settings.TABLE_NAMES_FOR_PII_MASKING and phone_number_masking_indexes: + for table_name in app_settings.TABLE_NAMES_FOR_PII_MASKING: + if table_name in self.sql: + type_code_and_column_indices_to_be_masked_dict[ + TYPE_CODE_FOR_CHAR + ] = phone_number_masking_indexes + break + + return type_code_and_column_indices_to_be_masked_dict + + def get_masked_data(self, data, type_code): + """ + Mask the data based on the type code. + """ + if not data: + return data + if type_code == TYPE_CODE_FOR_JSON: + return json.dumps(mask_string(str(data))) + elif type_code == TYPE_CODE_FOR_TEXT: + return mask_string(data) + elif type_code in PLAYER_PHONE_NUMBER_MASKING_TYPE_CODES: + return mask_player_pii(data) + return data + + def mask_pii_data(self, row, type_code_and_column_indices_to_be_masked_dict): + """ + Mask the JSON and TEXT data types in the row. + """ + modified_row = list(row) + for ( + type_code, + indices, + ) in type_code_and_column_indices_to_be_masked_dict.items(): + for index in indices: + modified_row[index] = self.get_masked_data( + modified_row[index], type_code + ) + + return modified_row + + def get_data_to_be_displayed(self, cursor): + """ + If the connection type allows PII, then return the data as is. + If connection type does not allow PII, then mask JSON and TEXT data types and then return the data. + JSON and TEXT data types can be identified by the type_code attribute of the column. + """ + if self.is_connection_type_pii: + return [list(r) for r in cursor.fetchall()] + + type_code_and_column_indices_to_be_masked_dict = ( + self.get_type_code_and_column_indices_to_be_masked_dict() + ) + data_to_be_displayed = [] + + for row in cursor.fetchall(): + modified_row = self.mask_pii_data( + row, type_code_and_column_indices_to_be_masked_dict + ) + data_to_be_displayed.append(modified_row) + + return data_to_be_displayed + + def __init__( + self, + sql, + title=None, + is_connection_type_pii=None, + used_by_user=None, + is_connection_for_explorer_master_db=False, + ): + self.sql = sql + self.title = title + self.is_connection_for_explorer_master_db = is_connection_for_explorer_master_db + if is_connection_type_pii: + self.is_connection_type_pii = is_connection_type_pii + else: + self.is_connection_type_pii = False + + self.used_by_user = used_by_user cursor, duration = self.execute_query() self._description = cursor.description or [] - self._data = [list(r) for r in cursor.fetchall()] - self.duration = duration + self._data = self.get_data_to_be_displayed(cursor) + + self.duration = duration cursor.close() self._headers = self._get_headers() @@ -138,20 +338,38 @@ def headers(self): return self._headers or [] def _get_headers(self): - return [ColumnHeader(d[0]) for d in self._description] if self._description else [ColumnHeader('--')] + return ( + [ColumnHeader(d[0]) for d in self._description] + if self._description + else [ColumnHeader("--")] + ) def _get_numerics(self): conn = get_connection() if hasattr(conn.Database, "NUMBER"): - return [ix for ix, c in enumerate(self._description) if hasattr(c, 'type_code') and c.type_code in conn.Database.NUMBER.values] + return [ + ix + for ix, c in enumerate(self._description) + if hasattr(c, "type_code") + and c.type_code in conn.Database.NUMBER.values + ] elif self.data: d = self.data[0] - return [ix for ix, _ in enumerate(self._description) if not isinstance(d[ix], six.string_types) and six.text_type(d[ix]).isnumeric()] + return [ + ix + for ix, _ in enumerate(self._description) + if not isinstance(d[ix], six.string_types) + and six.text_type(d[ix]).isnumeric() + ] return [] def _get_transforms(self): transforms = dict(app_settings.EXPLORER_TRANSFORMS) - return [(ix, transforms[str(h)]) for ix, h in enumerate(self.headers) if str(h) in transforms.keys()] + return [ + (ix, transforms[str(h)]) + for ix, h in enumerate(self.headers) + if str(h) in transforms.keys() + ] def column(self, ix): return [r[ix] for r in self.data] @@ -162,7 +380,9 @@ def process(self): self.process_columns() self.process_rows() - logger.info("Explorer Query Processing took %sms." % ((time() - start_time) * 1000)) + logger.info( + "Explorer test Query Processing took %sms." % ((time() - start_time) * 1000) + ) def process_columns(self): for ix in self._get_numerics(): @@ -176,7 +396,19 @@ def process_rows(self): r[ix] = t.format(str(r[ix])) def execute_query(self): - conn = get_connection() + # can change connectiion type here to use different role --> get_connection_pii() + if self.is_connection_type_pii: + logger.info("pii-connection") + conn = get_connection_pii() + elif should_route_to_asyncapi_db(self.sql): + logger.info("Route to Async API DB") + conn = get_connection_asyncapi_db() + elif self.is_connection_for_explorer_master_db: + conn = get_explorer_master_db_connection() + else: + logger.info("non-pii-connection") + conn = get_connection() + cursor = conn.cursor() start_time = time() @@ -184,7 +416,16 @@ def execute_query(self): cursor.execute(self.sql) except DatabaseError as e: cursor.close() - raise e + if ( + re.search("permission denied for table", str(e)) + and self.title != "Playground" + ): + + raise DatabaseError( + "Query saved but unable to execute it because " + str(e) + ) + else: + raise e return cursor, ((time() - start_time) * 1000) @@ -214,7 +455,9 @@ def __init__(self, label, statfn, precision=2, handles_null=False): self.handles_null = handles_null def __call__(self, coldata): - self.value = round(float(self.statfn(coldata)), self.precision) if coldata else 0 + self.value = ( + round(float(self.statfn(coldata)), self.precision) if coldata else 0 + ) def __unicode__(self): return self.label @@ -232,7 +475,12 @@ def __init__(self, header, col): ColumnStat("Avg", lambda x: float(sum(x)) / float(len(x))), ColumnStat("Min", min), ColumnStat("Max", max), - ColumnStat("NUL", lambda x: int(sum(map(lambda y: 1 if y is None else 0, x))), 0, True) + ColumnStat( + "NUL", + lambda x: int(sum(map(lambda y: 1 if y is None else 0, x))), + 0, + True, + ), ] without_nulls = list(map(lambda x: 0 if x is None else x, col)) diff --git a/explorer/static/explorer/explorer.js b/explorer/static/explorer/explorer.js index 2bb654e1..cf347440 100644 --- a/explorer/static/explorer/explorer.js +++ b/explorer/static/explorer/explorer.js @@ -1,253 +1,315 @@ -var csrf_token = $.cookie('csrftoken'); +var csrf_token = $.cookie("csrftoken"); $.ajaxSetup({ - beforeSend: function(xhr) { - xhr.setRequestHeader("X-CSRFToken", csrf_token); - } + beforeSend: function (xhr) { + xhr.setRequestHeader("X-CSRFToken", csrf_token); + }, }); function ExplorerEditor(queryId, dataUrl) { - this.queryId = queryId; - this.dataUrl = dataUrl; - this.$table = $('#preview'); - this.$rows = $('#rows'); - this.$form = $("form"); - this.$snapshotField = $("#id_snapshot"); - this.$paramFields = this.$form.find(".param"); - - this.$submit = $("#refresh_play_button, #save_button"); - if (!this.$submit.length) { this.$submit = $("#refresh_button"); } - - this.editor = CodeMirror.fromTextArea(document.getElementById('id_sql'), { - mode: "text/x-sql", - lineNumbers: 't', - autofocus: true, - height: 500, - extraKeys: { - "Ctrl-Enter": function() { this.doCodeMirrorSubmit(); }.bind(this), - "Cmd-Enter": function() { this.doCodeMirrorSubmit(); }.bind(this), - "Cmd-/": function() { this.editor.toggleComment(); }.bind(this) - } - }); - this.editor.on("change", function(cm, change) { - document.getElementById('id_sql').classList.add('changed-input'); - }); - this.bind(); + this.queryId = queryId; + this.dataUrl = dataUrl; + this.$table = $("#preview"); + this.$rows = $("#rows"); + this.$form = $("form"); + this.$snapshotField = $("#id_snapshot"); + this.$paramFields = this.$form.find(".param"); + + this.$submit = $("#refresh_play_button, #save_button"); + + if (!this.$submit.length) { + this.$submit = $("#refresh_button"); + } + + this.editor = CodeMirror.fromTextArea(document.getElementById("id_sql"), { + mode: "text/x-sql", + lineNumbers: "t", + autofocus: true, + height: 500, + extraKeys: { + "Ctrl-Enter": function () { + this.doCodeMirrorSubmit(); + }.bind(this), + "Cmd-Enter": function () { + this.doCodeMirrorSubmit(); + }.bind(this), + "Cmd-/": function () { + this.editor.toggleComment(); + }.bind(this), + }, + }); + this.editor.on("change", function (cm, change) { + document.getElementById("id_sql").classList.add("changed-input"); + }); + this.bind(); } -ExplorerEditor.prototype.getParams = function() { - var o = false; - if(this.$paramFields.length) { - o = {}; - this.$paramFields.each(function() { - o[$(this).data('param')] = $(this).val(); - }); - } - return o; +ExplorerEditor.prototype.getParams = function () { + var o = false; + if (this.$paramFields.length) { + o = {}; + this.$paramFields.each(function () { + o[$(this).data("param")] = $(this).val(); + }); + } + return o; }; -ExplorerEditor.prototype.serializeParams = function(params) { - var args = []; - for(var key in params) { - args.push(key + '%3A' + params[key]); - } - return args.join('%7C'); +ExplorerEditor.prototype.serializeParams = function (params) { + var args = []; + for (var key in params) { + args.push(key + "%3A" + params[key]); + } + return args.join("%7C"); }; -ExplorerEditor.prototype.doCodeMirrorSubmit = function() { - // Captures the cmd+enter keystroke and figures out which button to trigger. - this.$submit.click(); +ExplorerEditor.prototype.doCodeMirrorSubmit = function () { + // Captures the cmd+enter keystroke and figures out which button to trigger. + this.$submit.click(); }; -ExplorerEditor.prototype.savePivotState = function(state) { - bmark = btoa(JSON.stringify(_(state).pick('aggregatorName', 'rows', 'cols', 'rendererName', 'vals'))); - $el = $('#pivot-bookmark') - $el.attr('href', $el.data('baseurl') + '#' + bmark) +ExplorerEditor.prototype.savePivotState = function (state) { + bmark = btoa( + JSON.stringify( + _(state).pick("aggregatorName", "rows", "cols", "rendererName", "vals") + ) + ); + $el = $("#pivot-bookmark"); + $el.attr("href", $el.data("baseurl") + "#" + bmark); }; -ExplorerEditor.prototype.updateQueryString = function(key, value, url) { - // http://stackoverflow.com/a/11654596/221390 - if (!url) url = window.location.href; - var re = new RegExp("([?&])" + key + "=.*?(&|#|$)(.*)", "gi"); - - if (re.test(url)) { - if (typeof value !== 'undefined' && value !== null) - return url.replace(re, '$1' + key + "=" + value + '$2$3'); - else { - var hash = url.split('#'); - url = hash[0].replace(re, '$1$3').replace(/(&|\?)$/, ''); - if (typeof hash[1] !== 'undefined' && hash[1] !== null) - url += '#' + hash[1]; - return url; - } - } - else { - if (typeof value !== 'undefined' && value !== null) { - var separator = url.indexOf('?') !== -1 ? '&' : '?', - hash = url.split('#'); - url = hash[0] + separator + key + '=' + value; - if (typeof hash[1] !== 'undefined' && hash[1] !== null) - url += '#' + hash[1]; - return url; - } - else - return url; - } +ExplorerEditor.prototype.updateQueryString = function (key, value, url) { + // http://stackoverflow.com/a/11654596/221390 + if (!url) url = window.location.href; + var re = new RegExp("([?&])" + key + "=.*?(&|#|$)(.*)", "gi"); + + if (re.test(url)) { + if (typeof value !== "undefined" && value !== null) + return url.replace(re, "$1" + key + "=" + value + "$2$3"); + else { + var hash = url.split("#"); + url = hash[0].replace(re, "$1$3").replace(/(&|\?)$/, ""); + if (typeof hash[1] !== "undefined" && hash[1] !== null) + url += "#" + hash[1]; + return url; + } + } else { + if (typeof value !== "undefined" && value !== null) { + var separator = url.indexOf("?") !== -1 ? "&" : "?", + hash = url.split("#"); + url = hash[0] + separator + key + "=" + value; + if (typeof hash[1] !== "undefined" && hash[1] !== null) + url += "#" + hash[1]; + return url; + } else return url; + } }; -ExplorerEditor.prototype.formatSql = function() { - $.post('../format/', {sql: this.editor.getValue() }, function(data) { - this.editor.setValue(data.formatted); - }.bind(this)); +ExplorerEditor.prototype.formatSql = function () { + $.post( + "../format/", + { sql: this.editor.getValue() }, + function (data) { + this.editor.setValue(data.formatted); + }.bind(this) + ); }; -ExplorerEditor.prototype.showRows = function() { - var rows = this.$rows.val(), - $form = $("#editor"); - $form.attr('action', this.updateQueryString("rows", rows, window.location.href)); - $form.submit(); +ExplorerEditor.prototype.showRows = function () { + var rows = this.$rows.val(), + $form = $("#editor"); + $form.attr( + "action", + this.updateQueryString("rows", rows, window.location.href) + ); + $form.submit(); }; -ExplorerEditor.prototype.bind = function() { - $("#show_schema_button").click(function() { - $("#schema_frame").attr('src', '../schema/'); - $("#query_area").addClass("col-md-9"); - var schema$ = $("#schema"); - schema$.addClass("col-md-3"); - schema$.show(); - $(this).hide(); - $("#hide_schema_button").show(); - return false; - }); - - $("#hide_schema_button").click(function() { - $("#query_area").removeClass("col-md-9"); - var schema$ = $("#schema"); - schema$.removeClass("col-md-3"); - schema$.hide(); - $(this).hide(); - $("#show_schema_button").show(); - return false; - }); - - $("#format_button").click(function(e) { - e.preventDefault(); - this.formatSql(); - }.bind(this)); - - $("#save_button").click(function() { - var params = this.getParams(this); - if(params) { - this.$form.attr('action', '../' + this.queryId + '/?params=' + this.serializeParams(params)); - } - this.$snapshotField.hide(); - this.$form.append(this.$snapshotField); - }.bind(this)); - - $("#refresh_button").click(function(e) { - e.preventDefault(); - var params = this.getParams(); - if(params) { - window.location.href = '../' + this.queryId + '/?params=' + this.serializeParams(params); - } else { - window.location.href = '../' + this.queryId + '/'; - } - }.bind(this)); - - $("#refresh_play_button").click(function() { - this.$form.attr('action', '../play/'); - }.bind(this)); - - $("#playground_button").click(function() { - this.$form.prepend(""); - this.$form.attr('action', '../play/'); - }.bind(this)); - - $("#download_play_button").click(function() { - this.$form.attr('action', '../csv'); - }.bind(this)); - - $(".download_button").click(function(e) { - e.preventDefault(); - var dl_link = 'download'; - var params = this.getParams(this); - if(params) { dl_link = dl_link + '?params=' + this.serializeParams(params); } - window.open(dl_link, '_blank'); - }.bind(this)); - - $("#create_button").click(function() { - this.$form.attr('action', '../new/'); - }.bind(this)); - - $(".stats-expand").click(function(e) { - e.preventDefault(); - $(".stats-expand").hide(); - $(".stats-wrapper").show(); - this.$table.floatThead('reflow'); - }.bind(this)); - - $(".sort").click(function(e) { - var t = $(e.target).data('sort'); - var dir = $(e.target).data('dir'); - $('.sort').css('background-image', 'url(http://cdn.datatables.net/1.10.0/images/sort_both.png)') - if (dir == 'asc'){ - $(e.target).data('dir', 'desc'); - $(e.target).css('background-image', 'url(http://cdn.datatables.net/1.10.0/images/sort_asc.png)') - } else { - $(e.target).data('dir', 'asc'); - $(e.target).css('background-image', 'url(http://cdn.datatables.net/1.10.0/images/sort_desc.png)') - } - var vals = []; - var ct = 0; - while (ct < this.$table.find('th').length) { - vals.push(ct++); - } - var options = { - valueNames: vals - }; - var tableList = new List('preview', options); - tableList.sort(t, { order: dir }); - }.bind(this)); - - $("#preview-tab-label").click(function() { - this.$table.floatThead('reflow'); - }.bind(this)); - - var pivotState = window.location.hash; - var navToPivot = false; - if (!pivotState) { - pivotState = {onRefresh: this.savePivotState}; - } else { - pivotState = JSON.parse(atob(pivotState.substr(1))); - pivotState['onRefresh'] = this.savePivotState; - navToPivot = true; - } - - $(".pivot-table").pivotUI(this.$table, pivotState); - if (navToPivot) { - $("#pivot-tab-label").tab('show'); - } - - this.$table.floatThead({ - scrollContainer: function() { - return this.$table.closest('.overflow-wrapper'); - }.bind(this) - }); - - this.$rows.change(function() { this.showRows(); }.bind(this)); - this.$rows.keyup(function(event) { - if(event.keyCode == 13){ this.showRows(); } - }.bind(this)); +ExplorerEditor.prototype.bind = function () { + $("#show_schema_button").click(function () { + $("#schema_frame").attr("src", "../schema/"); + $("#query_area").addClass("col-md-9"); + var schema$ = $("#schema"); + schema$.addClass("col-md-3"); + schema$.show(); + $(this).hide(); + $("#hide_schema_button").show(); + return false; + }); + + $("#hide_schema_button").click(function () { + $("#query_area").removeClass("col-md-9"); + var schema$ = $("#schema"); + schema$.removeClass("col-md-3"); + schema$.hide(); + $(this).hide(); + $("#show_schema_button").show(); + return false; + }); + + $("#format_button").click( + function (e) { + e.preventDefault(); + this.formatSql(); + }.bind(this) + ); + + $("#save_button").click( + function () { + var params = this.getParams(this); + if (params) { + this.$form.attr( + "action", + "../" + this.queryId + "/?params=" + this.serializeParams(params) + ); + } + this.$snapshotField.hide(); + this.$form.append(this.$snapshotField); + }.bind(this) + ); + + $("#refresh_button").click( + function (e) { + e.preventDefault(); + var params = this.getParams(); + if (params) { + window.location.href = + "../" + this.queryId + "/?params=" + this.serializeParams(params); + } else { + window.location.href = "../" + this.queryId + "/"; + } + }.bind(this) + ); + + $("#refresh_play_button").click( + function () { + this.$form.attr("action", "../play/"); + }.bind(this) + ); + + $("#playground_button").click( + function () { + this.$form.prepend(""); + this.$form.attr("action", "../play/"); + }.bind(this) + ); + + $("#download_play_button").click( + function () { + this.$form.attr("action", "../csv"); + }.bind(this) + ); + + $(".download_button").click( + function (e) { + e.preventDefault(); + var dl_link = "download"; + var params = this.getParams(this); + if (params) { + dl_link = dl_link + "?params=" + this.serializeParams(params); + } + window.open(dl_link, "_blank"); + }.bind(this) + ); + + $("#create_button").click( + function () { + this.$form.attr("action", "../new/"); + }.bind(this) + ); + + $(".stats-expand").click( + function (e) { + e.preventDefault(); + $(".stats-expand").hide(); + $(".stats-wrapper").show(); + this.$table.floatThead("reflow"); + }.bind(this) + ); + + $(".sort").click( + function (e) { + var t = $(e.target).data("sort"); + var dir = $(e.target).data("dir"); + $(".sort").css( + "background-image", + "url(http://cdn.datatables.net/1.10.0/images/sort_both.png)" + ); + if (dir == "asc") { + $(e.target).data("dir", "desc"); + $(e.target).css( + "background-image", + "url(http://cdn.datatables.net/1.10.0/images/sort_asc.png)" + ); + } else { + $(e.target).data("dir", "asc"); + $(e.target).css( + "background-image", + "url(http://cdn.datatables.net/1.10.0/images/sort_desc.png)" + ); + } + var vals = []; + var ct = 0; + while (ct < this.$table.find("th").length) { + vals.push(ct++); + } + var options = { + valueNames: vals, + }; + var tableList = new List("preview", options); + tableList.sort(t, { order: dir }); + }.bind(this) + ); + + $("#preview-tab-label").click( + function () { + this.$table.floatThead("reflow"); + }.bind(this) + ); + + var pivotState = window.location.hash; + var navToPivot = false; + if (!pivotState) { + pivotState = { onRefresh: this.savePivotState }; + } else { + pivotState = JSON.parse(atob(pivotState.substr(1))); + pivotState["onRefresh"] = this.savePivotState; + navToPivot = true; + } + + $(".pivot-table").pivotUI(this.$table, pivotState); + if (navToPivot) { + $("#pivot-tab-label").tab("show"); + } + + this.$table.floatThead({ + scrollContainer: function () { + return this.$table.closest(".overflow-wrapper"); + }.bind(this), + }); + + this.$rows.change( + function () { + this.showRows(); + }.bind(this) + ); + this.$rows.keyup( + function (event) { + if (event.keyCode == 13) { + this.showRows(); + } + }.bind(this) + ); }; -$(window).on('beforeunload', function () { - // Only do this if changed-input is on the page and we're not on the playground page. - if ($('.changed-input').length && !$('.playground-form').length) { - return 'You have unsaved changes to your query.'; - } +$(window).on("beforeunload", function () { + // Only do this if changed-input is on the page and we're not on the playground page. + if ($(".changed-input").length && !$(".playground-form").length) { + return "You have unsaved changes to your query."; + } }); // Disable unsaved changes warning when submitting the editor form -$(document).on("submit", "#editor", function(event){ - // disable warning - $(window).off('beforeunload'); +$(document).on("submit", "#editor", function (event) { + // disable warning + $(window).off("beforeunload"); }); diff --git a/explorer/templates/explorer/base.html b/explorer/templates/explorer/base.html index 392d0a85..8cd31b1d 100644 --- a/explorer/templates/explorer/base.html +++ b/explorer/templates/explorer/base.html @@ -22,6 +22,16 @@ dataUrl = "{{ dataUrl }}"; queryId = "{% firstof query.id 'new' %}"; + diff --git a/explorer/templates/explorer/play.html b/explorer/templates/explorer/play.html index 4e0e3e49..bc65a014 100644 --- a/explorer/templates/explorer/play.html +++ b/explorer/templates/explorer/play.html @@ -5,6 +5,7 @@
  • New Query
  • Playground
  • Logs
  • +
  • Change Logs
  • {% endif %} {% endblock %} @@ -12,23 +13,45 @@

    Playground

    -

    The playground is for experimenting and writing ad-hoc queries. By default, nothing you do here will be saved.

    -
    {% csrf_token %} +

    The playground is for experimenting and writing ad-hoc queries. By default, nothing you do here will be + saved.

    + {% if lag_exists %} +
    Warning! The query results might be outdated since some data was last updated + around {{ replication_lag }}.
    + {% endif %} +
    +

    + Note: Starting April 15th, access to the phone_number column has been removed for the + following + tables: voice_workflow_campaigncallcontact, voice_core_callcontact, compliance_outreachblacklist, + whatsapp2_whatsapp2inboundmessage, whatsapp2_whatsapp2outboundmessage and + voice_compliance_businessoutreach. Please avoid using `select *` for these tables. +

    +
    +

    + Additional Note: The request logs are now stored in two separate tables: + request_log_requestlog and request_log_requestlogdata. The request data, response data and request headers + are stored in pii_masked_request_data, pii_masked_response_data and pii_masked_request_headers columns respectively in the + request_log_requestlogdata table. Please avoid using `select *` for these tables. +

    + + {% csrf_token %} {% if error %} -
    {{ error|escape }}
    +
    {{ error|escape }}
    {% endif %}
    -
    -
    - Playground SQL -
    - {% if ql_id %} +
    +
    + Playground SQL +
    + {% if ql_id %}
    - +
    - {% endif %} -
    + {% endif %} +
    @@ -37,11 +60,12 @@

    Playground

    - - - + + + - +
    @@ -58,11 +82,9 @@

    Playground

    {% endblock %} {% block sql_explorer_scripts %} - + {% endblock %} - - diff --git a/explorer/templates/explorer/query.html b/explorer/templates/explorer/query.html index 7ffcd3f2..6a4be8cd 100644 --- a/explorer/templates/explorer/query.html +++ b/explorer/templates/explorer/query.html @@ -7,6 +7,7 @@ {% endif %} {% if query %}
  • Query Detail
  • {% endif %}
  • Logs
  • +
  • Change Logs
  • {% endblock %} {% block sql_explorer_content %} @@ -17,6 +18,9 @@

    {% if query %}{{ query.title }}{% if shared %}  shared{{ message }}

    {% endif %} + {% if lag_exists %} +
    Warning! The query results might be outdated since some data was last updated around {{ replication_lag }}.
    + {% endif %}
    {% if query %} {% csrf_token %} @@ -76,6 +80,7 @@

    {% if query %}{{ query.title }}{% if shared %}  shared {% if can_change %} + {% if query %} diff --git a/explorer/templates/explorer/query_list.html b/explorer/templates/explorer/query_list.html index a0e7366d..a3a67f44 100644 --- a/explorer/templates/explorer/query_list.html +++ b/explorer/templates/explorer/query_list.html @@ -2,117 +2,120 @@ {% load staticfiles %} {% block sql_explorer_navlinks %} - {% if can_change %} -
  • New Query
  • -
  • Playground
  • -
  • Logs
  • - {% endif %} +{% if can_change %} +
  • New Query
  • +
  • Playground
  • +
  • Logs
  • +
  • Change Logs
  • +{% endif %} {% endblock %} {% block sql_explorer_content %} - {% if recent_queries|length > 0 %} -

    {{ recent_queries|length }} Most Recently Used

    - - - - - - - - - - {% for object in recent_queries %} - - - - - - {% endfor %} - -
    QueryLast RunCSV
    {{ object.title }}{{ object.last_run_date|date:"SHORT_DATETIME_FORMAT" }} - -
    - {% endif %} +{% if recent_queries|length > 0 %} +

    {{ recent_queries|length }} Most Recently Used

    + + + + + + + + + + {% for object in recent_queries %} + + + + + + {% endfor %} + +
    QueryLast RunCSV
    {{ object.title }}{{ object.last_run_date|date:"SHORT_DATETIME_FORMAT" }} + +
    +{% endif %} -
    -
    +
    +
    -

    All Queries

    +

    All Queries

    - +
    -
    +
    {% if tasks_enabled %} - + {% endif %} {% if can_change %} - - + + {% endif %} {% for object in object_list %} - + {% if object.is_header %} - + + {% else %} - - - {% if tasks_enabled %} - - {% endif %} - - {% if can_change %} - - + + + + {% if tasks_enabled %} + + {% endif %} + + {% if can_change %} + + + {% endif %} + {% endif %} {% endfor %}
    Query CreatedEmailEmailCSVPlayDeletePlayDeleteRun Count
    - - + + + {{ object.title }} ({{ object.count }}) - - - {% if object.is_in_category %} → {% endif %}{{ object.title }} - {{ object.created_at|date:"SHORT_DATE_FORMAT" }} - {% if object.created_by_user %} - by {{ object.created_by_user }} - {% endif %} - - - - - - - - - + {% if object.is_in_category %} → {% endif %}{{ object.title }} + {{ object.created_at|date:"SHORT_DATE_FORMAT" }} + {% if object.created_by_user %} + by {{ object.created_by_user }} {% endif %} - {{ object.run_count }} + + + + + + + + {{ object.run_count }}
    -
    +
    {% endblock %} {% block sql_explorer_scripts %} - -{% endblock %} +{% endblock %} \ No newline at end of file diff --git a/explorer/templates/explorer/querychangelog_list.html b/explorer/templates/explorer/querychangelog_list.html new file mode 100644 index 00000000..6d92f17e --- /dev/null +++ b/explorer/templates/explorer/querychangelog_list.html @@ -0,0 +1,55 @@ +{% extends "explorer/base.html" %} + +{% block sql_explorer_navlinks %} +{% if can_change %} +
  • New Query
  • +
  • Playground
  • +
  • Logs
  • +
  • Change Logs
  • +{% endif %} +{% endblock %} + +{% block sql_explorer_content %} +

    Recent Changed Query Logs - Page {{ page_obj.number }}

    + + + + + + + + + + + + + {% for object in recent_change_logs %} + + + + + + + + + {% endfor %} + +
    Run AtRun ByOld SQLNew SQLQuery IDPlayground
    {{ object.run_at|date:"SHORT_DATETIME_FORMAT" }}{{ object.run_by_user.email }}{{ object.old_sql }}{{ object.new_sql }} {% if object.query_id %}Query {{ object.query_id + }}{% elif object.is_playground %}Playground{% else %}--{% endif %}Open
    +{% if is_paginated %} + +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/explorer/templates/explorer/querylog_list.html b/explorer/templates/explorer/querylog_list.html index 76e66c56..de036308 100644 --- a/explorer/templates/explorer/querylog_list.html +++ b/explorer/templates/explorer/querylog_list.html @@ -1,52 +1,55 @@ {% extends "explorer/base.html" %} {% block sql_explorer_navlinks %} - {% if can_change %} -
  • New Query
  • -
  • Playground
  • -
  • Logs
  • - {% endif %} +{% if can_change %} +
  • New Query
  • +
  • Playground
  • +
  • Logs
  • +
  • Change Logs
  • +{% endif %} {% endblock %} {% block sql_explorer_content %} -

    Recent Query Logs - Page {{ page_obj.number }}

    - +

    Recent Query Logs - Page {{ page_obj.number }}

    +
    - - - - - - - {% for object in recent_logs %} - - - - - - - - - {% endfor %} - -
    Run At Run By DurationSQLQuery IDPlayground
    {{ object.run_at|date:"SHORT_DATETIME_FORMAT" }}{{ object.run_by_user.email }}{{ object.duration|floatformat:2 }}ms{{ object.sql }} {% if object.query_id %}Query {{ object.query_id }}{% elif object.is_playground %}Playground{% else %}--{% endif %}Open
    - {% if is_paginated %} - +{% endif %} +{% endblock %} \ No newline at end of file diff --git a/explorer/tests/settings.py b/explorer/tests/settings.py index f3d0e3ae..b7296f07 100644 --- a/explorer/tests/settings.py +++ b/explorer/tests/settings.py @@ -10,7 +10,7 @@ 'PASSWORD': '', 'HOST': '', 'PORT': '', - } + }, } ROOT_URLCONF = 'explorer.tests.urls' @@ -48,7 +48,7 @@ STATIC_URL = '/static/' -MIDDLEWARE_CLASSES = ('django.contrib.sessions.middleware.SessionMiddleware', +MIDDLEWARE = ('django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware') EXPLORER_USER_QUERY_VIEWS = {} diff --git a/explorer/tests/test_utils.py b/explorer/tests/test_utils.py index c99dcc6d..ea628cc5 100644 --- a/explorer/tests/test_utils.py +++ b/explorer/tests/test_utils.py @@ -128,4 +128,4 @@ def test_writing_unicode(self): headers = ['a', None] data = [[1, None], [u"Jenét", '1']] res = write_csv(headers, data).getvalue() - self.assertEqual(res, 'a,\r\n1,\r\nJenét,1\r\n') \ No newline at end of file + self.assertEqual(res, 'a,\r\n1,\r\nJenét,1\r\n') diff --git a/explorer/tests/test_views.py b/explorer/tests/test_views.py index 6fc6aafd..6c05d081 100644 --- a/explorer/tests/test_views.py +++ b/explorer/tests/test_views.py @@ -1,10 +1,10 @@ from django.test import TestCase -from django.core.urlresolvers import reverse +from django.urls import reverse from django.contrib.auth.models import User from django.conf import settings from django.forms.models import model_to_dict from explorer.tests.factories import SimpleQueryFactory, QueryLogFactory -from explorer.models import Query, QueryLog, MSG_FAILED_BLACKLIST +from explorer.models import Query, QueryLog, QueryChangeLog, MSG_FAILED_BLACKLIST from explorer.views import user_can_see_query from explorer.app_settings import EXPLORER_TOKEN from mock import Mock, patch @@ -426,4 +426,33 @@ def test_is_playground(self): self.assertTrue(QueryLog(sql='foo').is_playground) q = SimpleQueryFactory() - self.assertFalse(QueryLog(sql='foo', query_id=q.id).is_playground) \ No newline at end of file + self.assertFalse(QueryLog(sql='foo', query_id=q.id).is_playground) + + +class TestQueryChangeLog(TestCase): + + def setUp(self): + self.user = User.objects.create_superuser('admin', 'admin@admin.com', 'pwd') + self.client.login(username='admin', password='pwd') + + def test_admin_required(self): + self.client.logout() + resp = self.client.get(reverse("explorer_change_logs")) + self.assertTemplateUsed(resp, 'admin/login.html') + + def test_query_change_saves_to_change_log(self): + query = SimpleQueryFactory() + data = model_to_dict(query) + data['sql'] = 'select 12345;' + self.client.post(reverse("query_detail", kwargs={'query_id': query.id}), data) + data['sql'] = 'select 67890;' + self.client.post(reverse("query_detail", kwargs={'query_id': query.id}), data) + resp = self.client.get(reverse("explorer_change_logs")) + self.assertContains(resp, 'select 12345;') + self.assertContains(resp, 'select 67890;') + + def test_is_playground(self): + self.assertTrue(QueryChangeLog(old_sql='foo', new_sql='bar').is_playground) + + q = SimpleQueryFactory() + self.assertFalse(QueryChangeLog(old_sql='foo', new_sql='bar', query_id=q.id).is_playground) diff --git a/explorer/tests/urls.py b/explorer/tests/urls.py index 5d729a95..5021e057 100644 --- a/explorer/tests/urls.py +++ b/explorer/tests/urls.py @@ -1,9 +1,7 @@ -from django.conf.urls import patterns, url, include +from django.conf.urls import url, include from django.contrib import admin from explorer.urls import urlpatterns admin.autodiscover() -urlpatterns += patterns('', - url(r'^admin/', include(admin.site.urls)), -) \ No newline at end of file +urlpatterns += [url(r"^admin/", include(admin.site.urls))] diff --git a/explorer/urls.py b/explorer/urls.py index d270d96c..e88dadbe 100644 --- a/explorer/urls.py +++ b/explorer/urls.py @@ -6,6 +6,7 @@ DeleteQueryView, ListQueryView, ListQueryLogView, + ListQueryChangeLogView, download_query, view_csv_query, email_csv_query, @@ -24,6 +25,7 @@ url(r'play/$', PlayQueryView.as_view(), name='explorer_playground'), url(r'csv$', download_csv_from_sql, name='generate_csv'), url(r'schema/$', schema, name='explorer_schema'), + url(r'changelogs/$', ListQueryChangeLogView.as_view(), name='explorer_change_logs'), url(r'logs/$', ListQueryLogView.as_view(), name='explorer_logs'), url(r'format/$', format_sql, name='format_sql'), url(r'^$', ListQueryView.as_view(), name='explorer_index'), diff --git a/explorer/utils.py b/explorer/utils.py index 5cf2541a..8f3c857e 100644 --- a/explorer/utils.py +++ b/explorer/utils.py @@ -1,36 +1,75 @@ - - +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth.views import login +from django.contrib.admin.forms import AdminAuthenticationForm +import datetime +import sqlparse +from ago import human +from six.moves import cStringIO +from django.http import HttpResponse +from django.db import connections, connection, DatabaseError +from explorer import app_settings +import string +import re import functools import sys +import logging + +from explorer.constants import PII_MASKING_PATTERN_REPLACEMENT_DICT, ALLOW_PHONE_NUMBER_MASKING_GROUP_ID, \ + PATTERN_FOR_FINDING_EMAIL, PATTERN_FOR_FINDING_PHONE_NUMBER + +logger = logging.getLogger(__name__) + PY3 = sys.version_info[0] == 3 if PY3: import csv else: import unicodecsv as csv -import re -import string -from explorer import app_settings -from django.db import connections, connection, DatabaseError -from django.http import HttpResponse -from six.moves import cStringIO -import sqlparse EXPLORER_PARAM_TOKEN = "$$" +REPLICATION_LAG_THRESHOLD_VALUE_IN_MINUTES = 3 + + # SQL Specific Things def passes_blacklist(sql): - clean = functools.reduce(lambda sql, term: sql.upper().replace(term, ""), [t.upper() for t in app_settings.EXPLORER_SQL_WHITELIST], sql) - fails = [bl_word for bl_word in app_settings.EXPLORER_SQL_BLACKLIST if bl_word in clean.upper()] + clean = functools.reduce(lambda sql, term: sql.upper().replace(term, ""), [ + t.upper() for t in app_settings.EXPLORER_SQL_WHITELIST], sql) + fails = [ + bl_word for bl_word in app_settings.EXPLORER_SQL_BLACKLIST if bl_word in clean.upper()] return not any(fails), fails def get_connection(): + logger.info("explorer_connection succesfull") return connections[app_settings.EXPLORER_CONNECTION_NAME] if app_settings.EXPLORER_CONNECTION_NAME else connection +def get_explorer_master_db_connection(): + logger.info("get_explorer_master_db_connection successful") + return connections[ + app_settings.EXPLORER_MASTER_DB_CONNECTION_NAME] if app_settings.EXPLORER_MASTER_DB_CONNECTION_NAME else connection + + +def get_connection_pii(): + logger.info("explorer_pii_connection succesfull") + return connections[app_settings.EXPLORER_CONNECTION_PII_NAME] if app_settings.EXPLORER_CONNECTION_PII_NAME else connection + + +def get_master_db_connection(): + logger.info("explorer_pii_connection succesfull") + return connections[ + app_settings.EXPLORER_CONNECTION_PII_NAME] if app_settings.EXPLORER_CONNECTION_PII_NAME else connection + + +def get_connection_asyncapi_db(): + logger.info("Connecting with async-api DB") + return connections[ + app_settings.EXPLORER_CONNECTION_ASYNC_API_DB_NAME] if app_settings.EXPLORER_CONNECTION_ASYNC_API_DB_NAME else connection + + def schema_info(): """ Construct schema information via introspection of the django models in the database. @@ -48,6 +87,9 @@ def schema_info(): """ from django.apps import apps + import logging + + logger = logging.getLogger(__name__) ret = [] @@ -56,18 +98,29 @@ def schema_info(): for model_name, model in apps.get_app_config(label).models.items(): friendly_model = "%s -> %s" % (app.name, model._meta.object_name) ret.append(( - friendly_model, - model._meta.db_table, - [_format_field(f) for f in model._meta.fields] - )) - - # Do the same thing for many_to_many fields. These don't show up in the field list of the model - # because they are stored as separate "through" relations and have their own tables - ret += [( - friendly_model, - m2m.rel.through._meta.db_table, - [_format_field(f) for f in m2m.rel.through._meta.fields] - ) for m2m in model._meta.many_to_many] + friendly_model, + model._meta.db_table, + [_format_field(f) for f in model._meta.fields] + )) + + try: + # Loop over Many-to-Many relationships + for m2m in model._meta.many_to_many: + through_model = m2m.remote_field.through + ret.append( + ( + friendly_model, + through_model._meta.db_table, + [ + _format_field(f) + for f in through_model._meta.fields + ], + ) + ) + except Exception as e: + logger.error( + "Error while processing Many-to-Many relationships: %s" % e + ) return sorted(ret, key=lambda t: t[1]) @@ -119,14 +172,14 @@ def get_filename_for_title(title): return filename -def build_stream_response(query, delim=None): - data = csv_report(query, delim).getvalue() +def build_stream_response(query, delim=None, user=None): + data = csv_report(query, delim, user).getvalue() response = HttpResponse(data, content_type='text') return response -def build_download_response(query, delim=None): - data = csv_report(query, delim).getvalue() +def build_download_response(query, delim=None, user=None): + data = csv_report(query, delim, user).getvalue() response = HttpResponse(data, content_type='text/csv') response['Content-Disposition'] = 'attachment; filename="%s.csv"' % ( get_filename_for_title(query.title) @@ -135,18 +188,15 @@ def build_download_response(query, delim=None): return response -def csv_report(query, delim=None): +def csv_report(query, delim=None, user=None): try: - res = query.execute_query_only() + res = query.execute_query_only(executing_user=user) return write_csv(res.headers, res.data, delim) except DatabaseError as e: return str(e) # Helpers -from django.contrib.admin.forms import AdminAuthenticationForm -from django.contrib.auth.views import login -from django.contrib.auth import REDIRECT_FIELD_NAME def safe_admin_login_prompt(request): @@ -236,4 +286,132 @@ def get_s3_connection(): import tinys3 return tinys3.Connection(app_settings.S3_ACCESS_KEY, app_settings.S3_SECRET_KEY, - default_bucket=app_settings.S3_BUCKET) \ No newline at end of file + default_bucket=app_settings.S3_BUCKET) + + +def compare_sql(old_sql, new_sql): + """ + Compares whether two sql queries are the + same after formatting them + """ + return fmt_sql(old_sql) == fmt_sql(new_sql) + + +def check_replication_lag(): + """ + Check if a replication lag exists + :returns: True and the replication lag interval if it + exceeds 3 minutes, else returns False and None + """ + conn = get_connection() + cursor = conn.cursor() + + cursor.execute( + "SELECT now() - pg_last_xact_replay_timestamp() AS replication_lag") + replication_lag = cursor.fetchone()[0] + + threshold_value = datetime.timedelta( + minutes=REPLICATION_LAG_THRESHOLD_VALUE_IN_MINUTES) + + if not replication_lag or replication_lag <= threshold_value: + return False, None + + return True, human(replication_lag, 4) + + +def should_route_to_asyncapi_db(sql): + request_log_tables = [ + "request_log_requestlog", + "request_log_requestlogdata", + "temp_request_log_requestlog_customer", + "temp_request_log_requestlogdata_customer", + ] + pattern = r"\b(?:%s)\b" % "|".join(map(re.escape, request_log_tables)) + match = re.search(pattern, sql) + if match: + return True + + return False + + +def mask_string(string_to_masked): + """ + Replace a string with a dictionary of regex patterns and replacements + Param 1: string that needs to be masked + + Eg. + Following is the PII_MASKING_PATTERN_REPLACEMENT_DICT as of now + { + r"(?:\+?\d{1,3}|0)?([6-9]\d{9})\b": "XXXXXXXXXXX", # For masking phone number + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,7}\b": "XXX@XXX.com", # For masking email + } + It would mask the string: + 'My number is +919191919191, their number is 9191919191, my email is abc@abc.com, their email is xyz@pq.in.' + to: + 'My number is XXXXXXXXXXX, their number is XXXXXXXXXXX, my email is XXX@XXX.com, their email is XXX@XXX.com.' + and return it. + """ + + for pattern, replacement in PII_MASKING_PATTERN_REPLACEMENT_DICT.items(): + string_to_masked = re.sub(pattern, replacement, string_to_masked) + return string_to_masked + + +def get_masked_value(value: str): + """ + This function will return the masked value depending upon the length of the value. + For eg. + 1. value = None => masked_value = None + 1. value = "son" => masked_value = "XXX" + 2. value = "sunny" => masked_value = "sXXXy" + 3. value = "my name is kunal" => masked_value = "myXXXXXXXXXXXal" + """ + + if not value: + return None + + if len(value) <= 3: + return "".join("X" for _ in range(len(value))) + elif 4 <= len(value) <= 5: + return value[0] + "".join("X" for _ in range(len(value) - 2)) + value[-1] + else: + return value[:2] + "".join("X" for _ in range(len(value) - 2)) + value[-2:] + + +def get_masked_email(email: str) -> str: + if not email or "@" not in email: + return email + + local_part, domain_part = email.split("@") + masked_local_part = get_masked_value(local_part) + return masked_local_part + "@" + "".join("X" for _ in range(len(domain_part))) + + +def get_masked_phone_number(phone_number: str) -> str: + if not phone_number or len(phone_number) < 10: + return phone_number + + return "".join("X" for _ in range(len(phone_number) - 4)) + phone_number[-4:] + + +def mask_player_pii(data: str) -> str: + if not data: + return data + + matched_phone_numbers = re.findall(PATTERN_FOR_FINDING_PHONE_NUMBER, data) + for phone_number in matched_phone_numbers: + data = data.replace(phone_number, get_masked_phone_number(phone_number)) + + matched_emails = re.findall(PATTERN_FOR_FINDING_EMAIL, data) + for email in matched_emails: + data = data.replace(email, get_masked_email(email)) + + return data + + +def is_pii_masked_for_user(user): + """ + Check if the user has permission to view masked phone numbers + """ + user_group_ids = user.groups.all().values_list('id', flat=True) + return ALLOW_PHONE_NUMBER_MASKING_GROUP_ID not in user_group_ids diff --git a/explorer/views.py b/explorer/views.py index 419525de..cba6755b 100644 --- a/explorer/views.py +++ b/explorer/views.py @@ -1,5 +1,8 @@ +from django.views.decorators.csrf import csrf_exempt + from explorer.tasks import execute_query import six +import logging from django.http.response import HttpResponseRedirect from django.shortcuts import render_to_response, get_object_or_404 @@ -9,49 +12,54 @@ from django.views.generic.edit import CreateView, DeleteView from django.views.decorators.http import require_POST, require_GET from django.utils.decorators import method_decorator -from django.core.urlresolvers import reverse_lazy +from django.urls import reverse_lazy from django.forms.models import model_to_dict from django.http import HttpResponse from django.db import DatabaseError from django.db.models import Count from django.forms import ValidationError +from django.utils.translation import gettext_lazy as _ -from explorer.models import Query, QueryLog, MSG_FAILED_BLACKLIST +from explorer.models import Query, QueryLog, QueryChangeLog, MSG_FAILED_BLACKLIST from explorer import app_settings from explorer.forms import QueryForm -from explorer.utils import url_get_rows,\ - url_get_query_id,\ - url_get_log_id,\ - schema_info,\ - url_get_params,\ - safe_admin_login_prompt,\ - build_download_response,\ - build_stream_response,\ - user_can_see_query,\ - fmt_sql,\ - allowed_query_pks,\ - url_get_show +from explorer.utils import url_get_rows, \ + url_get_query_id, \ + url_get_log_id, \ + schema_info, \ + url_get_params, \ + safe_admin_login_prompt, \ + build_download_response, \ + build_stream_response, \ + user_can_see_query, \ + fmt_sql, \ + allowed_query_pks, \ + url_get_show, \ + compare_sql, \ + check_replication_lag try: from collections import Counter except: from counter import Counter - import re import json from functools import wraps +logger = logging.getLogger(__name__) + def view_permission(f): @wraps(f) def wrap(request, *args, **kwargs): - if not app_settings.EXPLORER_PERMISSION_VIEW(request.user)\ - and not user_can_see_query(request, kwargs)\ + if not app_settings.EXPLORER_PERMISSION_VIEW(request.user) \ + and not user_can_see_query(request, kwargs) \ and not (app_settings.EXPLORER_TOKEN_AUTH_ENABLED() and request.META.get('HTTP_X_API_TOKEN') == app_settings.EXPLORER_TOKEN): return safe_admin_login_prompt(request) return f(request, *args, **kwargs) + return wrap @@ -61,10 +69,11 @@ def wrap(request, *args, **kwargs): def view_permission_list(f): @wraps(f) def wrap(request, *args, **kwargs): - if not app_settings.EXPLORER_PERMISSION_VIEW(request.user)\ + if not app_settings.EXPLORER_PERMISSION_VIEW(request.user) \ and not allowed_query_pks(request.user.id): return safe_admin_login_prompt(request) return f(request, *args, **kwargs) + return wrap @@ -74,6 +83,7 @@ def wrap(request, *args, **kwargs): if not app_settings.EXPLORER_PERMISSION_CHANGE(request.user): return safe_admin_login_prompt(request) return f(request, *args, **kwargs) + return wrap @@ -119,14 +129,17 @@ def email_csv_query(request, query_id): def _csv_response(request, query_id, stream=False, delim=None): query = get_object_or_404(Query, pk=query_id) query.params = url_get_params(request) - return build_stream_response(query, delim) if stream else build_download_response(query, delim) + return build_stream_response(query, delim, user=request.user) if stream else build_download_response(query, delim, + user=request.user) +@csrf_exempt @change_permission @require_POST def download_csv_from_sql(request): sql = request.POST.get('sql') - return build_download_response(Query(sql=sql, title="Playground", params=url_get_params(request))) + return build_download_response(Query(sql=sql, title="Playground", params=url_get_params(request)), + user=request.user) @change_permission @@ -135,13 +148,17 @@ def schema(request): return render_to_response('explorer/schema.html', {'schema': schema_info()}) +@csrf_exempt @require_POST def format_sql(request): + if not (request.user.is_authenticated()): + return HttpResponse(status=403) sql = request.POST.get('sql', '') formatted = fmt_sql(sql) return HttpResponse(json.dumps({"formatted": formatted}), content_type="application/json") +@method_decorator(csrf_exempt, name='dispatch') class ListQueryView(ExplorerContextMixin, ListView): @method_decorator(view_permission_list) @@ -151,7 +168,8 @@ def dispatch(self, *args, **kwargs): def get_context_data(self, **kwargs): context = super(ListQueryView, self).get_context_data(**kwargs) context['object_list'] = self._build_queries_and_headers() - context['recent_queries'] = self.get_queryset().order_by('-last_run_date')[:app_settings.EXPLORER_RECENT_QUERY_COUNT] + context['recent_queries'] = self.get_queryset().order_by( + '-last_run_date')[:app_settings.EXPLORER_RECENT_QUERY_COUNT] context['tasks_enabled'] = app_settings.ENABLE_TASKS return context @@ -159,7 +177,8 @@ def get_queryset(self): if app_settings.EXPLORER_PERMISSION_VIEW(self.request.user): qs = Query.objects.prefetch_related('created_by_user').all() else: - qs = Query.objects.prefetch_related('created_by_user').filter(pk__in=allowed_query_pks(self.request.user.id)) + qs = Query.objects.prefetch_related('created_by_user').filter( + pk__in=allowed_query_pks(self.request.user.id)) return qs.annotate(run_count=Count('querylog')) def _build_queries_and_headers(self): @@ -209,6 +228,7 @@ def _build_queries_and_headers(self): model = Query +@method_decorator(csrf_exempt, name='dispatch') class ListQueryLogView(ExplorerContextMixin, ListView): @method_decorator(view_permission) @@ -223,6 +243,24 @@ def get_queryset(self): paginate_by = 20 +@method_decorator(csrf_exempt, name='dispatch') +class ListQueryChangeLogView(ExplorerContextMixin, ListView): + + @method_decorator(view_permission) + def dispatch(self, *args, **kwargs): + return super(ListQueryChangeLogView, self).dispatch(*args, **kwargs) + + def get_queryset(self): + kwargs = {'old_sql__isnull': False, 'new_sql__isnull': False} + return QueryChangeLog.objects.filter(**kwargs).all() + + context_object_name = "recent_change_logs" + model = QueryChangeLog + paginate_by = 20 + template_name = 'explorer/querychangelog_list.html' + + +@method_decorator(csrf_exempt, name='dispatch') class CreateQueryView(ExplorerContextMixin, CreateView): @method_decorator(change_permission) @@ -237,6 +275,7 @@ def form_valid(self, form): template_name = 'explorer/query.html' +@method_decorator(csrf_exempt, name='dispatch') class DeleteQueryView(ExplorerContextMixin, DeleteView): @method_decorator(change_permission) @@ -247,6 +286,7 @@ def dispatch(self, *args, **kwargs): success_url = reverse_lazy("explorer_index") +@method_decorator(csrf_exempt, name='dispatch') class PlayQueryView(ExplorerContextMixin, View): @method_decorator(change_permission) @@ -270,18 +310,23 @@ def post(self, request): show_results = request.POST.get('show', True) query = Query(sql=sql, title="Playground") passes_blacklist, failing_words = query.passes_blacklist() - error = MSG_FAILED_BLACKLIST % ', '.join(failing_words) if not passes_blacklist else None + error = MSG_FAILED_BLACKLIST % ', '.join( + failing_words) if not passes_blacklist else None run_query = not bool(error) if show_results else False return self.render_with_sql(request, query, run_query=run_query, error=error) def render(self, request): - return self.render_template('explorer/play.html', RequestContext(request, {'title': 'Playground'})) + return self.render_template( + "explorer/play.html", {"title": "Playground", "request": request} + ) def render_with_sql(self, request, query, run_query=True, error=None): - return self.render_template('explorer/play.html', query_viewmodel(request, query, title="Playground" - , run_query=run_query, error=error)) + return self.render_template('explorer/play.html', + query_viewmodel(request, query, title="Playground", run_query=run_query, + error=error)) +@method_decorator(csrf_exempt, name='dispatch') class QueryView(ExplorerContextMixin, View): @method_decorator(view_permission) @@ -291,7 +336,8 @@ def dispatch(self, *args, **kwargs): def get(self, request, query_id): query, form = QueryView.get_instance_and_form(request, query_id) query.save() # updates the modified date - show = url_get_show(request) # if a query is timing out, it can be useful to nav to /query/id/?show=0 + # if a query is timing out, it can be useful to nav to /query/id/?show=0 + show = url_get_show(request) vm = query_viewmodel(request, query, form=form, run_query=show) return self.render_template('explorer/query.html', vm) @@ -300,17 +346,49 @@ def post(self, request, query_id): return HttpResponseRedirect( reverse_lazy('query_detail', kwargs={'query_id': query_id}) ) - + show = url_get_show(request) query, form = QueryView.get_instance_and_form(request, query_id) - success = form.is_valid() and form.save() - vm = query_viewmodel(request, query, form=form, message="Query saved." if success else None) + + old_sql = query.sql + form_isvalid = form.is_valid() + if form_isvalid: + new_sql = request.POST.get('sql') + if not compare_sql(old_sql, new_sql): + change_log = QueryChangeLog( + old_sql=old_sql, + new_sql=new_sql, + query=query, + run_by_user=request.user, + ) + change_log.save() + success = form_isvalid and form.save() + + try: + vm = query_viewmodel( + request, + query, + form=form, + run_query=show, + message=_("Query saved.") if success else "Query not saved" + ) + except ValidationError as ve: + + vm = query_viewmodel( + request, + query, + form=form, + run_query=False, + + error=ve.message + ) return self.render_template('explorer/query.html', vm) @staticmethod def get_instance_and_form(request, query_id): query = get_object_or_404(Query, pk=query_id) query.params = url_get_params(request) - form = QueryForm(request.POST if len(request.POST) else None, instance=query) + form = QueryForm(request.POST if len( + request.POST) else None, instance=query) return query, form @@ -318,29 +396,36 @@ def query_viewmodel(request, query, title=None, form=None, message=None, run_que rows = url_get_rows(request) res = None ql = None + lag_exists = False + replication_lag = None if run_query: try: res, ql = query.execute_with_logging(request.user) + lag_exists, replication_lag = check_replication_lag() except DatabaseError as e: error = str(e) has_valid_results = not error and res and run_query ret = RequestContext(request, { - 'tasks_enabled': app_settings.ENABLE_TASKS, - 'params': query.available_params(), - 'title': title, - 'shared': query.shared, - 'query': query, - 'form': form, - 'message': message, - 'error': error, - 'rows': rows, - 'data': res.data[:rows] if has_valid_results else None, - 'headers': res.headers if has_valid_results else None, - 'total_rows': len(res.data) if has_valid_results else None, - 'duration': res.duration if has_valid_results else None, - 'has_stats': len([h for h in res.headers if h.summary]) if has_valid_results else False, - 'dataUrl': reverse_lazy('query_csv', kwargs={'query_id': query.id}) if query.id else '', - 'bucket': app_settings.S3_BUCKET, - 'snapshots': query.snapshots if query.snapshot else [], - 'ql_id': ql.id if ql else None}) - return ret + 'tasks_enabled': app_settings.ENABLE_TASKS, + 'params': query.available_params(), + 'title': title, + 'shared': query.shared, + 'query': query, + 'form': form, + 'message': message, + 'error': error, + 'rows': rows, + 'data': res.data[:rows] if has_valid_results else None, + 'headers': res.headers if has_valid_results else None, + 'total_rows': len(res.data) if has_valid_results else None, + 'duration': res.duration if has_valid_results else None, + 'has_stats': len([h for h in res.headers if h.summary]) if has_valid_results else False, + 'dataUrl': reverse_lazy('query_csv', kwargs={'query_id': query.id}) if query.id else '', + 'bucket': app_settings.S3_BUCKET, + 'snapshots': query.snapshots if query.snapshot else [], + 'ql_id': ql.id if ql else None, + 'lag_exists': lag_exists, + 'replication_lag': replication_lag, + } + ) + return ret.flatten() diff --git a/requirements.txt b/requirements.txt index b4f0851b..893c8e70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ -Django>=1.7.0 +Django>=1.11.17 factory-boy==2.6.0 mock==1.0.1 six==1.10.0 -sqlparse==0.1.18 -unicodecsv==0.14.1 \ No newline at end of file +sqlparse>=0.4.0 +unicodecsv==0.14.1 +ago==0.0.93 diff --git a/setup.py b/setup.py index 70dbb6cd..cc76a6be 100644 --- a/setup.py +++ b/setup.py @@ -12,8 +12,8 @@ def read(fname): return open(os.path.join(os.path.dirname(__file__), fname)).read() setup( - name = "django-sql-explorer", - version = __version__, + name = "django-sql-explorer-squad", + version = "0.9.25", author = "Chris Clark", author_email = "chris@untrod.com", description = ("A pluggable app that allows users (admins) to execute SQL," @@ -37,10 +37,12 @@ def read(fname): 'Programming Language :: Python :: 3.4', ], install_requires=[ - 'Django>=1.6.7', + 'Django>=1.11', 'sqlparse>=0.1.11', 'unicodecsv>=0.13.0', 'six>=1.10.0', + # custom deps + 'ago==0.0.93', ], include_package_data=True, zip_safe = False, diff --git a/test_settings.py b/test_settings.py new file mode 100644 index 00000000..af4262bc --- /dev/null +++ b/test_settings.py @@ -0,0 +1,93 @@ +import os + +# import djcelery + +SECRET_KEY = "shhh" +DEBUG = True +STATIC_URL = "/static/" + +ALLOWED_HOSTS = ["0.0.0.0", "localhost", "127.0.0.1"] + +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) +CSRF_COOKIE_HTTPONLY = False + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "tmp", + "TEST": {"NAME": "test_tmp"}, + }, + "alt": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "tmp2", + "TEST": {"NAME": "test_tmp2"}, + }, + "not_registered": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "tmp3", + "TEST": {"NAME": "test_tmp3"}, + }, +} + +EXPLORER_CONNECTIONS = { + #'Postgres': 'postgres', + #'MySQL': 'mysql', + "SQLite": "default", + "Another": "alt", +} +EXPLORER_DEFAULT_CONNECTION = "default" + +ROOT_URLCONF = "explorer.tests.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + "django.template.context_processors.static", + "django.template.context_processors.request", + ], + "debug": DEBUG, + }, + }, +] + +INSTALLED_APPS = ( + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "django.contrib.admin", + "explorer", + # 'djcelery' +) + +AUTHENTICATION_BACKENDS = ("django.contrib.auth.backends.ModelBackend",) + + +MIDDLEWARE = [ + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", +] + +# TEST_RUNNER = 'djcelery.contrib.test_runner.CeleryTestSuiteRunner' + +# djcelery.setup_loader() +CELERY_ALWAYS_EAGER = True +BROKER_BACKEND = "memory" + +# Explorer-specific + +EXPLORER_TRANSFORMS = (("foo", '{0}'), ("bar", "x: {0}")) + +EXPLORER_USER_QUERY_VIEWS = {} +EXPLORER_TASKS_ENABLED = True +EXPLORER_S3_BUCKET = "thisismybucket.therearemanylikeit.butthisoneismine"