diff --git a/.github/sync.yml b/.github/sync.yml index 9de822878f..1a5178c85e 100644 --- a/.github/sync.yml +++ b/.github/sync.yml @@ -1,4 +1,4 @@ -mathesar-foundation/mathesar-ansible: +mathesar-foundation/mathesar-infrastructure: - .github/workflows/toc.yml - .github/workflows/stale.yml mathesar-foundation/mathesar-data-playground: diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml index d5d170fff3..e562829589 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -8,10 +8,10 @@ jobs: staging-deploy: runs-on: ubuntu-latest steps: - - name: Checkout ansible repo + - name: Checkout infrastructure repo uses: actions/checkout@v2 with: - repository: 'mathesar-foundation/mathesar-ansible' + repository: 'mathesar-foundation/mathesar-infrastructure' token: ${{ secrets.MATHESAR_ORG_GITHUB_TOKEN }} # Repo is private, so an access token is used # This checkout is used for getting the 'action' from the current repo - name: Checkout mathesar repo diff --git a/.github/workflows/sync-github-labels-milestones.yml b/.github/workflows/sync-github-labels-milestones.yml index 4b55fba303..190b31db6c 100644 --- a/.github/workflows/sync-github-labels-milestones.yml +++ b/.github/workflows/sync-github-labels-milestones.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v2 - run: composer global require 'vanilla/github-sync' - - run: /home/runner/.composer/vendor/bin/github-sync labels -f mathesar-foundation/mathesar -t mathesar-foundation/mathesar-ansible -d + - run: /home/runner/.composer/vendor/bin/github-sync labels -f mathesar-foundation/mathesar -t mathesar-foundation/mathesar-infrastructure -d - run: /home/runner/.composer/vendor/bin/github-sync labels -f mathesar-foundation/mathesar -t mathesar-foundation/mathesar-data-playground -d - run: /home/runner/.composer/vendor/bin/github-sync labels -f mathesar-foundation/mathesar -t mathesar-foundation/mathesar-design -d - run: /home/runner/.composer/vendor/bin/github-sync labels -f mathesar-foundation/mathesar -t mathesar-foundation/mathesar-internal-crm -d @@ -21,7 +21,7 @@ jobs: - run: /home/runner/.composer/vendor/bin/github-sync labels -f mathesar-foundation/mathesar -t mathesar-foundation/mathesar-scripts -d - run: /home/runner/.composer/vendor/bin/github-sync labels -f mathesar-foundation/mathesar -t mathesar-foundation/mathesar-website -d - run: /home/runner/.composer/vendor/bin/github-sync labels -f mathesar-foundation/mathesar -t mathesar-foundation/mathesar-wiki -d - - run: /home/runner/.composer/vendor/bin/github-sync milestones -f mathesar-foundation/mathesar -t mathesar-foundation/mathesar-ansible -s open + - run: /home/runner/.composer/vendor/bin/github-sync milestones -f mathesar-foundation/mathesar -t mathesar-foundation/mathesar-infrastructure -s open - run: /home/runner/.composer/vendor/bin/github-sync milestones -f mathesar-foundation/mathesar -t mathesar-foundation/mathesar-data-playground -s open - run: /home/runner/.composer/vendor/bin/github-sync milestones -f mathesar-foundation/mathesar -t mathesar-foundation/mathesar-design -s open - run: /home/runner/.composer/vendor/bin/github-sync milestones -f mathesar-foundation/mathesar -t mathesar-foundation/mathesar-internal-crm -s open diff --git a/.github/workflows/test-and-lint-code.yml b/.github/workflows/test-and-lint-code.yml index 6d4fbec9aa..e19cb18ded 100644 --- a/.github/workflows/test-and-lint-code.yml +++ b/.github/workflows/test-and-lint-code.yml @@ -155,6 +155,30 @@ jobs: - name: Run tests with pg_prove run: docker exec mathesar_dev_db /bin/bash /sql/run_tests.sh + api_tests: + name: Run API scenario tests + runs-on: ubuntu-latest + needs: [python_tests_required, all_be_tests_required] + if: needs.python_tests_required.outputs.tests_should_run == 'true' || + needs.all_be_tests_required.outputs.tests_should_run + strategy: + matrix: + py-version: [3.9-bookworm, 3.10-bookworm, 3.11-bookworm, 3.12-bookworm, 3.13-bookworm] + pg-version: [13, 14, 15, 16, 17] + steps: + - uses: actions/checkout@v4 + - name: Copy env file + run: cp .env.example .env + # The code is checked out under uid 1001 - reset this to 1000 for the + # container to run tests successfully + - name: Fix permissions + run: sudo chown -R 1000:1000 . + - name: Run tests + run: sh run_api_tests.sh + env: + PYTHON_VERSION: ${{ matrix.py-version }} + PG_VERSION: ${{ matrix.pg-version }} + python_lint: name: Run Python linter runs-on: ubuntu-latest diff --git a/api_tests/Dockerfile b/api_tests/Dockerfile new file mode 100644 index 0000000000..38187455aa --- /dev/null +++ b/api_tests/Dockerfile @@ -0,0 +1,5 @@ +ARG PYTHON_VERSION=3.9-bookworm +FROM python:$PYTHON_VERSION +WORKDIR /code/ +COPY . . +RUN pip install -r requirements.txt diff --git a/api_tests/requirements.txt b/api_tests/requirements.txt new file mode 100644 index 0000000000..cbfa5a1284 --- /dev/null +++ b/api_tests/requirements.txt @@ -0,0 +1,2 @@ +requests==2.32.3 +pytest==8.3.3 diff --git a/api_tests/test_happy_db_setups.py b/api_tests/test_happy_db_setups.py new file mode 100644 index 0000000000..5ad24cd167 --- /dev/null +++ b/api_tests/test_happy_db_setups.py @@ -0,0 +1,370 @@ +import pytest +import requests + +SERVICE_HOST = 'http://mathesar-api-test-service:8000' +EXTERNAL_HOST = 'mathesar-test-user-db' +RPC_ENDPOINT = f'{SERVICE_HOST}/api/rpc/v0/' + + +@pytest.fixture(scope="module") +def admin_session(): + login_payload = {"username": "admin", "password": "password"} + s = requests.Session() + s.get(f'{SERVICE_HOST}/auth/login/') + s.headers['X-CSRFToken'] = s.cookies['csrftoken'] + s.post(f'{SERVICE_HOST}/auth/login/', data=login_payload) + s.headers['X-CSRFToken'] = s.cookies['csrftoken'] + return s + + +@pytest.fixture(scope="module") +def admin_rpc_call(admin_session): + def _admin_rpc_request(function, **kwargs): + response = admin_session.post( + RPC_ENDPOINT, + json={ + "jsonrpc": "2.0", + "method": function, + "params": kwargs, + "id": 0, + } + ).json() + return response['result'] + return _admin_rpc_request + + +@pytest.fixture(scope="module") +def intern_session(): + # This function handles Django's auto-password-reset flow + # NOTE: This will only work after the admin adds `intern` as a user! + init_login_payload = {"username": "intern", "password": "password"} + s = requests.Session() + s.get(f'{SERVICE_HOST}/auth/login/') + s.headers['X-CSRFToken'] = s.cookies['csrftoken'] + s.post(f'{SERVICE_HOST}/auth/login/', data=init_login_payload) + s.headers['X-CSRFToken'] = s.cookies['csrftoken'] + reset_payload = { + "new_password1": "myinternpass1234", + "new_password2": "myinternpass1234" + } + s.post(f'{SERVICE_HOST}/auth/password_reset_confirm', data=reset_payload) + s.headers['X-CSRFToken'] = s.cookies['csrftoken'] + new_login_payload = {"username": "intern", "password": "myinternpass1234"} + s.post(f'{SERVICE_HOST}/auth/login/', data=new_login_payload) + s.headers['X-CSRFToken'] = s.cookies['csrftoken'] + return s + + +@pytest.fixture(scope="module") +def intern_rpc_call(intern_session): + def _intern_rpc_request(function, **kwargs): + response = intern_session.post( + RPC_ENDPOINT, + json={ + "jsonrpc": "2.0", + "method": function, + "params": kwargs, + "id": 0, + } + ).json() + return response['result'] + return _intern_rpc_request + + +def test_empty_db_list(admin_rpc_call): + db_list = admin_rpc_call('databases.configured.list') + assert db_list == [] + + +def test_create_mathesar_db_internal(admin_rpc_call): + global internal_db_id + global internal_server_id + result = admin_rpc_call( + 'databases.setup.create_new', + database='mathesar', + sample_data=['library_management'] + ) + assert set(result.keys()) == set(['configured_role', 'database', 'server']) + internal_db = result['database'] + internal_server = result['server'] + assert internal_db['name'] == 'mathesar' + assert internal_db['needs_upgrade_attention'] is False + internal_db_id = internal_db['id'] + internal_server_id = internal_server['id'] + + +def test_connect_mathesar_db_external(admin_rpc_call): + global external_db_id + result = admin_rpc_call( + 'databases.setup.connect_existing', + host=EXTERNAL_HOST, + port=5432, + database='my_data', + role='data_admin', + password='data1234', + ) + assert set(result.keys()) == set(['configured_role', 'database', 'server']) + external_db = result['database'] + assert external_db['name'] == 'my_data' + assert external_db['needs_upgrade_attention'] is False + assert result['configured_role']['name'] == 'data_admin' + external_db_id = external_db['id'] + + +def test_list_databases_has_upgrade_status(admin_rpc_call): + result = admin_rpc_call('databases.configured.list') + assert all(d['needs_upgrade_attention'] is False for d in result) + + +def test_batch_sql_update_no_error(admin_session): + admin_session.post( + RPC_ENDPOINT, + json=[ + { + "jsonrpc": "2.0", + "method": "databases.upgrade_sql", + "id": "0", + "params": {"database_id": internal_db_id} + }, + { + "jsonrpc": "2.0", + "method": "databases.upgrade_sql", + "id": "2", + "params": {"database_id": external_db_id} + }, + ] + ) + + +def test_get_current_role(admin_rpc_call): + global mathesar_role_oid + result = admin_rpc_call( + 'roles.get_current_role', + database_id=internal_db_id, + ) + assert result['current_role']['super'] is True + mathesar_role_oid = result['current_role']['oid'] + + +# Now the scenario proper starts. The admin will add an `intern` user to +# Mathesar, an `intern` role to the internal `mathesar` DB, then grant +# `CONNECT` to that role on the DB, `USAGE` on "Library Management", and +# `SELECT` on "Books". We'll make sure the intern has the correct +# privileges at each step. +# +# We'll follow along with the API calls made by the front end. + + +def test_add_user(admin_rpc_call): + global intern_user_id + before_users = admin_rpc_call('users.list') + assert len(before_users) == 1 + intern_add = { + 'display_language': 'en', + 'email': 'intern@example.com', + 'is_superuser': False, + 'password': 'password', + 'username': 'intern', + } + admin_rpc_call('users.add', user_def=intern_add) + after_users = admin_rpc_call('users.list') + assert len(after_users) == 2 + intern_user_id = [ + u['id'] for u in after_users if u['username'] == 'intern' + ][0] + + +def test_intern_no_databases(intern_rpc_call): + db_list = intern_rpc_call('databases.configured.list') + assert db_list == [] + + +def test_list_configured_roles(admin_rpc_call): + # The only role at this point should be the initial `mathesar` role. + result = admin_rpc_call( + 'roles.configured.list', + server_id=internal_server_id, + ) + assert len(result) == 1 + + +def test_add_role(admin_rpc_call): + global intern_role_oid + result = admin_rpc_call( + 'roles.add', + database_id=internal_db_id, + login=True, + rolename='intern', + password='internpass' + ) + intern_role_oid = result['oid'] + + +def test_configure_role(admin_rpc_call): + global intern_configured_role_id + result = admin_rpc_call( + 'roles.configured.add', + name='intern', + password='internpass', + server_id=internal_server_id, + ) + assert 'password' not in result.keys() + intern_configured_role_id = result['id'] + + +def test_add_collaborator(admin_rpc_call): + before_collaborators = admin_rpc_call( + 'collaborators.list', + database_id=internal_db_id, + ) + # Should only have the admin so far + assert len(before_collaborators) == 1 + admin_rpc_call( + 'collaborators.add', + configured_role_id=intern_configured_role_id, + database_id=internal_db_id, + user_id=intern_user_id, + ) + after_collaborators = admin_rpc_call( + 'collaborators.list', + database_id=internal_db_id, + ) + assert len(after_collaborators) == 2 + intern_collab_definition = [ + c for c in after_collaborators if c['user_id'] == intern_user_id + ][0] + assert intern_collab_definition['database_id'] == internal_db_id + assert intern_collab_definition['configured_role_id'] == intern_configured_role_id + + +def test_intern_has_internal_database(intern_rpc_call): + db_list = intern_rpc_call('databases.configured.list') + assert len(db_list) == 1 + assert db_list[0]['id'] == internal_db_id + + +def test_database_privileges_add(admin_rpc_call): + before_result = admin_rpc_call( + 'databases.privileges.list_direct', + database_id=internal_db_id, + ) + # `intern` shouldn't have any privileges yet. + assert intern_role_oid not in [r['role_oid'] for r in before_result] + after_result = admin_rpc_call( + 'databases.privileges.replace_for_roles', + database_id=internal_db_id, + privileges=[{'direct': ['CONNECT'], 'role_oid': intern_role_oid}] + ) + # `intern` should have `CONNECT` after this point. + assert [ + d['direct'] for d in after_result if d['role_oid'] == intern_role_oid + ][0] == ['CONNECT'] + + +def test_schemas_list(admin_rpc_call): + global library_management_oid + result = admin_rpc_call( + 'schemas.list', + database_id=1 + ) + # Should have `public` and `Library Management` + assert len(result) == 2 + library_management_oid = [ + s['oid'] for s in result if s['name'] == 'Library Management' + ][0] + + +def test_tables_list(admin_rpc_call): + global books_oid + result = admin_rpc_call( + 'tables.list', + database_id=1, + schema_oid=library_management_oid + ) + assert len(result) == 7 + books_oid = [t['oid'] for t in result if t['name'] == 'Books'][0] + + +def test_intern_cannot_access_library_schema_tables(intern_session): + response = intern_session.post( + RPC_ENDPOINT, + json={ + "jsonrpc": "2.0", + "method": 'records.list', + "params": { + "table_oid": books_oid, + "database_id": internal_db_id, + "limit": 20 + }, + "id": 0, + } + ).json() + assert response['error']['code'] == -30101 # InsufficientPrivilege + assert "permission denied for schema" in response['error']['message'] + + +def test_schema_privileges_add(admin_rpc_call): + before_result = admin_rpc_call( + 'schemas.privileges.list_direct', + database_id=internal_db_id, + schema_oid=library_management_oid, + ) + # `intern` should not have any schema privileges yet. + assert intern_role_oid not in [r['role_oid'] for r in before_result] + after_result = admin_rpc_call( + 'schemas.privileges.replace_for_roles', + database_id=internal_db_id, + schema_oid=library_management_oid, + privileges=[{'direct': ['USAGE'], 'role_oid': intern_role_oid}], + ) + assert [ + d['direct'] for d in after_result if d['role_oid'] == intern_role_oid + ][0] == ['USAGE'] + + +def test_intern_still_cannot_access_books_table_data(intern_session): + response = intern_session.post( + RPC_ENDPOINT, + json={ + "jsonrpc": "2.0", + "method": 'records.list', + "params": { + "table_oid": books_oid, + "database_id": internal_db_id, + "limit": 20 + }, + "id": 0, + } + ).json() + assert response['error']['code'] == -30101 # InsufficientPrivilege + assert "permission denied for table" in response['error']['message'] + + +def test_table_privileges_add(admin_rpc_call): + before_result = admin_rpc_call( + 'tables.privileges.list_direct', + table_oid=books_oid, + database_id=internal_db_id, + ) + # `intern` should not have any schema privileges yet. + assert intern_role_oid not in [r['role_oid'] for r in before_result] + after_result = admin_rpc_call( + 'tables.privileges.replace_for_roles', + table_oid=books_oid, + database_id=internal_db_id, + privileges=[{'direct': ['SELECT'], 'role_oid': intern_role_oid}], + ) + assert [ + d['direct'] for d in after_result if d['role_oid'] == intern_role_oid + ][0] == ['SELECT'] + + +def test_intern_can_now_access_books_table(intern_rpc_call): + records = intern_rpc_call( + 'records.list', + table_oid=books_oid, + database_id=internal_db_id, + limit=20, + ) + assert records['count'] == 1410 + assert len(records['results']) == 20 diff --git a/config/settings/common_settings.py b/config/settings/common_settings.py index 91e7039539..04ba020f8c 100644 --- a/config/settings/common_settings.py +++ b/config/settings/common_settings.py @@ -81,6 +81,7 @@ def pipe_delim(pipe_string): 'mathesar.rpc.tables', 'mathesar.rpc.tables.metadata', 'mathesar.rpc.tables.privileges', + 'mathesar.rpc.users' ] TEMPLATES = [ @@ -230,6 +231,7 @@ def pipe_delim(pipe_string): MATHESAR_UI_SOURCE_LOCATION = os.path.join(BASE_DIR, 'mathesar_ui/') MATHESAR_CAPTURE_UNHANDLED_EXCEPTION = os.environ.get('CAPTURE_UNHANDLED_EXCEPTION', default=False) MATHESAR_STATIC_NON_CODE_FILES_LOCATION = os.path.join(BASE_DIR, 'mathesar/static/non-code/') +MATHESAR_ANALYTICS_URL = os.environ.get('MATHESAR_ANALYTICS_URL', default='https://example.com') DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' diff --git a/conftest.py b/conftest.py index e7d67e4185..42d8d9e784 100644 --- a/conftest.py +++ b/conftest.py @@ -16,9 +16,8 @@ from db.deprecated.utils import get_pg_catalog_table from db.deprecated.metadata import get_empty_metadata -from fixtures.utils import create_scoped_fixtures - +@pytest.fixture(scope="session") def engine_cache(request): import logging logger = logging.getLogger(f'engine_cache-{request.scope}') @@ -40,15 +39,15 @@ def _get(db_name): logger.debug('exit') -# defines: -# FUN_engine_cache -# CLA_engine_cache -# MOD_engine_cache -# SES_engine_cache -create_scoped_fixtures(globals(), engine_cache) +@pytest.fixture(autouse=True) +def disable_http_requests(monkeypatch): + def mock_urlopen(self, *args, **kwargs): + raise Exception("Requests to 3rd party addresses make bad tests") + monkeypatch.setattr("urllib3.connectionpool.HTTPConnectionPool.urlopen", mock_urlopen) -def create_db(request, SES_engine_cache): +@pytest.fixture(scope="session") +def create_db(request, engine_cache): """ A factory for Postgres mathesar-installed databases. A fixture made of this method tears down created dbs when leaving scope. @@ -56,8 +55,6 @@ def create_db(request, SES_engine_cache): This method is used to create fixtures with different scopes, that's why it's not a fixture itself. """ - engine_cache = SES_engine_cache - import logging logger = logging.getLogger(f'create_db-{request.scope}') logger.debug('enter') @@ -89,14 +86,6 @@ def __create_db(db_name): logger.debug('exit') -# defines: -# FUN_create_db -# CLA_create_db -# MOD_create_db -# SES_create_db -create_scoped_fixtures(globals(), create_db) - - @pytest.fixture(scope="session") def worker_id(worker_id): """ @@ -136,12 +125,11 @@ def uid(get_uid): @pytest.fixture(scope="session", autouse=True) -def test_db_name(worker_id, SES_create_db): +def test_db_name(worker_id, create_db): """ A dynamic, yet non-random, db_name is used so that subsequent runs would automatically clean up test databases that we failed to tear down. """ - create_db = SES_create_db default_test_db_name = "mathesar_db_test" db_name = f"{default_test_db_name}_{worker_id}" create_db(db_name) @@ -149,8 +137,7 @@ def test_db_name(worker_id, SES_create_db): @pytest.fixture(scope="session") -def engine(test_db_name, SES_engine_cache): - engine_cache = SES_engine_cache +def engine(test_db_name, engine_cache): engine = engine_cache(test_db_name) add_custom_types_to_ischema_names(engine) return engine @@ -169,15 +156,13 @@ def engine_with_schema(engine, _test_schema_name, create_db_schema): @pytest.fixture -def create_db_schema(SES_engine_cache): +def create_db_schema(engine_cache): """ Creates a DB schema factory, making sure to track and clean up new instances. Schema setup and teardown is very fast, so we'll only use this fixture with the default "function" scope. """ - engine_cache = SES_engine_cache - import logging logger = logging.getLogger('create_db_schema') logger.debug('enter') diff --git a/db/analytics.py b/db/analytics.py new file mode 100644 index 0000000000..b787260777 --- /dev/null +++ b/db/analytics.py @@ -0,0 +1,5 @@ +from db import connection as db_conn + + +def get_object_counts(conn): + return db_conn.exec_msar_func(conn, 'get_object_counts').fetchone()[0] diff --git a/db/sql/00_msar.sql b/db/sql/00_msar.sql index 5c9f507964..a03b4cb17c 100644 --- a/db/sql/00_msar.sql +++ b/db/sql/00_msar.sql @@ -72,6 +72,16 @@ SELECT msar.drop_all_msar_objects(4); ---------------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION msar.mathesar_system_schemas() RETURNS text[] AS $$/* +Return a text array of the Mathesar System schemas. + +Update this function whenever the list changes. +*/ +SELECT ARRAY['msar', '__msar', 'mathesar_types'] +$$ LANGUAGE SQL STABLE; + + CREATE OR REPLACE FUNCTION msar.extract_smallints(v jsonb) RETURNS smallint[] AS $$/* From the supplied JSONB value, extract all top-level JSONB array elements which can be successfully cast to PostgreSQL smallint values. Return the resulting array of smallint values. @@ -921,7 +931,7 @@ $$ LANGUAGE SQL RETURNS NULL ON NULL INPUT; CREATE OR REPLACE FUNCTION msar.get_fkey_map_table(tab_id oid) RETURNS TABLE (target_oid oid, conkey smallint, confkey smallint) AS $$/* -Generate a table mapping foreign key values from refererrer to referant tables. +Generate a table mapping foreign key values from refererrer to referent tables. Given an input table (identified by OID), we return a table with each row representing a foreign key constraint on that table. We return only single-column foreign keys, and only one per foreign key @@ -954,6 +964,46 @@ WHERE has_privilege; $$ LANGUAGE SQL STABLE RETURNS NULL ON NULL INPUT; +CREATE OR REPLACE FUNCTION +msar.describe_column_default(tab_id regclass, col_id smallint) RETURNS jsonb AS $$/* +Return a JSONB object describing the default (if any) of the given column in the given table. + +The returned JSON will have the form: + { + "value": , + "is_dynamic": , + } + +If the default is possibly dynamic, i.e., if "is_dynamic" is true, then "value" will be a text SQL +expression that generates the default value if evaluated. If it is not dynamic, then "value" is the +actual default value. +*/ +DECLARE + def_expr text; + def_json jsonb; +BEGIN +def_expr = CASE + WHEN attidentity='' THEN pg_catalog.pg_get_expr(adbin, tab_id) + ELSE 'identity' +END +FROM pg_catalog.pg_attribute LEFT JOIN pg_catalog.pg_attrdef ON attrelid=adrelid AND attnum=adnum +WHERE attrelid=tab_id AND attnum=col_id; +IF def_expr IS NULL THEN + RETURN NULL; +ELSIF msar.is_default_possibly_dynamic(tab_id, col_id) THEN + EXECUTE format( + 'SELECT jsonb_build_object(''value'', %L, ''is_dynamic'', true)', def_expr + ) INTO def_json; +ELSE + EXECUTE format( + 'SELECT jsonb_build_object(''value'', msar.format_data(%s), ''is_dynamic'', false)', def_expr + ) INTO def_json; +END IF; +RETURN def_json; +END; +$$ LANGUAGE plpgsql RETURNS NULL ON NULL INPUT; + + CREATE OR REPLACE FUNCTION msar.get_column_info(tab_id regclass) RETURNS jsonb AS $$/* Given a table identifier, return an array of objects describing the columns of the table. @@ -985,20 +1035,7 @@ SELECT jsonb_agg( 'type_options', msar.get_type_options(atttypid, atttypmod, attndims), 'nullable', NOT attnotnull, 'primary_key', COALESCE(pgi.indisprimary, false), - 'default', - nullif( - jsonb_strip_nulls( - jsonb_build_object( - 'value', - CASE - WHEN attidentity='' THEN pg_get_expr(adbin, tab_id) - ELSE 'identity' - END, - 'is_dynamic', msar.is_default_possibly_dynamic(tab_id, attnum) - ) - ), - jsonb_build_object() - ), + 'default', msar.describe_column_default(tab_id, attnum), 'has_dependents', msar.has_dependents(tab_id, attnum), 'description', msar.col_description(tab_id, attnum), 'current_role_priv', msar.list_column_privileges_for_current_role(tab_id, attnum), @@ -1113,6 +1150,30 @@ WHERE has_privilege; $$ LANGUAGE SQL STABLE RETURNS NULL ON NULL INPUT; +CREATE OR REPLACE FUNCTION +msar.get_object_counts() RETURNS jsonb AS $$/* +Return a JSON object with counts of some objects in the database. + +We exclude the mathesar-system schemas. + +The objects counted are: +- total schemas, excluding Mathesar internal schemas +- total tables in the included schemas +- total rows of tables included +*/ +SELECT jsonb_build_object( + 'schema_count', COUNT(DISTINCT pgn.oid), + 'table_count', COUNT(pgc.oid), + 'record_count', SUM(pgc.reltuples) +) +FROM pg_catalog.pg_namespace pgn +LEFT JOIN pg_catalog.pg_class pgc ON pgc.relnamespace = pgn.oid AND pgc.relkind = 'r' +WHERE pgn.nspname <> 'information_schema' +AND NOT (pgn.nspname = ANY(msar.mathesar_system_schemas())) +AND pgn.nspname NOT LIKE 'pg_%'; +$$ LANGUAGE SQL STABLE; + + CREATE OR REPLACE FUNCTION msar.schema_info_table() RETURNS TABLE ( oid bigint, -- The OID of the schema. @@ -1136,7 +1197,8 @@ LEFT JOIN pg_catalog.pg_class c ON c.relnamespace = s.oid AND c.relkind = 'r' GROUP BY s.oid, s.nspname, - s.nspowner; + s.nspowner +ORDER BY s.nspname; -- Filter on relkind so that we only count tables. This must be done in the ON clause so that -- we still get a row for schemas with no tables. $$ LANGUAGE SQL STABLE; diff --git a/db/sql/10_msar_joinable_tables.sql b/db/sql/10_msar_joinable_tables.sql index 0b49dd99f1..1c86576897 100644 --- a/db/sql/10_msar_joinable_tables.sql +++ b/db/sql/10_msar_joinable_tables.sql @@ -25,7 +25,7 @@ A Foreign Key path gives the same information in a different form: ] In this form, `constraint_idN` is a foreign key constraint, and `reversed` is a boolean giving -whether to travel from referrer to referant (when False) or from referant to referrer (when True). +whether to travel from referrer to referent (when False) or from referent to referrer (when True). */ diff --git a/db/sql/test_00_msar.sql b/db/sql/test_00_msar.sql index 2287d39fad..924eb9c30c 100644 --- a/db/sql/test_00_msar.sql +++ b/db/sql/test_00_msar.sql @@ -2662,7 +2662,7 @@ BEGIN "name": "txt", "type": "text", "default": { - "value": "'abc'::text", + "value": "abc", "is_dynamic": false }, "nullable": true, @@ -5907,3 +5907,21 @@ BEGIN ); END; $$ LANGUAGE plpgsql; + + +CREATE OR REPLACE FUNCTION test_get_object_counts() RETURNS SETOF TEXT AS $$ +DECLARE + object_counts jsonb; +BEGIN + CREATE SCHEMA anewone; + CREATE TABLE anewone.mytab (col1 text); + CREATE TABLE "12345" (bleh text, bleh2 numeric); + CREATE TABLE tableno3 (id INTEGER); + object_counts = msar.get_object_counts(); + RETURN NEXT is((object_counts ->> 'schema_count')::integer, 2); + RETURN NEXT is((object_counts ->> 'table_count')::integer, 3); + -- Can't check actual record count without a vacuum, since we just estimate based on catalog. + -- So, we just check that the expected key exists. + RETURN NEXT is(object_counts ? 'record_count', true); +END; +$$ LANGUAGE plpgsql; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3bede37fc4..05d2ffaf0a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -4,8 +4,7 @@ services: # Mathesar App built with the same configurations as the production image # but with additional testing dependencies. # It is used to run automated test cases to verify if the app works as intended - dev-db: - container_name: mathesar_dev_db + dev-db-base: command: ["postgres", "-c", "shared_preload_libraries=plugin_debugger"] build: context: . @@ -17,17 +16,21 @@ services: - POSTGRES_DB=mathesar_django - POSTGRES_USER=mathesar - POSTGRES_PASSWORD=mathesar - ports: - - "5432:5432" - volumes: - - dev_postgres_data:/var/lib/postgresql/data - - ./db/sql:/sql/ healthcheck: test: [ "CMD-SHELL", "pg_isready -d $${POSTGRES_DB-mathesar_django} -U $${POSTGRES_USER-mathesar}"] interval: 5s timeout: 1s retries: 30 start_period: 5s + dev-db: + container_name: mathesar_dev_db + extends: + service: dev-db-base + ports: + - "5432:5432" + volumes: + - dev_postgres_data:/var/lib/postgresql/data + - ./db/sql:/sql/ # A Django development webserver + Svelte development server used when developing Mathesar. # The code changes are hot reloaded and debug flags are enabled to aid developers working on Mathesar. # It is not recommended to use this service in production environment. @@ -46,6 +49,7 @@ services: - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE-config.settings.development} - ALLOWED_HOSTS=${ALLOWED_HOSTS-*} - SECRET_KEY=${SECRET_KEY} + - MATHESAR_ANALYTICS_URL=${MATHESAR_ANALYTICS_URL-https://example.com} - MATHESAR_DATABASES=(mathesar_tables|postgresql://mathesar:mathesar@mathesar_dev_db:5432/mathesar) - DJANGO_SUPERUSER_PASSWORD=password - POSTGRES_DB=mathesar_django diff --git a/docker-compose.test.yml b/docker-compose.test.yml new file mode 100644 index 0000000000..083d84cd1e --- /dev/null +++ b/docker-compose.test.yml @@ -0,0 +1,64 @@ +--- +services: + test-db: + container_name: mathesar-test-db + extends: + file: docker-compose.dev.yml + service: dev-db-base + expose: + - "5432" + healthcheck: + interval: 1s + start_period: 2s + test-user-db: + container_name: mathesar-test-user-db + extends: + service: test-db + environment: + - POSTGRES_DB=my_data + - POSTGRES_USER=data_admin + - POSTGRES_PASSWORD=data1234 + api-test-service: + container_name: mathesar-api-test-service + build: + context: . + target: testing + dockerfile: Dockerfile + args: + PYTHON_VERSION: ${PYTHON_VERSION-3.9-bookworm} + environment: + - ALLOWED_HOSTS=* + - DJANGO_SUPERUSER_PASSWORD=password + - POSTGRES_DB=mathesar_django + - POSTGRES_USER=mathesar + - POSTGRES_PASSWORD=mathesar + - POSTGRES_HOST=mathesar-test-db + - POSTGRES_PORT=5432 + volumes: + - .:/code/ + depends_on: + test-db: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl --fail http://localhost:8000/ || exit 1"] + interval: 1s + timeout: 1s + retries: 30 + start_period: 3s + expose: + - "8000" + test-runner: + container_name: mathesar-api-test-runner + build: + context: api_tests + args: + PYTHON_VERSION: ${PYTHON_VERSION-3.9-bookworm} + depends_on: + api-test-service: + condition: service_healthy + test-user-db: + condition: service_healthy + volumes: + - ./api_tests/:/code/ +volumes: + ui_node_modules_test: diff --git a/docs/README.md b/docs/README.md index dfa72d599c..1c0420d829 100644 --- a/docs/README.md +++ b/docs/README.md @@ -152,6 +152,23 @@ We have some custom code in `overrides/404.html` which is pretty weird! - We have some customizations to the version switcher which are applied within the `extra.css` file. - If you modify this, you'll need to port those modifications to all published versions so that the user experience is consistent when switching between versions. +## Spell-checking + +We use the [`mkdocs-spellcheck`](https://github.com/pawamoy/mkdocs-spellcheck) plugin to spell-check our documentation. + +- CI will fail if there are any spelling errors. + +- To check for spelling errors locally, run: + + ``` + mkdocs build --strict + ``` + + Spelling errors will be reported as warnings when building in strict mode. + +- To configure ignored words, see the `spellcheck` section in `mkdocs.yml` and refer to the [plugin docs](https://github.com/pawamoy/mkdocs-spellcheck) as needed. + +- If you happen to be writing documentation content using VS Code, we recommend the Code Spell Checker extension (id: `streetsidesoftware.code-spell-checker`). It will highlight spelling errors in real-time as you type. ## Page redirects diff --git a/docs/docs/api/rpc.md b/docs/docs/api/rpc.md index e0c6b0e5b6..141d96cfeb 100644 --- a/docs/docs/api/rpc.md +++ b/docs/docs/api/rpc.md @@ -317,3 +317,19 @@ Unrecognized errors from a given library return a "round number" code, so an unk - replace_for_roles - transfer_ownership - TablePrivileges + +## Users + +::: users + options: + members: + - list_ + - get + - add + - delete + - patch_self + - patch_other + - replace_own + - revoke + - UserInfo + - UserDef diff --git a/docs/docs/releases/0.1.3.md b/docs/docs/releases/0.1.3.md index 7218f790f2..3cedfee6f5 100644 --- a/docs/docs/releases/0.1.3.md +++ b/docs/docs/releases/0.1.3.md @@ -54,7 +54,7 @@ This release: - Properly detect identity columns _([#3125](https://github.com/centerofci/mathesar/pull/3125))_ - Wiring sql functions for links and tables _([#3130](https://github.com/centerofci/mathesar/pull/3130))_ - Tests for alter table _([#3139](https://github.com/centerofci/mathesar/pull/3139))_ -- Add constraint copying to column extration logic _([#3168](https://github.com/centerofci/mathesar/pull/3168))_ +- Add constraint copying to column extraction logic _([#3168](https://github.com/centerofci/mathesar/pull/3168))_ ### Summarization improvements diff --git a/docs/docs/releases/0.1.4.md b/docs/docs/releases/0.1.4.md index d2f2854eb9..f5f416b9e3 100644 --- a/docs/docs/releases/0.1.4.md +++ b/docs/docs/releases/0.1.4.md @@ -40,7 +40,7 @@ _[#3186](https://github.com/mathesar-foundation/mathesar/pull/3186) [#3219](http ### Text-only imports -When importing CSV data, Mathesar now gives you the option to use `TEXT` as the database type for all columns. This choice speeds up the import for larger data sets by skipping the process of guessing colum types. +When importing CSV data, Mathesar now gives you the option to use `TEXT` as the database type for all columns. This choice speeds up the import for larger data sets by skipping the process of guessing column types. ![image](https://github.com/mathesar-foundation/mathesar/assets/42411/6e0b5b1c-2e10-4e1f-8ad3-f4d99d28d8a9) diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 25b24c0d98..060fe2c791 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -68,6 +68,17 @@ plugins: show_root_members_full_path: true show_source: false group_by_category: false + - spellcheck: + backends: + - codespell: + dictionaries: [clear] + known_words: + - Mathesar + ignore_code: yes + min_length: 2 + max_capital: 1 + allow_unicode: yes + strict_only: yes theme: name: material diff --git a/docs/requirements.txt b/docs/requirements.txt index d8a12071ad..82504b6f52 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,5 +4,6 @@ mkdocs-material==8.5.11 mkdocs-redirects==1.2.0 mkdocs-macros-plugin==0.7.0 mkdocs-placeholder-plugin==0.3.1 +mkdocs-spellcheck[codespell]==1.1.0 mkdocstrings==0.25.2 mkdocstrings-python==1.10.8 diff --git a/fixtures/utils.py b/fixtures/utils.py deleted file mode 100644 index 716c4ce4ef..0000000000 --- a/fixtures/utils.py +++ /dev/null @@ -1,69 +0,0 @@ -import pytest - - -def get_fixture_value(request, fixture_impl_function): - """ - A way to have fixtures whose scope dynamically matches that of the caller. Pytest does not - provide dynamicly-scoped fixtures: this is a workaround for that. - - Scoped variants of the fixture must already have been created using `create_scoped_fixtures`. - """ - scope = request.scope - scoped_fixture_name = _get_scoped_fixture_name(fixture_impl_function, scope) - return request.getfixturevalue(scoped_fixture_name) - - -def create_scoped_fixtures(globals, fixture_impl_function): - """ - Simplifies creating multiple identical, but differently scoped, fixtures. - - For every pytest fixture scope, creates a fixture with that scope, with the body of - `fixture_impl_function`, and the name of `fixture_impl_function` prepended with `FUN_`, `CLA_`, - `MOD_` or `SES_` (depending on resulting scope), and then adds that fixture to the provided - `globals` dict. - - E.g. given a fixture implementation function named `create_db`, this will add 4 fixtures - to the passed `globals` dict: a function-scoped `FUN_create_db`, a class-scoped `CLA_create_db`, - a module-scoped `MOD_create_db`, and a session-scoped `SES_create_db`. - - Since this method is adding dynamically-named stuff to `globals()`, a developer might have a - hard time understanding where a given global member is being defined. To help with that, we - prepend a comment to the `create_scoped_fixtures` call that lists the global variables it is - expected to introduce, like so: - - ``` - # defines: - # FUN_create_dj_db - # CLA_create_dj_db - # MOD_create_dj_db - # SES_create_dj_db - create_scoped_fixtures(globals(), create_dj_db) - ``` - """ - for scope in ('function', 'class', 'module', 'session'): - scoped_fixture_name = _get_scoped_fixture_name(fixture_impl_function, scope) - globals[scoped_fixture_name] = pytest.fixture( - fixture_impl_function, - scope=scope, - name=scoped_fixture_name - ) - - -def _get_scoped_fixture_name(fixture_impl_function, scope): - """ - Produces names like "FUN_some_fixture", "FUN" signifying the "function" scope. - """ - shorthand = { - 'function': 'FUN', - 'class': 'CLA', - 'module': 'MOD', - 'session': 'SES', - }.get(scope) - assert shorthand is not None - fixture_impl_name = _get_function_name(fixture_impl_function) - scoped_fixture_name = shorthand + '_' + fixture_impl_name - return scoped_fixture_name - - -def _get_function_name(f): - return f.__name__ diff --git a/mathesar/analytics.py b/mathesar/analytics.py new file mode 100644 index 0000000000..d4d0f5400b --- /dev/null +++ b/mathesar/analytics.py @@ -0,0 +1,139 @@ +""" +This module contains functions for dealing with analytics in Mathesar. + +The basic principle is: If there is an installation_id, analytics are +"turned on", and can and will be collected. Otherwise they won't. + +Thus, the `disable_analytics` function simply deletes that ID, if it +exists. +""" +from functools import wraps +import threading +from uuid import uuid4 + +from django.core.cache import cache +from django.conf import settings +from django.db.models import Q +from django.utils import timezone +import requests + +from mathesar import __version__ +from mathesar.models import ( + AnalyticsReport, + ConfiguredRole, + Database, + Explorations, + InstallationID, + User, +) + +ANALYTICS_DONE = "analytics_done" +CACHE_TIMEOUT = 1800 # seconds +ACTIVE_USER_DAYS = 14 +ANALYTICS_REPORT_MAX_AGE = 30 # days +ANALYTICS_FREQUENCY = 1 # a report is saved at most once per day. + + +def wire_analytics(f): + @wraps(f) + def wrapped(*args, **kwargs): + if settings.TEST is False and cache.get(ANALYTICS_DONE) is None: + cache.set(ANALYTICS_DONE, True, CACHE_TIMEOUT) + threading.Thread(target=run_analytics).start() + return f(*args, **kwargs) + return wrapped + + +def run_analytics(): + if ( + InstallationID.objects.first() is not None + and not AnalyticsReport.objects.filter( + created_at__gte=timezone.now() + - timezone.timedelta(days=ANALYTICS_FREQUENCY) + ) + ): + save_analytics_report() + upload_analytics_reports() + delete_stale_reports() + + +def initialize_analytics(): + InstallationID.objects.create(value=uuid4()) + + +def disable_analytics(): + InstallationID.objects.all().delete() + + +def save_analytics_report(): + installation_id = InstallationID.objects.first() + if installation_id is None: + return + connected_database_count = 0 + connected_database_schema_count = 0 + connected_database_table_count = 0 + connected_database_record_count = 0 + for d in Database.objects.all(): + try: + object_counts = d.object_counts + connected_database_count += 1 + connected_database_schema_count += object_counts['schema_count'] + connected_database_table_count += object_counts['table_count'] + connected_database_record_count += object_counts['record_count'] + except Exception: + print(f"Couldn't retrieve object counts for {d.name}") + + analytics_report = AnalyticsReport( + installation_id=installation_id, + mathesar_version=__version__, + user_count=User.objects.filter(is_active=True).count(), + active_user_count=User.objects.filter( + is_active=True, + last_login__gte=timezone.now() + - timezone.timedelta(days=ACTIVE_USER_DAYS) + ).count(), + configured_role_count=ConfiguredRole.objects.count(), + connected_database_count=connected_database_count, + connected_database_schema_count=connected_database_schema_count, + connected_database_table_count=connected_database_table_count, + connected_database_record_count=connected_database_record_count, + exploration_count=Explorations.objects.count(), + ) + analytics_report.save() + + +def upload_analytics_reports(): + reports = AnalyticsReport.objects.filter(uploaded=False) + reports_blob = [ + { + "id": report.id, + "created_at": report.created_at.isoformat(), + "installation_id": str(report.installation_id.value), + "mathesar_version": report.mathesar_version, + "user_count": report.user_count, + "active_user_count": report.active_user_count, + "configured_role_count": report.configured_role_count, + "connected_database_count": report.connected_database_count, + "connected_database_schema_count": report.connected_database_schema_count, + "connected_database_table_count": report.connected_database_table_count, + "connected_database_record_count": report.connected_database_record_count, + "exploration_count": report.exploration_count, + } + for report in reports + ] + requests.post(settings.MATHESAR_ANALYTICS_URL, json=reports_blob) + reports.update(uploaded=True) + + +def delete_stale_reports(): + AnalyticsReport.objects.filter( + Q( + # Delete uploaded analytics objects older than 2 days + uploaded=True, + created_at__lte=timezone.now() - timezone.timedelta(days=2) + ) | Q( + # Delete analytics reports after a time regardless of upload status + updated_at__lte=timezone.now() + - timezone.timedelta(days=ANALYTICS_REPORT_MAX_AGE) + ) + ).delete() diff --git a/mathesar/api/permission_conditions.py b/mathesar/api/permission_conditions.py deleted file mode 100644 index 3521fa4c96..0000000000 --- a/mathesar/api/permission_conditions.py +++ /dev/null @@ -1,11 +0,0 @@ -# These are available to all AccessPolicy instances -# https://rsinger86.github.io/drf-access-policy/reusable_conditions/ - - -def is_superuser(request, view, action): - return request.user.is_superuser - - -def is_self(request, view, action): - user = view.get_object() - return request.user == user diff --git a/mathesar/api/permissions/__init__.py b/mathesar/api/permissions/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/mathesar/api/permissions/users.py b/mathesar/api/permissions/users.py deleted file mode 100644 index 8da687f94c..0000000000 --- a/mathesar/api/permissions/users.py +++ /dev/null @@ -1,34 +0,0 @@ -from rest_access_policy import AccessPolicy - - -class UserAccessPolicy(AccessPolicy): - statements = [ - # Anyone can read all users - { - 'action': ['list', 'retrieve', 'password_change'], - 'principal': '*', - 'effect': 'allow' - }, - # Only superusers can create users - { - 'action': ['create', 'password_reset'], - 'principal': '*', - 'effect': 'allow', - 'condition': 'is_superuser' - }, - # Users can edit and delete themselves - # Superusers can also edit and delete users - { - 'action': ['destroy', 'partial_update', 'update'], - 'principal': ['*'], - 'effect': 'allow', - 'condition_expression': ['(is_superuser or is_self)'] - }, - ] - - @classmethod - def scope_fields(cls, request, fields, instance=None): - # Don't show emails except to admins or self - if not (request.user.is_superuser or request.user == instance): - fields.pop('email', None) - return fields diff --git a/mathesar/api/serializers/users.py b/mathesar/api/serializers/users.py deleted file mode 100644 index ff33e32b69..0000000000 --- a/mathesar/api/serializers/users.py +++ /dev/null @@ -1,77 +0,0 @@ -from django.contrib.auth.password_validation import validate_password -from django.core.exceptions import ValidationError as DjangoValidationError -from rest_access_policy import FieldAccessMixin -from rest_framework import serializers - -from mathesar.api.exceptions.mixins import MathesarErrorMessageMixin -from mathesar.api.exceptions.validation_exceptions.exceptions import IncorrectOldPassword -from mathesar.api.permissions.users import UserAccessPolicy -from mathesar.models.users import User - - -class UserSerializer(MathesarErrorMessageMixin, FieldAccessMixin, serializers.ModelSerializer): - access_policy = UserAccessPolicy - - class Meta: - model = User - fields = [ - 'id', - 'full_name', - 'short_name', - 'username', - 'password', - 'email', - 'is_superuser', - 'display_language' - ] - extra_kwargs = { - 'password': {'write_only': True}, - } - - def get_fields(self): - fields = super().get_fields() - request = self.context.get("request", None) - if not hasattr(request, 'parser_context'): - return fields - kwargs = request.parser_context.get('kwargs') - if kwargs: - user_pk = kwargs.get('pk') - if user_pk: - if request.user.id == int(user_pk) or not request.user.is_superuser: - fields["is_superuser"].read_only = True - return fields - - def create(self, validated_data): - password = validated_data.pop('password') - user = User(**validated_data) - user.password_change_needed = True - user.set_password(password) - user.save() - return user - - -class ChangePasswordSerializer(MathesarErrorMessageMixin, serializers.Serializer): - password = serializers.CharField(write_only=True, required=True) - old_password = serializers.CharField(write_only=True, required=True) - - def validate_old_password(self, value): - user = self.context['request'].user - if user.check_password(value) is True: - return value - raise IncorrectOldPassword(field='old_password') - - def validate_password(self, value): - try: - validate_password(value) - except DjangoValidationError as e: - raise e - return value - - def update(self, instance, validated_data): - instance.set_password(validated_data['password']) - instance.save() - return instance - - -class PasswordResetSerializer(MathesarErrorMessageMixin, serializers.Serializer): - password = serializers.CharField(write_only=True, required=True) diff --git a/mathesar/api/viewsets/data_files.py b/mathesar/api/viewsets/data_files.py index fc20278de3..e5cf087a21 100644 --- a/mathesar/api/viewsets/data_files.py +++ b/mathesar/api/viewsets/data_files.py @@ -24,7 +24,7 @@ class DataFileViewSet(viewsets.GenericViewSet, ListModelMixin, RetrieveModelMixi filterset_class = DataFileFilter parser_classes = [MultiPartParser, JSONParser] - def partial_update(self, request, pk=None): + def partial_update(self, request, **kwargs): serializer = DataFileSerializer( data=request.data, context={'request': request}, partial=True ) diff --git a/mathesar/api/viewsets/users.py b/mathesar/api/viewsets/users.py deleted file mode 100644 index 1bcb2c30e4..0000000000 --- a/mathesar/api/viewsets/users.py +++ /dev/null @@ -1,42 +0,0 @@ -from django.contrib.auth import get_user_model -from rest_access_policy import AccessViewSetMixin -from rest_framework import status, viewsets -from rest_framework.decorators import action -from rest_framework.generics import get_object_or_404 -from rest_framework.response import Response - -from mathesar.api.serializers.users import ( - ChangePasswordSerializer, PasswordResetSerializer, UserSerializer, -) -from mathesar.api.pagination import DefaultLimitOffsetPagination -from mathesar.api.permissions.users import UserAccessPolicy - - -class UserViewSet(AccessViewSetMixin, viewsets.ModelViewSet): - queryset = get_user_model().objects.all().order_by('id') - serializer_class = UserSerializer - pagination_class = DefaultLimitOffsetPagination - access_policy = UserAccessPolicy - - @action(methods=['post'], detail=True) - def password_reset(self, request, pk=None): - serializer = PasswordResetSerializer(data=request.data, context={'request': request}) - serializer.is_valid(raise_exception=True) - user = get_object_or_404(get_user_model(), pk=pk) - password = serializer.validated_data["password"] - user.set_password(password) - # Make sure we redirect user to change password set by the admin on login - user.password_change_needed = True - user.save() - return Response(status=status.HTTP_200_OK) - - @action(methods=['post'], detail=False) - def password_change(self, request): - serializer = ChangePasswordSerializer( - instance=request.user, - data=request.data, - context={'request': request} - ) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(status=status.HTTP_200_OK) diff --git a/mathesar/examples/resources/library_without_checkouts.sql b/mathesar/examples/resources/library_without_checkouts.sql index 7233edd35b..08420e7e8b 100644 --- a/mathesar/examples/resources/library_without_checkouts.sql +++ b/mathesar/examples/resources/library_without_checkouts.sql @@ -7,15 +7,9 @@ CREATE TABLE "Authors" ( "Website" mathesar_types.uri ); -CREATE SEQUENCE "Authors_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Authors_id_seq" OWNED BY "Authors".id; +ALTER TABLE "Authors" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Authors_id_seq" +); -- Books @@ -34,15 +28,9 @@ CREATE TABLE "Books" ( "Publisher" integer ); -CREATE SEQUENCE "Books_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Books_id_seq" OWNED BY "Books".id; +ALTER TABLE "Books" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Books_id_seq" +); -- Checkouts @@ -56,15 +44,9 @@ CREATE TABLE "Checkouts" ( "Check In Time" timestamp without time zone ); -CREATE SEQUENCE "Checkouts_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Checkouts_id_seq" OWNED BY "Checkouts".id; +ALTER TABLE "Checkouts" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Checkouts_id_seq" +); -- Items @@ -78,15 +60,9 @@ CREATE TABLE "Items" ( ); -CREATE SEQUENCE "Items_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Items_id_seq" OWNED BY "Items".id; +ALTER TABLE "Items" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Items_id_seq" +); -- Media @@ -96,15 +72,9 @@ CREATE TABLE "Media" ( "Type" text ); -CREATE SEQUENCE "Media_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Media_id_seq" OWNED BY "Media".id; +ALTER TABLE "Media" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Media_id_seq" +); -- Patrons @@ -116,15 +86,9 @@ CREATE TABLE "Patrons" ( "Email" mathesar_types.email ); -CREATE SEQUENCE "Patrons_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Patrons_id_seq" OWNED BY "Patrons".id; +ALTER TABLE "Patrons" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Patrons_id_seq" +); -- Publishers @@ -134,32 +98,9 @@ CREATE TABLE "Publishers" ( "Name" text ); -CREATE SEQUENCE "Publishers_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Publishers_id_seq" OWNED BY "Publishers".id; - - --- Set up defaults -ALTER TABLE ONLY "Authors" - ALTER COLUMN id SET DEFAULT nextval('"Authors_id_seq"'::regclass); -ALTER TABLE ONLY "Books" - ALTER COLUMN id SET DEFAULT nextval('"Books_id_seq"'::regclass); -ALTER TABLE ONLY "Checkouts" - ALTER COLUMN id SET DEFAULT nextval('"Checkouts_id_seq"'::regclass); -ALTER TABLE ONLY "Items" - ALTER COLUMN id SET DEFAULT nextval('"Items_id_seq"'::regclass); -ALTER TABLE ONLY "Media" - ALTER COLUMN id SET DEFAULT nextval('"Media_id_seq"'::regclass); -ALTER TABLE ONLY "Patrons" - ALTER COLUMN id SET DEFAULT nextval('"Patrons_id_seq"'::regclass); -ALTER TABLE ONLY "Publishers" - ALTER COLUMN id SET DEFAULT nextval('"Publishers_id_seq"'::regclass); +ALTER TABLE "Publishers" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Publishers_id_seq" +); -- -- Data for Name: Authors; Type: TABLE DATA; Schema: Library Management; Owner: - diff --git a/mathesar/examples/resources/movie_collection_tables.sql b/mathesar/examples/resources/movie_collection_tables.sql index 95e1b35a2d..6fea21db6a 100644 --- a/mathesar/examples/resources/movie_collection_tables.sql +++ b/mathesar/examples/resources/movie_collection_tables.sql @@ -7,19 +7,9 @@ CREATE TABLE "Departments" ( "Name" text ); -CREATE SEQUENCE "Departments_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Departments_id_seq" OWNED BY "Departments".id; - -ALTER TABLE ONLY "Departments" - ALTER COLUMN id SET DEFAULT nextval('"Departments_id_seq"'::regclass); - +ALTER TABLE "Departments" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Departments_id_seq" +); -- Genres @@ -28,19 +18,9 @@ CREATE TABLE "Genres" ( "Name" text ); - -CREATE SEQUENCE "Genres_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Genres_id_seq" OWNED BY "Genres".id; - -ALTER TABLE ONLY "Genres" - ALTER COLUMN id SET DEFAULT nextval('"Genres_id_seq"'::regclass); +ALTER TABLE "Genres" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Genres_id_seq" +); -- Jobs @@ -50,18 +30,9 @@ CREATE TABLE "Jobs" ( "Name" text ); -CREATE SEQUENCE "Jobs_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Jobs_id_seq" OWNED BY "Jobs".id; - -ALTER TABLE ONLY "Jobs" - ALTER COLUMN id SET DEFAULT nextval('"Jobs_id_seq"'::regclass); +ALTER TABLE "Jobs" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Jobs_id_seq" +); -- Movie Cast Map @@ -74,18 +45,9 @@ CREATE TABLE "Movie Cast Map" ( "Credit Order" integer ); -CREATE SEQUENCE "Movie Cast Map_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Movie Cast Map_id_seq" OWNED BY "Movie Cast Map".id; - -ALTER TABLE ONLY "Movie Cast Map" - ALTER COLUMN id SET DEFAULT nextval('"Movie Cast Map_id_seq"'::regclass); +ALTER TABLE "Movie Cast Map" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Movie Cast Map_id_seq" +); -- Movie Crew Map @@ -98,18 +60,9 @@ CREATE TABLE "Movie Crew Map" ( "Crew Member" integer NOT NULL ); -CREATE SEQUENCE "Movie Crew Map_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Movie Crew Map_id_seq" OWNED BY "Movie Crew Map".id; - -ALTER TABLE ONLY "Movie Crew Map" - ALTER COLUMN id SET DEFAULT nextval('"Movie Crew Map_id_seq"'::regclass); +ALTER TABLE "Movie Crew Map" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Movie Crew Map_id_seq" +); -- Movie Genre Map @@ -120,18 +73,9 @@ CREATE TABLE "Movie Genre Map" ( "Genre" integer ); -CREATE SEQUENCE "Movie Genre Map_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Movie Genre Map_id_seq" OWNED BY "Movie Genre Map".id; - -ALTER TABLE ONLY "Movie Genre Map" - ALTER COLUMN id SET DEFAULT nextval('"Movie Genre Map_id_seq"'::regclass); +ALTER TABLE "Movie Genre Map" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Movie Genre Map_id_seq" +); -- Movie Production Company Map @@ -142,18 +86,9 @@ CREATE TABLE "Movie Production Company Map" ( "Production Company" integer ); -CREATE SEQUENCE "Movie Production Company Map_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Movie Production Company Map_id_seq" OWNED BY "Movie Production Company Map".id; - -ALTER TABLE ONLY "Movie Production Company Map" - ALTER COLUMN id SET DEFAULT nextval('"Movie Production Company Map_id_seq"'::regclass); +ALTER TABLE "Movie Production Company Map" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Movie Production Company Map_id_seq" +); -- Movie Production Country Map @@ -164,18 +99,9 @@ CREATE TABLE "Movie Production Country Map" ( "Production Country" integer NOT NULL ); -CREATE SEQUENCE "Movie Production Country Map_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Movie Production Country Map_id_seq" OWNED BY "Movie Production Country Map".id; - -ALTER TABLE ONLY "Movie Production Country Map" - ALTER COLUMN id SET DEFAULT nextval('"Movie Production Country Map_id_seq"'::regclass); +ALTER TABLE "Movie Production Country Map" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Movie Production Country Map_id_seq" +); -- Movie Spoken Language Map @@ -186,18 +112,9 @@ CREATE TABLE "Movie Spoken Language Map" ( "Spoken Language" integer NOT NULL ); -CREATE SEQUENCE "Movie Spoken Language Map_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Movie Spoken Language Map_id_seq" OWNED BY "Movie Spoken Language Map".id; - -ALTER TABLE ONLY "Movie Spoken Language Map" - ALTER COLUMN id SET DEFAULT nextval('"Movie Spoken Language Map_id_seq"'::regclass); +ALTER TABLE "Movie Spoken Language Map" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Movie Spoken Language Map_id_seq" +); -- Movies @@ -218,18 +135,9 @@ CREATE TABLE "Movies" ( "Original Language" text ); -CREATE SEQUENCE "Movies_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Movies_id_seq" OWNED BY "Movies".id; - -ALTER TABLE ONLY "Movies" - ALTER COLUMN id SET DEFAULT nextval('"Movies_id_seq"'::regclass); +ALTER TABLE "Movies" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Movies_id_seq" +); -- People @@ -239,18 +147,9 @@ CREATE TABLE "People" ( "Name" text ); -CREATE SEQUENCE "People_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "People_id_seq" OWNED BY "People".id; - -ALTER TABLE ONLY "People" - ALTER COLUMN id SET DEFAULT nextval('"People_id_seq"'::regclass); +ALTER TABLE "People" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "People_id_seq" +); -- Production Companies @@ -260,18 +159,9 @@ CREATE TABLE "Production Companies" ( "Name" text ); -CREATE SEQUENCE "Production Companies_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Production Companies_id_seq" OWNED BY "Production Companies".id; - -ALTER TABLE ONLY "Production Companies" - ALTER COLUMN id SET DEFAULT nextval('"Production Companies_id_seq"'::regclass); +ALTER TABLE "Production Companies" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Production Companies_id_seq" +); -- Production Countries @@ -282,18 +172,9 @@ CREATE TABLE "Production Countries" ( "ISO 3166-1" character(2) NOT NULL ); -CREATE SEQUENCE "Production Countries_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Production Countries_id_seq" OWNED BY "Production Countries".id; - -ALTER TABLE ONLY "Production Countries" - ALTER COLUMN id SET DEFAULT nextval('"Production Countries_id_seq"'::regclass); +ALTER TABLE "Production Countries" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Production Countries_id_seq" +); -- Spoken Languages @@ -304,18 +185,9 @@ CREATE TABLE "Spoken Languages" ( "ISO 639-1" character(2) NOT NULL ); -CREATE SEQUENCE "Spoken Languages_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Spoken Languages_id_seq" OWNED BY "Spoken Languages".id; - -ALTER TABLE ONLY "Spoken Languages" - ALTER COLUMN id SET DEFAULT nextval('"Spoken Languages_id_seq"'::regclass); +ALTER TABLE "Spoken Languages" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Spoken Languages_id_seq" +); -- Sub-Collections @@ -325,15 +197,6 @@ CREATE TABLE "Sub-Collections" ( "Name" text ); -CREATE SEQUENCE "Sub-Collections_id_seq" - AS integer - START WITH 1 - INCREMENT BY 1 - NO MINVALUE - NO MAXVALUE - CACHE 1; - -ALTER SEQUENCE "Sub-Collections_id_seq" OWNED BY "Sub-Collections".id; - -ALTER TABLE ONLY "Sub-Collections" - ALTER COLUMN id SET DEFAULT nextval('"Sub-Collections_id_seq"'::regclass); +ALTER TABLE "Sub-Collections" ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY ( + SEQUENCE NAME "Sub-Collections_id_seq" +); diff --git a/mathesar/migrations/0022_installationid_analyticsreport.py b/mathesar/migrations/0022_installationid_analyticsreport.py new file mode 100644 index 0000000000..eb5d06dfb4 --- /dev/null +++ b/mathesar/migrations/0022_installationid_analyticsreport.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.16 on 2024-12-09 07:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('mathesar', '0021_database_last_confirmed_sql_version'), + ] + + operations = [ + migrations.CreateModel( + name='InstallationID', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.IntegerField(default=1, primary_key=True, serialize=False)), + ('value', models.UUIDField()), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='AnalyticsReport', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('mathesar_version', models.CharField()), + ('user_count', models.PositiveIntegerField(blank=True, null=True)), + ('active_user_count', models.PositiveIntegerField(blank=True, null=True)), + ('configured_role_count', models.PositiveIntegerField(blank=True, null=True)), + ('connected_database_count', models.PositiveIntegerField(blank=True, null=True)), + ('connected_database_schema_count', models.PositiveIntegerField(blank=True, null=True)), + ('connected_database_table_count', models.PositiveIntegerField(blank=True, null=True)), + ('connected_database_record_count', models.PositiveBigIntegerField(blank=True, null=True)), + ('exploration_count', models.PositiveIntegerField(blank=True, null=True)), + ('uploaded', models.BooleanField(default=False)), + ('installation_id', models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='mathesar.installationid')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/mathesar/models/__init__.py b/mathesar/models/__init__.py index 1f6b3b1e86..4ae5987ba8 100644 --- a/mathesar/models/__init__.py +++ b/mathesar/models/__init__.py @@ -1,2 +1,4 @@ # We need to do this to register the model correctly in Django settings from .users import User # noqa +from .analytics import InstallationID, AnalyticsReport # noqa +from .base import * # noqa diff --git a/mathesar/models/analytics.py b/mathesar/models/analytics.py new file mode 100644 index 0000000000..88e0d2ff3b --- /dev/null +++ b/mathesar/models/analytics.py @@ -0,0 +1,24 @@ +from django.db import models +from mathesar.models.base import BaseModel + + +class InstallationID(BaseModel): + # We shouldn't increment this, since only one row is allowed. + id = models.IntegerField(primary_key=True, default=1) + value = models.UUIDField() + + +class AnalyticsReport(BaseModel): + installation_id = models.ForeignKey( + 'InstallationID', default=1, on_delete=models.CASCADE + ) + mathesar_version = models.CharField() + user_count = models.PositiveIntegerField(null=True, blank=True) + active_user_count = models.PositiveIntegerField(null=True, blank=True) + configured_role_count = models.PositiveIntegerField(null=True, blank=True) + connected_database_count = models.PositiveIntegerField(null=True, blank=True) + connected_database_schema_count = models.PositiveIntegerField(null=True, blank=True) + connected_database_table_count = models.PositiveIntegerField(null=True, blank=True) + connected_database_record_count = models.PositiveBigIntegerField(null=True, blank=True) + exploration_count = models.PositiveIntegerField(null=True, blank=True) + uploaded = models.BooleanField(default=False) diff --git a/mathesar/models/base.py b/mathesar/models/base.py index af90aa99c3..51cf0f5069 100644 --- a/mathesar/models/base.py +++ b/mathesar/models/base.py @@ -6,6 +6,7 @@ import psycopg from db.sql.install import install as install_sql +from db.analytics import get_object_counts from mathesar import __version__ from mathesar.models import exceptions @@ -47,6 +48,17 @@ class Meta: ) ] + @property + def object_counts(self): + for role_map in UserDatabaseRoleMap.objects.filter(database=self): + try: + with role_map.connection as conn: + return get_object_counts(conn) + except Exception: + pass + else: + raise exceptions.NoConnectionAvailable + @property def needs_upgrade_attention(self): return self.last_confirmed_sql_version != __version__ diff --git a/mathesar/rpc/collaborators.py b/mathesar/rpc/collaborators.py index 8273f9d4b7..a76ba88f7d 100644 --- a/mathesar/rpc/collaborators.py +++ b/mathesar/rpc/collaborators.py @@ -1,11 +1,8 @@ from typing import TypedDict -from modernrpc.core import rpc_method -from modernrpc.auth.basic import http_basic_auth_login_required, http_basic_auth_superuser_required - from mathesar.models.base import UserDatabaseRoleMap, Database, ConfiguredRole from mathesar.models.users import User -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method class CollaboratorInfo(TypedDict): @@ -33,9 +30,7 @@ def from_model(cls, model): ) -@rpc_method(name="collaborators.list") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="collaborators.list", auth="login") def list_(*, database_id: int = None, **kwargs) -> list[CollaboratorInfo]: """ List information about collaborators. Exposed as `list`. @@ -56,9 +51,7 @@ def list_(*, database_id: int = None, **kwargs) -> list[CollaboratorInfo]: return [CollaboratorInfo.from_model(db_model) for db_model in user_database_role_map_qs] -@rpc_method(name='collaborators.add') -@http_basic_auth_superuser_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="collaborators.add") def add( *, database_id: int, @@ -86,9 +79,7 @@ def add( return CollaboratorInfo.from_model(collaborator) -@rpc_method(name='collaborators.delete') -@http_basic_auth_superuser_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="collaborators.delete") def delete(*, collaborator_id: int, **kwargs): """ Delete a collaborator from a database. @@ -100,9 +91,7 @@ def delete(*, collaborator_id: int, **kwargs): collaborator.delete() -@rpc_method(name='collaborators.set_role') -@http_basic_auth_superuser_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="collaborators.set_role") def set_role( *, collaborator_id: int, diff --git a/mathesar/rpc/columns/base.py b/mathesar/rpc/columns/base.py index affe4bb88c..aaddfe329b 100644 --- a/mathesar/rpc/columns/base.py +++ b/mathesar/rpc/columns/base.py @@ -3,8 +3,7 @@ """ from typing import Literal, Optional, TypedDict -from modernrpc.core import rpc_method, REQUEST_KEY -from modernrpc.auth.basic import http_basic_auth_login_required +from modernrpc.core import REQUEST_KEY from db.columns import ( add_columns_to_table, @@ -13,7 +12,7 @@ get_column_info_for_table, ) from mathesar.rpc.columns.metadata import ColumnMetaDataBlob -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method from mathesar.rpc.utils import connect from mathesar.utils.columns import get_columns_meta_data @@ -195,9 +194,7 @@ def from_dict(cls, col_info): ) -@rpc_method(name="columns.list") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="columns.list", auth="login") def list_(*, table_oid: int, database_id: int, **kwargs) -> list[ColumnInfo]: """ List information about columns for a table. Exposed as `list`. @@ -215,9 +212,7 @@ def list_(*, table_oid: int, database_id: int, **kwargs) -> list[ColumnInfo]: return [ColumnInfo.from_dict(col) for col in raw_column_info] -@rpc_method(name="columns.add") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="columns.add", auth="login") def add( *, column_data_list: list[CreatableColumnInfo], @@ -245,9 +240,7 @@ def add( return add_columns_to_table(table_oid, column_data_list, conn) -@rpc_method(name="columns.patch") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="columns.patch", auth="login") def patch( *, column_data_list: list[SettableColumnInfo], @@ -273,9 +266,7 @@ def patch( return alter_columns_in_table(table_oid, column_data_list, conn) -@rpc_method(name="columns.delete") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="columns.delete", auth="login") def delete( *, column_attnums: list[int], table_oid: int, database_id: int, **kwargs ) -> int: @@ -295,9 +286,7 @@ def delete( return drop_columns_from_table(table_oid, column_attnums, conn) -@rpc_method(name="columns.list_with_metadata") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="columns.list_with_metadata", auth="login") def list_with_metadata(*, table_oid: int, database_id: int, **kwargs) -> list: """ List information about columns for a table, along with the metadata associated with each column. diff --git a/mathesar/rpc/columns/metadata.py b/mathesar/rpc/columns/metadata.py index 4b3876de05..d58c7da4bc 100644 --- a/mathesar/rpc/columns/metadata.py +++ b/mathesar/rpc/columns/metadata.py @@ -3,10 +3,7 @@ """ from typing import Literal, Optional, TypedDict -from modernrpc.core import rpc_method -from modernrpc.auth.basic import http_basic_auth_login_required - -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method from mathesar.utils.columns import get_columns_meta_data, set_columns_meta_data @@ -128,9 +125,7 @@ def from_model(cls, model): ) -@rpc_method(name="columns.metadata.list") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="columns.metadata.list", auth="login") def list_(*, table_oid: int, database_id: int, **kwargs) -> list[ColumnMetaDataRecord]: """ List metadata associated with columns for a table. Exposed as `list`. @@ -148,9 +143,7 @@ def list_(*, table_oid: int, database_id: int, **kwargs) -> list[ColumnMetaDataR ] -@rpc_method(name="columns.metadata.set") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="columns.metadata.set", auth="login") def set_( *, column_meta_data_list: list[ColumnMetaDataBlob], diff --git a/mathesar/rpc/constraints.py b/mathesar/rpc/constraints.py index eb7fb202cb..26abeeff74 100644 --- a/mathesar/rpc/constraints.py +++ b/mathesar/rpc/constraints.py @@ -3,15 +3,14 @@ """ from typing import Optional, TypedDict, Union -from modernrpc.core import rpc_method, REQUEST_KEY -from modernrpc.auth.basic import http_basic_auth_login_required +from modernrpc.core import REQUEST_KEY from db.constraints import ( get_constraints_for_table, create_constraint, drop_constraint_via_oid, ) -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method from mathesar.rpc.utils import connect @@ -78,7 +77,7 @@ class UniqueConstraint(TypedDict): CreatableConstraintInfo = list[Union[ForeignKeyConstraint, PrimaryKeyConstraint, UniqueConstraint]] """ -Type alias for a list of createable constraints which can be unique, primary key, or foreign key constraints. +Type alias for a list of creatable constraints which can be unique, primary key, or foreign key constraints. """ @@ -113,9 +112,7 @@ def from_dict(cls, con_info): ) -@rpc_method(name="constraints.list") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="constraints.list", auth="login") def list_(*, table_oid: int, database_id: int, **kwargs) -> list[ConstraintInfo]: """ List information about constraints in a table. Exposed as `list`. @@ -133,9 +130,7 @@ def list_(*, table_oid: int, database_id: int, **kwargs) -> list[ConstraintInfo] return [ConstraintInfo.from_dict(con) for con in con_info] -@rpc_method(name="constraints.add") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="constraints.add", auth="login") def add( *, table_oid: int, @@ -158,9 +153,7 @@ def add( return create_constraint(table_oid, constraint_def_list, conn) -@rpc_method(name="constraints.delete") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="constraints.delete", auth="login") def delete(*, table_oid: int, constraint_oid: int, database_id: int, **kwargs) -> str: """ Delete a constraint from a table. diff --git a/mathesar/rpc/data_modeling.py b/mathesar/rpc/data_modeling.py index a9054e5d5e..eb069744fc 100644 --- a/mathesar/rpc/data_modeling.py +++ b/mathesar/rpc/data_modeling.py @@ -3,17 +3,14 @@ """ from typing import TypedDict -from modernrpc.core import rpc_method, REQUEST_KEY -from modernrpc.auth.basic import http_basic_auth_login_required +from modernrpc.core import REQUEST_KEY from db import links, tables -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method from mathesar.rpc.utils import connect -@rpc_method(name="data_modeling.add_foreign_key_column") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="data_modeling.add_foreign_key_column", auth="login") def add_foreign_key_column( *, column_name: str, @@ -52,9 +49,7 @@ class MappingColumn(TypedDict): referent_table_oid: int -@rpc_method(name="data_modeling.add_mapping_table") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="data_modeling.add_mapping_table", auth="login") def add_mapping_table( *, table_name: str, @@ -82,9 +77,7 @@ def add_mapping_table( ) -@rpc_method(name="data_modeling.suggest_types") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="data_modeling.suggest_types", auth="login") def suggest_types(*, table_oid: int, database_id: int, **kwargs) -> dict: """ Infer the best type for each column in the table. @@ -118,9 +111,7 @@ class SplitTableInfo(TypedDict): new_fkey_attnum: int -@rpc_method(name="data_modeling.split_table") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="data_modeling.split_table", auth="login") def split_table( *, table_oid: int, @@ -154,9 +145,7 @@ def split_table( ) -@rpc_method(name="data_modeling.move_columns") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="data_modeling.move_columns", auth="login") def move_columns( *, source_table_oid: int, diff --git a/mathesar/rpc/databases/base.py b/mathesar/rpc/databases/base.py index 365eecd2a5..cc464bbdc7 100644 --- a/mathesar/rpc/databases/base.py +++ b/mathesar/rpc/databases/base.py @@ -1,15 +1,11 @@ from typing import Literal, TypedDict -from modernrpc.core import rpc_method, REQUEST_KEY -from modernrpc.auth.basic import ( - http_basic_auth_login_required, - http_basic_auth_superuser_required, -) +from modernrpc.core import REQUEST_KEY from db.databases import get_database, drop_database from mathesar.models.base import Database from mathesar.rpc.utils import connect -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method class DatabaseInfo(TypedDict): @@ -26,7 +22,7 @@ class DatabaseInfo(TypedDict): oid: int name: str owner_oid: int - current_role_priv: list[Literal['CONNECT', 'CREATE', 'TEMPORARY']] + current_role_priv: list[Literal["CONNECT", "CREATE", "TEMPORARY"]] current_role_owns: bool @classmethod @@ -40,9 +36,7 @@ def from_dict(cls, d): ) -@rpc_method(name="databases.get") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="databases.get", auth="login") def get(*, database_id: int, **kwargs) -> DatabaseInfo: """ Get information about a database. @@ -59,9 +53,7 @@ def get(*, database_id: int, **kwargs) -> DatabaseInfo: return DatabaseInfo.from_dict(db_info) -@rpc_method(name="databases.delete") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="databases.delete", auth="login") def delete(*, database_oid: int, database_id: int, **kwargs) -> None: """ Drop a database from the server. @@ -75,9 +67,7 @@ def delete(*, database_oid: int, database_id: int, **kwargs) -> None: drop_database(database_oid, conn) -@rpc_method(name="databases.upgrade_sql") -@http_basic_auth_superuser_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="databases.upgrade_sql") def upgrade_sql( *, database_id: int, username: str = None, password: str = None ) -> None: diff --git a/mathesar/rpc/databases/configured.py b/mathesar/rpc/databases/configured.py index 12166e62bb..692da8876c 100644 --- a/mathesar/rpc/databases/configured.py +++ b/mathesar/rpc/databases/configured.py @@ -1,10 +1,9 @@ from typing import TypedDict -from modernrpc.core import rpc_method, REQUEST_KEY -from modernrpc.auth.basic import http_basic_auth_login_required, http_basic_auth_superuser_required +from modernrpc.core import REQUEST_KEY from mathesar.models.base import Database -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method class ConfiguredDatabaseInfo(TypedDict): @@ -37,9 +36,7 @@ def from_model(cls, model): ) -@rpc_method(name="databases.configured.list") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="databases.configured.list", auth='login') def list_(*, server_id: int = None, **kwargs) -> list[ConfiguredDatabaseInfo]: """ List information about databases for a server. Exposed as `list`. @@ -68,9 +65,7 @@ def list_(*, server_id: int = None, **kwargs) -> list[ConfiguredDatabaseInfo]: return [ConfiguredDatabaseInfo.from_model(db_model) for db_model in database_qs] -@rpc_method(name="databases.configured.disconnect") -@http_basic_auth_superuser_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="databases.configured.disconnect") def disconnect(*, database_id: int, **kwargs) -> None: """ Disconnect a configured database. diff --git a/mathesar/rpc/databases/privileges.py b/mathesar/rpc/databases/privileges.py index cf2937031e..3bf5e9292d 100644 --- a/mathesar/rpc/databases/privileges.py +++ b/mathesar/rpc/databases/privileges.py @@ -1,7 +1,6 @@ from typing import Literal, TypedDict -from modernrpc.core import rpc_method, REQUEST_KEY -from modernrpc.auth.basic import http_basic_auth_login_required +from modernrpc.core import REQUEST_KEY from db.roles import ( list_db_priv, @@ -10,7 +9,7 @@ ) from mathesar.rpc.databases.base import DatabaseInfo from mathesar.rpc.utils import connect -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method class DBPrivileges(TypedDict): @@ -19,7 +18,7 @@ class DBPrivileges(TypedDict): Attributes: role_oid: The `oid` of the role on the database server. - direct: A list of database privileges for the afforementioned role_oid. + direct: A list of database privileges for the aforementioned role_oid. """ role_oid: int direct: list[Literal['CONNECT', 'CREATE', 'TEMPORARY']] @@ -32,9 +31,7 @@ def from_dict(cls, d): ) -@rpc_method(name="databases.privileges.list_direct") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="databases.privileges.list_direct", auth="login") def list_direct(*, database_id: int, **kwargs) -> list[DBPrivileges]: """ List database privileges for non-inherited roles. @@ -51,9 +48,7 @@ def list_direct(*, database_id: int, **kwargs) -> list[DBPrivileges]: return [DBPrivileges.from_dict(i) for i in raw_db_priv] -@rpc_method(name="databases.privileges.replace_for_roles") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="databases.privileges.replace_for_roles", auth="login") def replace_for_roles( *, privileges: list[DBPrivileges], database_id: int, **kwargs ) -> list[DBPrivileges]: @@ -84,9 +79,7 @@ def replace_for_roles( return [DBPrivileges.from_dict(i) for i in raw_db_priv] -@rpc_method(name="databases.privileges.transfer_ownership") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="databases.privileges.transfer_ownership", auth="login") def transfer_ownership(*, new_owner_oid: int, database_id: int, **kwargs) -> DatabaseInfo: """ Transfers ownership of the current database to a new owner. diff --git a/mathesar/rpc/databases/setup.py b/mathesar/rpc/databases/setup.py index d423d3438e..23b8168027 100644 --- a/mathesar/rpc/databases/setup.py +++ b/mathesar/rpc/databases/setup.py @@ -3,14 +3,13 @@ """ from typing import TypedDict -from modernrpc.core import rpc_method, REQUEST_KEY -from modernrpc.auth.basic import http_basic_auth_superuser_required +from modernrpc.core import REQUEST_KEY from mathesar.utils import permissions -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions from mathesar.rpc.servers.configured import ConfiguredServerInfo from mathesar.rpc.databases.configured import ConfiguredDatabaseInfo from mathesar.rpc.roles.configured import ConfiguredRoleInfo +from mathesar.rpc.decorators import mathesar_rpc_method class DatabaseConnectionResult(TypedDict): @@ -38,9 +37,7 @@ def from_model(cls, model): ) -@rpc_method(name='databases.setup.create_new') -@http_basic_auth_superuser_required -@handle_rpc_exceptions +@mathesar_rpc_method(name='databases.setup.create_new') def create_new( *, database: str, @@ -66,9 +63,7 @@ def create_new( return DatabaseConnectionResult.from_model(result) -@rpc_method(name='databases.setup.connect_existing') -@http_basic_auth_superuser_required -@handle_rpc_exceptions +@mathesar_rpc_method(name='databases.setup.connect_existing') def connect_existing( *, host: str, diff --git a/mathesar/rpc/decorators.py b/mathesar/rpc/decorators.py new file mode 100644 index 0000000000..763731ede4 --- /dev/null +++ b/mathesar/rpc/decorators.py @@ -0,0 +1,29 @@ +from modernrpc.core import rpc_method +from modernrpc.auth.basic import ( + http_basic_auth_login_required, + http_basic_auth_superuser_required, +) +from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.analytics import wire_analytics + + +def mathesar_rpc_method(*, name, auth="superuser"): + """ + Construct a decorator to add RPC functionality to functions. + + Args: + name: the name of the function that exposed at the RPC endpoint. + auth: the authorization wrapper for the function. + - "superuser" (default): only superusers can call it. + - "login": any logged in user can call it. + """ + if auth == "login": + auth_wrap = http_basic_auth_login_required + elif auth == "superuser": + auth_wrap = http_basic_auth_superuser_required + else: + raise Exception("`auth` must be 'superuser' or 'login'") + + def combo_decorator(f): + return rpc_method(name=name)(auth_wrap(wire_analytics(handle_rpc_exceptions(f)))) + return combo_decorator diff --git a/mathesar/rpc/explorations.py b/mathesar/rpc/explorations.py index 2b79a76516..37889f2e4e 100644 --- a/mathesar/rpc/explorations.py +++ b/mathesar/rpc/explorations.py @@ -3,11 +3,10 @@ """ from typing import Optional, TypedDict -from modernrpc.core import rpc_method, REQUEST_KEY -from modernrpc.auth.basic import http_basic_auth_login_required +from modernrpc.core import REQUEST_KEY from mathesar.models.base import Explorations -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method from mathesar.rpc.utils import connect from mathesar.utils.explorations import ( list_explorations, @@ -120,9 +119,7 @@ def from_dict(cls, e): ) -@rpc_method(name="explorations.list") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="explorations.list", auth="login") def list_(*, database_id: int, schema_oid: int = None, **kwargs) -> list[ExplorationInfo]: """ List information about explorations for a database. Exposed as `list`. @@ -138,9 +135,7 @@ def list_(*, database_id: int, schema_oid: int = None, **kwargs) -> list[Explora return [ExplorationInfo.from_model(exploration) for exploration in explorations] -@rpc_method(name="explorations.get") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="explorations.get", auth="login") def get(*, exploration_id: int, **kwargs) -> ExplorationInfo: """ List information about an exploration. @@ -155,9 +150,7 @@ def get(*, exploration_id: int, **kwargs) -> ExplorationInfo: return ExplorationInfo.from_model(exploration) -@rpc_method(name="explorations.delete") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="explorations.delete", auth="login") def delete(*, exploration_id: int, **kwargs) -> None: """ Delete an exploration. @@ -168,9 +161,7 @@ def delete(*, exploration_id: int, **kwargs) -> None: delete_exploration(exploration_id) -@rpc_method(name="explorations.run") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="explorations.run", auth="login") def run(*, exploration_def: ExplorationDef, limit: int = 100, offset: int = 0, **kwargs) -> ExplorationResult: """ Run an exploration. @@ -184,14 +175,12 @@ def run(*, exploration_def: ExplorationDef, limit: int = 100, offset: int = 0, * The result of the exploration run. """ user = kwargs.get(REQUEST_KEY).user - with connect(exploration_def['database_id'], user) as conn: + with connect(exploration_def["database_id"], user) as conn: exploration_result = run_exploration(exploration_def, conn, limit, offset) return ExplorationResult.from_dict(exploration_result) -@rpc_method(name='explorations.run_saved') -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="explorations.run_saved", auth="login") def run_saved(*, exploration_id: int, limit: int = 100, offset: int = 0, **kwargs) -> ExplorationResult: """ Run a saved exploration. @@ -211,9 +200,7 @@ def run_saved(*, exploration_id: int, limit: int = 100, offset: int = 0, **kwarg return ExplorationResult.from_dict(exploration_result) -@rpc_method(name='explorations.replace') -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="explorations.replace", auth="login") def replace(*, new_exploration: ExplorationInfo) -> ExplorationInfo: """ Replace a saved exploration. @@ -228,9 +215,7 @@ def replace(*, new_exploration: ExplorationInfo) -> ExplorationInfo: return ExplorationInfo.from_model(replaced_exp_model) -@rpc_method(name='explorations.add') -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="explorations.add", auth="login") def add(*, exploration_def: ExplorationDef) -> ExplorationInfo: """ Add a new exploration. diff --git a/mathesar/rpc/records.py b/mathesar/rpc/records.py index 54b75be298..496188555f 100644 --- a/mathesar/rpc/records.py +++ b/mathesar/rpc/records.py @@ -3,8 +3,7 @@ """ from typing import Any, Literal, Optional, TypedDict, Union -from modernrpc.core import rpc_method, REQUEST_KEY -from modernrpc.auth.basic import http_basic_auth_login_required +from modernrpc.core import REQUEST_KEY from db.records import ( list_records_from_table, @@ -14,7 +13,7 @@ add_record_to_table, patch_record_in_table, ) -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method from mathesar.rpc.utils import connect from mathesar.utils.tables import get_table_record_summary_templates @@ -90,7 +89,7 @@ class Grouping(TypedDict): Attributes: columns: The columns to be grouped by. - preproc: The preprocessing funtions to apply (if any). + preproc: The preprocessing functions to apply (if any). """ columns: list[int] preproc: list[str] @@ -124,7 +123,7 @@ class GroupingResponse(TypedDict): Attributes: columns: The columns to be grouped by. - preproc: The preprocessing funtions to apply (if any). + preproc: The preprocessing functions to apply (if any). groups: The groups applicable to the records being returned. """ columns: list[int] @@ -196,9 +195,7 @@ def from_dict(cls, d): ) -@rpc_method(name="records.list") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="records.list", auth="login") def list_( *, table_oid: int, @@ -245,9 +242,7 @@ def list_( return RecordList.from_dict(record_info) -@rpc_method(name="records.get") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="records.get", auth="login") def get( *, record_id: Any, @@ -291,9 +286,7 @@ def get( return RecordList.from_dict(record_info) -@rpc_method(name="records.add") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="records.add", auth="login") def add( *, record_def: dict, @@ -334,9 +327,7 @@ def add( return RecordAdded.from_dict(record_info) -@rpc_method(name="records.patch") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="records.patch", auth="login") def patch( *, record_def: dict, @@ -379,9 +370,7 @@ def patch( return RecordAdded.from_dict(record_info) -@rpc_method(name="records.delete") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="records.delete", auth="login") def delete( *, record_ids: list[Any], @@ -410,9 +399,7 @@ def delete( return num_deleted -@rpc_method(name="records.search") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="records.search", auth="login") def search( *, table_oid: int, diff --git a/mathesar/rpc/roles/base.py b/mathesar/rpc/roles/base.py index afc3535d85..814c530ef7 100644 --- a/mathesar/rpc/roles/base.py +++ b/mathesar/rpc/roles/base.py @@ -3,11 +3,8 @@ """ from typing import Optional, TypedDict -from modernrpc.core import rpc_method, REQUEST_KEY -from modernrpc.auth.basic import http_basic_auth_login_required +from modernrpc.core import REQUEST_KEY -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions -from mathesar.rpc.utils import connect from db.roles import ( create_role, drop_role, @@ -15,6 +12,8 @@ list_roles, set_members_to_role, ) +from mathesar.rpc.decorators import mathesar_rpc_method +from mathesar.rpc.utils import connect class RoleMember(TypedDict): @@ -44,7 +43,7 @@ class RoleInfo(TypedDict): description: A description of the role members: The member roles that directly inherit the role. - Refer PostgreSQL documenation on: + Refer PostgreSQL documentation on: - [pg_roles table](https://www.postgresql.org/docs/current/view-pg-roles.html). - [Role attributes](https://www.postgresql.org/docs/current/role-attributes.html) - [Role membership](https://www.postgresql.org/docs/current/role-membership.html) @@ -74,9 +73,7 @@ def from_dict(cls, d): ) -@rpc_method(name="roles.list") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="roles.list", auth="login") def list_(*, database_id: int, **kwargs) -> list[RoleInfo]: """ List information about roles for a database server. Exposed as `list`. @@ -94,9 +91,7 @@ def list_(*, database_id: int, **kwargs) -> list[RoleInfo]: return [RoleInfo.from_dict(role) for role in roles] -@rpc_method(name="roles.add") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="roles.add", auth="login") def add( *, rolename: str, @@ -123,9 +118,7 @@ def add( return RoleInfo.from_dict(role) -@rpc_method(name="roles.delete") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="roles.delete", auth="login") def delete( *, role_oid: int, @@ -144,9 +137,7 @@ def delete( drop_role(role_oid, conn) -@rpc_method(name="roles.get_current_role") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="roles.get_current_role", auth="login") def get_current_role(*, database_id: int, **kwargs) -> dict: """ Get information about the current role and all the parent role(s) whose @@ -167,9 +158,7 @@ def get_current_role(*, database_id: int, **kwargs) -> dict: } -@rpc_method(name="roles.set_members") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="roles.set_members", auth="login") def set_members( *, parent_role_oid: int, diff --git a/mathesar/rpc/roles/configured.py b/mathesar/rpc/roles/configured.py index 8c92444127..ca0bed8feb 100644 --- a/mathesar/rpc/roles/configured.py +++ b/mathesar/rpc/roles/configured.py @@ -1,10 +1,7 @@ from typing import TypedDict -from modernrpc.core import rpc_method -from modernrpc.auth.basic import http_basic_auth_login_required, http_basic_auth_superuser_required - from mathesar.models.base import ConfiguredRole, Server -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method class ConfiguredRoleInfo(TypedDict): @@ -29,9 +26,7 @@ def from_model(cls, model): ) -@rpc_method(name="roles.configured.list") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="roles.configured.list", auth="login") def list_(*, server_id: int, **kwargs) -> list[ConfiguredRoleInfo]: """ List information about roles configured in Mathesar. Exposed as `list`. @@ -47,9 +42,7 @@ def list_(*, server_id: int, **kwargs) -> list[ConfiguredRoleInfo]: return [ConfiguredRoleInfo.from_model(db_model) for db_model in configured_role_qs] -@rpc_method(name='roles.configured.add') -@http_basic_auth_superuser_required -@handle_rpc_exceptions +@mathesar_rpc_method(name='roles.configured.add') def add( *, server_id: int, @@ -77,9 +70,7 @@ def add( return ConfiguredRoleInfo.from_model(configured_role) -@rpc_method(name='roles.configured.delete') -@http_basic_auth_superuser_required -@handle_rpc_exceptions +@mathesar_rpc_method(name='roles.configured.delete') def delete(*, configured_role_id: int, **kwargs): """ Delete a configured role for a server. @@ -91,9 +82,7 @@ def delete(*, configured_role_id: int, **kwargs): configured_role.delete() -@rpc_method(name='roles.configured.set_password') -@http_basic_auth_superuser_required -@handle_rpc_exceptions +@mathesar_rpc_method(name='roles.configured.set_password') def set_password( *, configured_role_id: int, diff --git a/mathesar/rpc/schemas/base.py b/mathesar/rpc/schemas/base.py index c42b1e19e7..6da0b08110 100644 --- a/mathesar/rpc/schemas/base.py +++ b/mathesar/rpc/schemas/base.py @@ -3,8 +3,7 @@ """ from typing import Literal, Optional, TypedDict -from modernrpc.core import rpc_method, REQUEST_KEY -from modernrpc.auth.basic import http_basic_auth_login_required +from modernrpc.core import REQUEST_KEY from db.constants import INTERNAL_SCHEMAS from db.schemas import ( @@ -14,7 +13,7 @@ list_schemas, patch_schema, ) -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method from mathesar.rpc.utils import connect @@ -52,9 +51,7 @@ class SchemaPatch(TypedDict): description: Optional[str] -@rpc_method(name="schemas.add") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="schemas.add", auth="login") def add( *, name: str, @@ -85,9 +82,7 @@ def add( ) -@rpc_method(name="schemas.list") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="schemas.list", auth="login") def list_(*, database_id: int, **kwargs) -> list[SchemaInfo]: """ List information about schemas in a database. Exposed as `list`. @@ -105,9 +100,7 @@ def list_(*, database_id: int, **kwargs) -> list[SchemaInfo]: return [s for s in schemas if s['name'] not in INTERNAL_SCHEMAS] -@rpc_method(name="schemas.get") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="schemas.get", auth="login") def get(*, schema_oid: int, database_id: int, **kwargs) -> SchemaInfo: """ Get information about a schema in a database. @@ -125,9 +118,7 @@ def get(*, schema_oid: int, database_id: int, **kwargs) -> SchemaInfo: return schema_info -@rpc_method(name="schemas.delete") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="schemas.delete", auth="login") def delete(*, schema_oid: int, database_id: int, **kwargs) -> None: """ Delete a schema, given its OID. @@ -140,9 +131,7 @@ def delete(*, schema_oid: int, database_id: int, **kwargs) -> None: drop_schema_via_oid(conn, schema_oid) -@rpc_method(name="schemas.patch") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="schemas.patch", auth="login") def patch(*, schema_oid: int, database_id: int, patch: SchemaPatch, **kwargs) -> SchemaInfo: """ Patch a schema, given its OID. diff --git a/mathesar/rpc/schemas/privileges.py b/mathesar/rpc/schemas/privileges.py index ff548ef8f9..1fe1927082 100644 --- a/mathesar/rpc/schemas/privileges.py +++ b/mathesar/rpc/schemas/privileges.py @@ -1,15 +1,14 @@ from typing import Literal, TypedDict -from modernrpc.core import rpc_method, REQUEST_KEY -from modernrpc.auth.basic import http_basic_auth_login_required +from modernrpc.core import REQUEST_KEY from db.roles import ( list_schema_privileges, replace_schema_privileges_for_roles, transfer_schema_ownership, ) +from mathesar.rpc.decorators import mathesar_rpc_method from mathesar.rpc.utils import connect -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions from mathesar.rpc.schemas.base import SchemaInfo @@ -19,7 +18,7 @@ class SchemaPrivileges(TypedDict): Attributes: role_oid: The `oid` of the role. - direct: A list of schema privileges for the afforementioned role_oid. + direct: A list of schema privileges for the aforementioned role_oid. """ role_oid: int direct: list[Literal['USAGE', 'CREATE']] @@ -32,9 +31,7 @@ def from_dict(cls, d): ) -@rpc_method(name="schemas.privileges.list_direct") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="schemas.privileges.list_direct", auth="login") def list_direct( *, schema_oid: int, database_id: int, **kwargs ) -> list[SchemaPrivileges]: @@ -54,9 +51,7 @@ def list_direct( return [SchemaPrivileges.from_dict(i) for i in raw_priv] -@rpc_method(name="schemas.privileges.replace_for_roles") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="schemas.privileges.replace_for_roles", auth="login") def replace_for_roles( *, privileges: list[SchemaPrivileges], schema_oid: int, database_id: int, @@ -90,9 +85,7 @@ def replace_for_roles( return [SchemaPrivileges.from_dict(i) for i in raw_priv] -@rpc_method(name="schemas.privileges.transfer_ownership") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="schemas.privileges.transfer_ownership", auth="login") def transfer_ownership(*, schema_oid: int, new_owner_oid: int, database_id: int, **kwargs) -> SchemaInfo: """ Transfers ownership of a given schema to a new owner. diff --git a/mathesar/rpc/servers/configured.py b/mathesar/rpc/servers/configured.py index 0d488966d6..959c92e489 100644 --- a/mathesar/rpc/servers/configured.py +++ b/mathesar/rpc/servers/configured.py @@ -1,10 +1,7 @@ from typing import TypedDict -from modernrpc.core import rpc_method -from modernrpc.auth.basic import http_basic_auth_login_required - from mathesar.models.base import Server -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method class ConfiguredServerInfo(TypedDict): @@ -29,9 +26,7 @@ def from_model(cls, model): ) -@rpc_method(name="servers.configured.list") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="servers.configured.list", auth="login") def list_() -> list[ConfiguredServerInfo]: """ List information about servers. Exposed as `list`. diff --git a/mathesar/rpc/tables/base.py b/mathesar/rpc/tables/base.py index 2cb951666e..b56884addf 100644 --- a/mathesar/rpc/tables/base.py +++ b/mathesar/rpc/tables/base.py @@ -3,8 +3,7 @@ """ from typing import Literal, Optional, TypedDict -from modernrpc.core import rpc_method, REQUEST_KEY -from modernrpc.auth.basic import http_basic_auth_login_required +from modernrpc.core import REQUEST_KEY from db.tables import ( alter_table_on_database, @@ -18,7 +17,7 @@ from mathesar.imports.csv import import_csv from mathesar.rpc.columns import CreatableColumnInfo, SettableColumnInfo, PreviewableColumnInfo from mathesar.rpc.constraints import CreatableConstraintInfo -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method from mathesar.rpc.tables.metadata import TableMetaDataBlob from mathesar.rpc.utils import connect from mathesar.utils.tables import list_tables_meta_data, get_table_meta_data @@ -112,7 +111,7 @@ class JoinableTableRecord(TypedDict): ] In this form, `constraint_idN` is a foreign key constraint, and `reversed` is a boolean giving - whether to travel from referrer to referant (when False) or from referant to referrer (when True). + whether to travel from referrer to referent (when False) or from referent to referrer (when True). depth: Specifies how far to search for joinable tables. multiple_results: Specifies whether the path included is reversed. """ @@ -154,9 +153,7 @@ def from_dict(cls, joinable_dict): ) -@rpc_method(name="tables.list") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.list", auth="login") def list_(*, schema_oid: int, database_id: int, **kwargs) -> list[TableInfo]: """ List information about tables for a schema. Exposed as `list`. @@ -176,9 +173,7 @@ def list_(*, schema_oid: int, database_id: int, **kwargs) -> list[TableInfo]: ] -@rpc_method(name="tables.get") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.get", auth="login") def get(*, table_oid: int, database_id: int, **kwargs) -> TableInfo: """ List information about a table for a schema. @@ -196,9 +191,7 @@ def get(*, table_oid: int, database_id: int, **kwargs) -> TableInfo: return TableInfo(raw_table_info) -@rpc_method(name="tables.add") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.add", auth="login") def add( *, schema_oid: int, @@ -234,9 +227,7 @@ def add( return created_table_oid -@rpc_method(name="tables.delete") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.delete", auth="login") def delete( *, table_oid: int, database_id: int, cascade: bool = False, **kwargs ) -> str: @@ -256,9 +247,7 @@ def delete( return drop_table_from_database(table_oid, conn, cascade) -@rpc_method(name="tables.patch") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.patch", auth="login") def patch( *, table_oid: str, table_data_dict: SettableTableInfo, database_id: int, **kwargs ) -> str: @@ -278,9 +267,7 @@ def patch( return alter_table_on_database(table_oid, table_data_dict, conn) -@rpc_method(name="tables.import") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.import", auth="login") def import_( *, data_file_id: int, @@ -308,9 +295,7 @@ def import_( return import_csv(data_file_id, table_name, schema_oid, conn, comment) -@rpc_method(name="tables.get_import_preview") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.get_import_preview", auth="login") def get_import_preview( *, table_oid: int, @@ -336,9 +321,7 @@ def get_import_preview( return get_preview(table_oid, columns, conn, limit) -@rpc_method(name="tables.list_joinable") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.list_joinable", auth="login") def list_joinable( *, table_oid: int, @@ -363,9 +346,7 @@ def list_joinable( return JoinableTableInfo.from_dict(joinable_dict) -@rpc_method(name="tables.list_with_metadata") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.list_with_metadata", auth="login") def list_with_metadata(*, schema_oid: int, database_id: int, **kwargs) -> list: """ List tables in a schema, along with the metadata associated with each table @@ -389,9 +370,7 @@ def list_with_metadata(*, schema_oid: int, database_id: int, **kwargs) -> list: return [table | {"metadata": metadata_map.get(table["oid"])} for table in tables] -@rpc_method(name="tables.get_with_metadata") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.get_with_metadata", auth="login") def get_with_metadata(*, table_oid: int, database_id: int, **kwargs) -> dict: """ Get information about a table in a schema, along with the associated table metadata. diff --git a/mathesar/rpc/tables/metadata.py b/mathesar/rpc/tables/metadata.py index 4d1f01e5f8..99e2bbbad0 100644 --- a/mathesar/rpc/tables/metadata.py +++ b/mathesar/rpc/tables/metadata.py @@ -3,10 +3,7 @@ """ from typing import Optional, TypedDict, Union -from modernrpc.core import rpc_method -from modernrpc.auth.basic import http_basic_auth_login_required - -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions +from mathesar.rpc.decorators import mathesar_rpc_method from mathesar.utils.tables import list_tables_meta_data, set_table_meta_data @@ -71,9 +68,7 @@ def from_model(cls, model): ) -@rpc_method(name="tables.metadata.list") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.metadata.list", auth="login") def list_(*, database_id: int, **kwargs) -> list[TableMetaDataRecord]: """ List metadata associated with tables for a database. @@ -90,9 +85,7 @@ def list_(*, database_id: int, **kwargs) -> list[TableMetaDataRecord]: ] -@rpc_method(name="tables.metadata.set") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.metadata.set", auth="login") def set_( *, table_oid: int, metadata: TableMetaDataBlob, database_id: int, **kwargs ) -> None: diff --git a/mathesar/rpc/tables/privileges.py b/mathesar/rpc/tables/privileges.py index 2b383c8606..402bbe852c 100644 --- a/mathesar/rpc/tables/privileges.py +++ b/mathesar/rpc/tables/privileges.py @@ -1,15 +1,14 @@ from typing import Literal, TypedDict -from modernrpc.core import rpc_method, REQUEST_KEY -from modernrpc.auth.basic import http_basic_auth_login_required +from modernrpc.core import REQUEST_KEY from db.roles import ( transfer_table_ownership, list_table_privileges, replace_table_privileges_for_roles, ) +from mathesar.rpc.decorators import mathesar_rpc_method from mathesar.rpc.utils import connect -from mathesar.rpc.exceptions.handlers import handle_rpc_exceptions from mathesar.rpc.tables.base import TableInfo @@ -18,7 +17,7 @@ class TablePrivileges(TypedDict): Information about table privileges for a role. Attributes: role_oid: The `oid` of the role. - direct: A list of table privileges for the afforementioned role_oid. + direct: A list of table privileges for the aforementioned role_oid. """ role_oid: int direct: list[Literal['INSERT', 'SELECT', 'UPDATE', 'DELETE', 'TRUNCATE', 'REFERENCES', 'TRIGGER']] @@ -31,9 +30,7 @@ def from_dict(cls, d): ) -@rpc_method(name="tables.privileges.list_direct") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.privileges.list_direct", auth="login") def list_direct( *, table_oid: int, database_id: int, **kwargs ) -> list[TablePrivileges]: @@ -51,9 +48,7 @@ def list_direct( return [TablePrivileges.from_dict(i) for i in raw_priv] -@rpc_method(name="tables.privileges.replace_for_roles") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.privileges.replace_for_roles", auth="login") def replace_for_roles( *, privileges: list[TablePrivileges], table_oid: int, database_id: int, @@ -87,9 +82,7 @@ def replace_for_roles( return [TablePrivileges.from_dict(i) for i in raw_priv] -@rpc_method(name="tables.privileges.transfer_ownership") -@http_basic_auth_login_required -@handle_rpc_exceptions +@mathesar_rpc_method(name="tables.privileges.transfer_ownership", auth="login") def transfer_ownership(*, table_oid: int, new_owner_oid: int, database_id: int, **kwargs) -> TableInfo: """ Transfers ownership of a given table to a new owner. diff --git a/mathesar/rpc/users.py b/mathesar/rpc/users.py new file mode 100644 index 0000000000..2f52b535f1 --- /dev/null +++ b/mathesar/rpc/users.py @@ -0,0 +1,236 @@ +""" +Classes and functions exposed to the RPC endpoint for managing mathesar users. +""" +from typing import Optional, TypedDict +from modernrpc.core import REQUEST_KEY + +from mathesar.rpc.decorators import mathesar_rpc_method +from mathesar.utils.users import ( + get_user, + list_users, + add_user, + update_self_user_info, + update_other_user_info, + delete_user, + change_password, + revoke_password +) + + +class UserInfo(TypedDict): + """ + Information about a mathesar user. + + Attributes: + id: The Django id of the user. + username: The username of the user. + is_superuser: Specifies whether the user is a superuser. + email: The email of the user. + full_name: The full name of the user. + display_language: Specifies the display language for the user, can be either `en` or `ja`. + """ + id: int + username: str + is_superuser: bool + email: str + full_name: str + display_language: str + + @classmethod + def from_model(cls, model): + return cls( + id=model.id, + username=model.username, + is_superuser=model.is_superuser, + email=model.email, + full_name=model.full_name, + display_language=model.display_language + ) + + +class UserDef(TypedDict): + """ + Definition for creating a mathesar user. + + Attributes: + username: The username of the user. + password: The password of the user. + is_superuser: Whether the user is a superuser. + email: The email of the user. + full_name: The full name of the user. + display_language: Specifies the display language for the user, can be set to either `en` or `ja`. + """ + username: str + password: str + is_superuser: bool + email: Optional[str] + full_name: Optional[str] + display_language: Optional[str] + + +@mathesar_rpc_method(name='users.add') +def add(*, user_def: UserDef) -> UserInfo: + """ + Add a new mathesar user. + + Args: + user_def: A dict describing the user to create. + + Privileges: + This endpoint requires the caller to be a superuser. + + Returns: + The information of the created user. + """ + user = add_user(user_def) + return UserInfo.from_model(user) + + +@mathesar_rpc_method(name='users.delete') +def delete(*, user_id: int) -> None: + """ + Delete a mathesar user. + + Args: + user_id: The Django id of the user to delete. + + Privileges: + This endpoint requires the caller to be a superuser. + """ + delete_user(user_id) + + +@mathesar_rpc_method(name="users.get", auth="login") +def get(*, user_id: int) -> UserInfo: + """ + List information about a mathesar user. + + Args: + user_id: The Django id of the user. + + Returns: + User information for a given user_id. + """ + user = get_user(user_id) + return UserInfo.from_model(user) + + +@mathesar_rpc_method(name='users.list', auth="login") +def list_() -> list[UserInfo]: + """ + List information about all mathesar users. Exposed as `list`. + + Returns: + A list of information about mathesar users. + """ + users = list_users() + return [UserInfo.from_model(user) for user in users] + + +@mathesar_rpc_method(name='users.patch_self', auth="login") +def patch_self( + *, + username: str, + email: str, + full_name: str, + display_language: str, + **kwargs +) -> UserInfo: + """ + Alter details of currently logged in mathesar user. + + Args: + username: The username of the user. + email: The email of the user. + full_name: The full name of the user. + display_language: Specifies the display language for the user, can be set to either `en` or `ja`. + + Returns: + Updated user information of the caller. + """ + user = kwargs.get(REQUEST_KEY).user + updated_user_info = update_self_user_info( + user_id=user.id, + username=username, + email=email, + full_name=full_name, + display_language=display_language + ) + return UserInfo.from_model(updated_user_info) + + +@mathesar_rpc_method(name='users.patch_other') +def patch_other( + *, + user_id: int, + username: str, + is_superuser: bool, + email: str, + full_name: str, + display_language: str +) -> UserInfo: + """ + Alter details of a mathesar user, given its user_id. + + Args: + user_id: The Django id of the user. + username: The username of the user. + email: The email of the user. + is_superuser: Specifies whether to set the user as a superuser. + full_name: The full name of the user. + display_language: Specifies the display language for the user, can be set to either `en` or `ja`. + + Privileges: + This endpoint requires the caller to be a superuser. + + Returns: + Updated user information for a given user_id. + """ + updated_user_info = update_other_user_info( + user_id=user_id, + username=username, + is_superuser=is_superuser, + email=email, + full_name=full_name, + display_language=display_language + ) + return UserInfo.from_model(updated_user_info) + + +@mathesar_rpc_method(name='users.password.replace_own', auth="login") +def replace_own( + *, + old_password: str, + new_password: str, + **kwargs +) -> None: + """ + Alter password of currently logged in mathesar user. + + Args: + old_password: Old password of the currently logged in user. + new_password: New password of the user to set. + """ + user = kwargs.get(REQUEST_KEY).user + if not user.check_password(old_password): + raise Exception('Old password is incorrect') + change_password(user.id, new_password) + + +@mathesar_rpc_method(name='users.password.revoke') +def revoke( + *, + user_id: int, + new_password: str, +) -> None: + """ + Alter password of a mathesar user, given its user_id. + + Args: + user_id: The Django id of the user. + new_password: New password of the user to set. + + Privileges: + This endpoint requires the caller to be a superuser. + """ + revoke_password(user_id, new_password) diff --git a/mathesar/static/non-code/icons/apple-touch-icon.png b/mathesar/static/non-code/icons/apple-touch-icon.png new file mode 100644 index 0000000000..b2de0205d2 Binary files /dev/null and b/mathesar/static/non-code/icons/apple-touch-icon.png differ diff --git a/mathesar/static/non-code/icons/favicon-16x16.png b/mathesar/static/non-code/icons/favicon-16x16.png new file mode 100644 index 0000000000..3e53c1f43b Binary files /dev/null and b/mathesar/static/non-code/icons/favicon-16x16.png differ diff --git a/mathesar/static/non-code/icons/favicon-180x180.png b/mathesar/static/non-code/icons/favicon-180x180.png new file mode 100644 index 0000000000..08734219d9 Binary files /dev/null and b/mathesar/static/non-code/icons/favicon-180x180.png differ diff --git a/mathesar/static/non-code/icons/favicon-32x32.png b/mathesar/static/non-code/icons/favicon-32x32.png new file mode 100644 index 0000000000..df2941f62a Binary files /dev/null and b/mathesar/static/non-code/icons/favicon-32x32.png differ diff --git a/mathesar/static/non-code/icons/favicon.ico b/mathesar/static/non-code/icons/favicon.ico new file mode 100644 index 0000000000..2edc4ff43c Binary files /dev/null and b/mathesar/static/non-code/icons/favicon.ico differ diff --git a/mathesar/templates/mathesar/base.html b/mathesar/templates/mathesar/base.html index 48cb7faf90..d9b9fc1656 100644 --- a/mathesar/templates/mathesar/base.html +++ b/mathesar/templates/mathesar/base.html @@ -1,3 +1,4 @@ +{% load i18n static %} @@ -5,8 +6,12 @@ Mathesar - {% block title %}{% endblock %} - - + + + + + +