Repository: mathesar-foundation/mathesar

Issue:
Implement 'Shares'
## Issues
- [x] https://github.com/centerofci/mathesar/issues/3033
- [x] https://github.com/centerofci/mathesar/issues/3034
- [x] https://github.com/centerofci/mathesar/issues/3035
- [x] https://github.com/centerofci/mathesar/issues/3036

## Tasks:
- [ ] Add regenerate slug endpoints

### https://github.com/centerofci/mathesar/pull/3093#pullrequestreview-1546069582
- [ ] Address the following in shared table consumer page
   - [ ] Disable re-reordering of columns
   - [ ] Don't show the icon hyperlink to the record page within the PK cell
   - [ ] Remove the following entries in the cell context menu:
       - "Set to NULL"
       - "Go to Record Page"
       - "Go to Linked Record" (shown only for FK columns)
   - [ ] Remove the "Go to Record Page" entry from the row header context menu
   - [ ] Disable record selector in filtering for FK columns
- [ ] Come up with a better term for 'ShareConsumer'. Some suggestions:
    - ShareAccessInfo
    - SharedLink
    - ConsumableShare

## Related:
* [Product spec](https://wiki.mathesar.org/en/product/specs/publicly-shareable-links)


Discussion:

Resolving patch:
diff --git a/mathesar/api/ui/permissions/shares.py b/mathesar/api/ui/permissions/shares.py
--- a/mathesar/api/ui/permissions/shares.py
+++ b/mathesar/api/ui/permissions/shares.py
@@ -13,7 +13,7 @@ class SharedTableAccessPolicy(AccessPolicy):
             'condition_expression': 'is_atleast_viewer_nested_table_resource'
         },
         {
-            'action': ['create', 'destroy', 'update', 'partial_update'],
+            'action': ['create', 'destroy', 'update', 'partial_update', 'regenerate'],
             'principal': 'authenticated',
             'effect': 'allow',
             'condition_expression': 'is_atleast_editor_nested_table_resource'
@@ -30,7 +30,7 @@ class SharedQueryAccessPolicy(AccessPolicy):
             'condition_expression': 'is_atleast_query_viewer'
         },
         {
-            'action': ['create', 'destroy', 'update', 'partial_update'],
+            'action': ['create', 'destroy', 'update', 'partial_update', 'regenerate'],
             'principal': 'authenticated',
             'effect': 'allow',
             'condition_expression': 'is_atleast_query_editor'
diff --git a/mathesar/api/ui/viewsets/shares.py b/mathesar/api/ui/viewsets/shares.py
--- a/mathesar/api/ui/viewsets/shares.py
+++ b/mathesar/api/ui/viewsets/shares.py
@@ -1,5 +1,8 @@
+import uuid
 from rest_framework import viewsets
 from rest_access_policy import AccessViewSetMixin
+from rest_framework.decorators import action
+from rest_framework.response import Response
 
 from mathesar.api.pagination import DefaultLimitOffsetPagination
 from mathesar.api.ui.serializers.shares import SharedTableSerializer, SharedQuerySerializer
@@ -7,7 +10,17 @@
 from mathesar.models.shares import SharedTable, SharedQuery
 
 
-class SharedTableViewSet(AccessViewSetMixin, viewsets.ModelViewSet):
+class RegenerateSlugMixin(viewsets.GenericViewSet):
+    @action(methods=['post'], detail=True)
+    def regenerate(self, *args, **kwargs):
+        share = self.get_object()
+        share.slug = uuid.uuid4()
+        share.save()
+        serializer = self.get_serializer(share)
+        return Response(serializer.data)
+
+
+class SharedTableViewSet(AccessViewSetMixin, viewsets.ModelViewSet, RegenerateSlugMixin):
     pagination_class = DefaultLimitOffsetPagination
     serializer_class = SharedTableSerializer
     access_policy = SharedTableAccessPolicy
@@ -19,7 +32,7 @@ def perform_create(self, serializer):
         serializer.save(table_id=self.kwargs['table_pk'])
 
 
-class SharedQueryViewSet(AccessViewSetMixin, viewsets.ModelViewSet):
+class SharedQueryViewSet(AccessViewSetMixin, viewsets.ModelViewSet, RegenerateSlugMixin):
     pagination_class = DefaultLimitOffsetPagination
     serializer_class = SharedQuerySerializer
     access_policy = SharedQueryAccessPolicy


Test patch:
diff --git a/mathesar/tests/api/query/test_shared_queries.py b/mathesar/tests/api/query/test_shared_queries.py
--- a/mathesar/tests/api/query/test_shared_queries.py
+++ b/mathesar/tests/api/query/test_shared_queries.py
@@ -5,7 +5,7 @@
 
 
 @pytest.fixture
-def shared_test_query(create_minimal_patents_query):
+def schemas_with_shared_queries(create_minimal_patents_query):
     query = create_minimal_patents_query()
     share = SharedQuery.objects.create(
         query=query,
@@ -17,10 +17,12 @@ def shared_test_query(create_minimal_patents_query):
         enabled=True,
     )
     yield {
-        'query': query,
-        'share': share,
+        'patent_schema': query.base_table.schema,
+        'patent_query': query,
+        'patent_query_share': share,
+        'different_schema': different_schema_query.base_table.schema,
         'different_schema_query': different_schema_query,
-        'different_schema_share': different_schema_share,
+        'different_schema_query_share': different_schema_share,
     }
 
     # cleanup
@@ -56,53 +58,53 @@ def shared_test_query(create_minimal_patents_query):
 
 @pytest.mark.parametrize('client_name,different_schema_status_code', read_client_with_different_roles)
 def test_shared_query_list(
-    shared_test_query,
+    schemas_with_shared_queries,
     request,
     client_name,
     different_schema_status_code,
 ):
-    client = request.getfixturevalue(client_name)(shared_test_query["query"].base_table.schema)
-    response = client.get(f'/api/ui/v0/queries/{shared_test_query["query"].id}/shares/')
+    client = request.getfixturevalue(client_name)(schemas_with_shared_queries["patent_schema"])
+    response = client.get(f'/api/ui/v0/queries/{schemas_with_shared_queries["patent_query"].id}/shares/')
     response_data = response.json()
 
     assert response.status_code == 200
     assert response_data['count'] == 1
     assert len(response_data['results']) == 1
     result = response_data['results'][0]
-    assert result['slug'] == str(shared_test_query['share'].slug)
-    assert result['enabled'] == shared_test_query['share'].enabled
+    assert result['slug'] == str(schemas_with_shared_queries['patent_query_share'].slug)
+    assert result['enabled'] == schemas_with_shared_queries['patent_query_share'].enabled
 
-    response = client.get(f'/api/ui/v0/queries/{shared_test_query["different_schema_query"].id}/shares/')
+    response = client.get(f'/api/ui/v0/queries/{schemas_with_shared_queries["different_schema_query"].id}/shares/')
     assert response.status_code == different_schema_status_code
     if different_schema_status_code == 200:
         response_data = response.json()
         assert len(response_data['results']) == 1
         result = response_data['results'][0]
-        assert result['slug'] == str(shared_test_query['different_schema_share'].slug)
-        assert result['enabled'] == shared_test_query['different_schema_share'].enabled
+        assert result['slug'] == str(schemas_with_shared_queries['different_schema_query_share'].slug)
+        assert result['enabled'] == schemas_with_shared_queries['different_schema_query_share'].enabled
 
 
 @pytest.mark.parametrize('client_name,different_schema_status_code', read_client_with_different_roles)
 def test_shared_query_retrieve(
-    shared_test_query,
+    schemas_with_shared_queries,
     request,
     client_name,
     different_schema_status_code,
 ):
-    client = request.getfixturevalue(client_name)(shared_test_query["query"].base_table.schema)
-    response = client.get(f'/api/ui/v0/queries/{shared_test_query["query"].id}/shares/{shared_test_query["share"].id}/')
+    client = request.getfixturevalue(client_name)(schemas_with_shared_queries["patent_schema"])
+    response = client.get(f'/api/ui/v0/queries/{schemas_with_shared_queries["patent_query"].id}/shares/{schemas_with_shared_queries["patent_query_share"].id}/')
     response_data = response.json()
 
     assert response.status_code == 200
-    assert response_data['slug'] == str(shared_test_query['share'].slug)
-    assert response_data['enabled'] == shared_test_query['share'].enabled
+    assert response_data['slug'] == str(schemas_with_shared_queries['patent_query_share'].slug)
+    assert response_data['enabled'] == schemas_with_shared_queries['patent_query_share'].enabled
 
-    response = client.get(f'/api/ui/v0/queries/{shared_test_query["different_schema_query"].id}/shares/{shared_test_query["different_schema_share"].id}/')
+    response = client.get(f'/api/ui/v0/queries/{schemas_with_shared_queries["different_schema_query"].id}/shares/{schemas_with_shared_queries["different_schema_query_share"].id}/')
     assert response.status_code == different_schema_status_code
     if different_schema_status_code == 200:
         response_data = response.json()
-        assert response_data['slug'] == str(shared_test_query['different_schema_share'].slug)
-        assert response_data['enabled'] == shared_test_query['different_schema_share'].enabled
+        assert response_data['slug'] == str(schemas_with_shared_queries['different_schema_query_share'].slug)
+        assert response_data['enabled'] == schemas_with_shared_queries['different_schema_query_share'].enabled
 
 
 @pytest.mark.parametrize('client_name,is_allowed', write_client_with_different_roles)
@@ -129,19 +131,19 @@ def test_shared_query_create(
 
 @pytest.mark.parametrize('client_name,is_allowed', write_client_with_different_roles)
 def test_shared_query_patch(
-    shared_test_query,
+    schemas_with_shared_queries,
     request,
     client_name,
     is_allowed
 ):
-    client = request.getfixturevalue(client_name)(shared_test_query["query"].base_table.schema)
+    client = request.getfixturevalue(client_name)(schemas_with_shared_queries["patent_schema"])
     data = {'enabled': False}
-    response = client.patch(f'/api/ui/v0/queries/{shared_test_query["query"].id}/shares/{shared_test_query["share"].id}/', data)
+    response = client.patch(f'/api/ui/v0/queries/{schemas_with_shared_queries["patent_query"].id}/shares/{schemas_with_shared_queries["patent_query_share"].id}/', data)
     response_data = response.json()
 
     if is_allowed:
         assert response.status_code == 200
-        assert response_data['slug'] == str(shared_test_query['share'].slug)
+        assert response_data['slug'] == str(schemas_with_shared_queries['patent_query_share'].slug)
         assert response_data['enabled'] is False
     else:
         assert response.status_code == 403
@@ -149,17 +151,36 @@ def test_shared_query_patch(
 
 @pytest.mark.parametrize('client_name,is_allowed', write_client_with_different_roles)
 def test_shared_query_delete(
-    shared_test_query,
+    schemas_with_shared_queries,
     request,
     client_name,
     is_allowed
 ):
-    client = request.getfixturevalue(client_name)(shared_test_query["query"].base_table.schema)
-    response = client.delete(f'/api/ui/v0/queries/{shared_test_query["query"].id}/shares/{shared_test_query["share"].id}/')
+    client = request.getfixturevalue(client_name)(schemas_with_shared_queries["patent_schema"])
+    response = client.delete(f'/api/ui/v0/queries/{schemas_with_shared_queries["patent_query"].id}/shares/{schemas_with_shared_queries["patent_query_share"].id}/')
 
     if is_allowed:
         assert response.status_code == 204
-        assert SharedQuery.objects.filter(id=shared_test_query['share'].id).first() is None
+        assert SharedQuery.objects.filter(id=schemas_with_shared_queries['patent_query_share'].id).first() is None
+    else:
+        assert response.status_code == 403
+
+
+@pytest.mark.parametrize('client_name,is_allowed', write_client_with_different_roles)
+def test_shared_query_regenerate_link(
+    schemas_with_shared_queries,
+    request,
+    client_name,
+    is_allowed
+):
+    client = request.getfixturevalue(client_name)(schemas_with_shared_queries["patent_schema"])
+    old_slug = str(schemas_with_shared_queries["patent_query_share"].slug)
+    response = client.post(f'/api/ui/v0/queries/{schemas_with_shared_queries["patent_query"].id}/shares/{schemas_with_shared_queries["patent_query_share"].id}/regenerate/')
+    response_data = response.json()
+
+    if is_allowed:
+        assert response.status_code == 200
+        assert response_data['slug'] != old_slug
     else:
         assert response.status_code == 403
 
@@ -181,20 +202,20 @@ def test_shared_query_delete(
 @pytest.mark.parametrize('client_name,same_schema_invalid_token_status,different_schema_invalid_token_status', queries_request_client_with_different_roles)
 @pytest.mark.parametrize('endpoint', ['/', '/results/'])
 def test_shared_query_view_requests(
-    shared_test_query,
+    schemas_with_shared_queries,
     request,
     endpoint,
     client_name,
     same_schema_invalid_token_status,
     different_schema_invalid_token_status
 ):
-    client = request.getfixturevalue(client_name)(shared_test_query["query"].base_table.schema)
+    client = request.getfixturevalue(client_name)(schemas_with_shared_queries["patent_schema"])
 
-    query_url = f'/api/db/v0/queries/{shared_test_query["query"].id}'
-    share_uuid_param = f'shared-link-uuid={shared_test_query["share"].slug}'
+    query_url = f'/api/db/v0/queries/{schemas_with_shared_queries["patent_query"].id}'
+    share_uuid_param = f'shared-link-uuid={schemas_with_shared_queries["patent_query_share"].slug}'
     invalid_share_uuid_param = f'shared-link-uuid={uuid.uuid4()}'
-    different_schema_query_url = f'/api/db/v0/queries/{shared_test_query["different_schema_query"].id}'
-    different_schema_query_uuid_param = f'shared-link-uuid={shared_test_query["different_schema_share"].slug}'
+    different_schema_query_url = f'/api/db/v0/queries/{schemas_with_shared_queries["different_schema_query"].id}'
+    different_schema_query_uuid_param = f'shared-link-uuid={schemas_with_shared_queries["different_schema_query_share"].slug}'
     is_result_endpoint = endpoint == '/results/'
 
     response = client.get(f'{query_url}{endpoint}?{share_uuid_param}')
diff --git a/mathesar/tests/api/test_shared_tables.py b/mathesar/tests/api/test_shared_tables.py
--- a/mathesar/tests/api/test_shared_tables.py
+++ b/mathesar/tests/api/test_shared_tables.py
@@ -5,8 +5,8 @@
 
 
 @pytest.fixture
-def shared_test_table(create_patents_table, uid):
-    table_name = f"shared_test_table_{uid}"
+def schemas_with_shared_tables(create_patents_table, uid):
+    table_name = f"schemas_with_shared_tables_{uid}"
     table = create_patents_table(table_name)
     share = SharedTable.objects.create(
         table=table,
@@ -18,10 +18,12 @@ def shared_test_table(create_patents_table, uid):
         enabled=True,
     )
     yield {
-        'table': table,
-        'share': share,
+        'patents_schema': table.schema,
+        'patents_table': table,
+        'patents_table_share': share,
+        'different_schema': different_schema_table.schema,
         'different_schema_table': different_schema_table,
-        'different_schema_share': different_schema_share,
+        'different_schema_table_share': different_schema_share,
     }
     share.delete()
     table.delete()
@@ -53,53 +55,53 @@ def shared_test_table(create_patents_table, uid):
 
 @pytest.mark.parametrize('client_name,different_schema_status_code', read_client_with_different_roles)
 def test_shared_table_list(
-    shared_test_table,
+    schemas_with_shared_tables,
     request,
     client_name,
     different_schema_status_code,
 ):
-    client = request.getfixturevalue(client_name)(shared_test_table["table"].schema)
-    response = client.get(f'/api/ui/v0/tables/{shared_test_table["table"].id}/shares/')
+    client = request.getfixturevalue(client_name)(schemas_with_shared_tables["patents_schema"])
+    response = client.get(f'/api/ui/v0/tables/{schemas_with_shared_tables["patents_table"].id}/shares/')
     response_data = response.json()
 
     assert response.status_code == 200
     assert response_data['count'] == 1
     assert len(response_data['results']) == 1
     result = response_data['results'][0]
-    assert result['slug'] == str(shared_test_table['share'].slug)
-    assert result['enabled'] == shared_test_table['share'].enabled
+    assert result['slug'] == str(schemas_with_shared_tables['patents_table_share'].slug)
+    assert result['enabled'] == schemas_with_shared_tables['patents_table_share'].enabled
 
-    response = client.get(f'/api/ui/v0/tables/{shared_test_table["different_schema_table"].id}/shares/')
+    response = client.get(f'/api/ui/v0/tables/{schemas_with_shared_tables["different_schema_table"].id}/shares/')
     assert response.status_code == different_schema_status_code
     if different_schema_status_code == 200:
         response_data = response.json()
         assert len(response_data['results']) == 1
         result = response_data['results'][0]
-        assert result['slug'] == str(shared_test_table['different_schema_share'].slug)
-        assert result['enabled'] == shared_test_table['different_schema_share'].enabled
+        assert result['slug'] == str(schemas_with_shared_tables['different_schema_table_share'].slug)
+        assert result['enabled'] == schemas_with_shared_tables['different_schema_table_share'].enabled
 
 
 @pytest.mark.parametrize('client_name,different_schema_status_code', read_client_with_different_roles)
 def test_shared_table_retrieve(
-    shared_test_table,
+    schemas_with_shared_tables,
     request,
     client_name,
     different_schema_status_code,
 ):
-    client = request.getfixturevalue(client_name)(shared_test_table["table"].schema)
-    response = client.get(f'/api/ui/v0/tables/{shared_test_table["table"].id}/shares/{shared_test_table["share"].id}/')
+    client = request.getfixturevalue(client_name)(schemas_with_shared_tables["patents_schema"])
+    response = client.get(f'/api/ui/v0/tables/{schemas_with_shared_tables["patents_table"].id}/shares/{schemas_with_shared_tables["patents_table_share"].id}/')
     response_data = response.json()
 
     assert response.status_code == 200
-    assert response_data['slug'] == str(shared_test_table['share'].slug)
-    assert response_data['enabled'] == shared_test_table['share'].enabled
+    assert response_data['slug'] == str(schemas_with_shared_tables['patents_table_share'].slug)
+    assert response_data['enabled'] == schemas_with_shared_tables['patents_table_share'].enabled
 
-    response = client.get(f'/api/ui/v0/tables/{shared_test_table["different_schema_table"].id}/shares/{shared_test_table["different_schema_share"].id}/')
+    response = client.get(f'/api/ui/v0/tables/{schemas_with_shared_tables["different_schema_table"].id}/shares/{schemas_with_shared_tables["different_schema_table_share"].id}/')
     assert response.status_code == different_schema_status_code
     if different_schema_status_code == 200:
         response_data = response.json()
-        assert response_data['slug'] == str(shared_test_table['different_schema_share'].slug)
-        assert response_data['enabled'] == shared_test_table['different_schema_share'].enabled
+        assert response_data['slug'] == str(schemas_with_shared_tables['different_schema_table_share'].slug)
+        assert response_data['enabled'] == schemas_with_shared_tables['different_schema_table_share'].enabled
 
 
 @pytest.mark.parametrize('client_name,is_allowed', write_client_with_different_roles)
@@ -129,19 +131,19 @@ def test_shared_table_create(
 
 @pytest.mark.parametrize('client_name,is_allowed', write_client_with_different_roles)
 def test_shared_table_patch(
-    shared_test_table,
+    schemas_with_shared_tables,
     request,
     client_name,
     is_allowed
 ):
-    client = request.getfixturevalue(client_name)(shared_test_table["table"].schema)
+    client = request.getfixturevalue(client_name)(schemas_with_shared_tables["patents_schema"])
     data = {'enabled': False}
-    response = client.patch(f'/api/ui/v0/tables/{shared_test_table["table"].id}/shares/{shared_test_table["share"].id}/', data)
+    response = client.patch(f'/api/ui/v0/tables/{schemas_with_shared_tables["patents_table"].id}/shares/{schemas_with_shared_tables["patents_table_share"].id}/', data)
     response_data = response.json()
 
     if is_allowed:
         assert response.status_code == 200
-        assert response_data['slug'] == str(shared_test_table['share'].slug)
+        assert response_data['slug'] == str(schemas_with_shared_tables['patents_table_share'].slug)
         assert response_data['enabled'] is False
     else:
         assert response.status_code == 403
@@ -149,17 +151,36 @@ def test_shared_table_patch(
 
 @pytest.mark.parametrize('client_name,is_allowed', write_client_with_different_roles)
 def test_shared_table_delete(
-    shared_test_table,
+    schemas_with_shared_tables,
     request,
     client_name,
     is_allowed
 ):
-    client = request.getfixturevalue(client_name)(shared_test_table["table"].schema)
-    response = client.delete(f'/api/ui/v0/tables/{shared_test_table["table"].id}/shares/{shared_test_table["share"].id}/')
+    client = request.getfixturevalue(client_name)(schemas_with_shared_tables["patents_schema"])
+    response = client.delete(f'/api/ui/v0/tables/{schemas_with_shared_tables["patents_table"].id}/shares/{schemas_with_shared_tables["patents_table_share"].id}/')
 
     if is_allowed:
         assert response.status_code == 204
-        assert SharedTable.objects.filter(id=shared_test_table['share'].id).first() is None
+        assert SharedTable.objects.filter(id=schemas_with_shared_tables['patents_table_share'].id).first() is None
+    else:
+        assert response.status_code == 403
+
+
+@pytest.mark.parametrize('client_name,is_allowed', write_client_with_different_roles)
+def test_shared_table_regenerate_link(
+    schemas_with_shared_tables,
+    request,
+    client_name,
+    is_allowed
+):
+    client = request.getfixturevalue(client_name)(schemas_with_shared_tables["patents_schema"])
+    old_slug = str(schemas_with_shared_tables["patents_table_share"].slug)
+    response = client.post(f'/api/ui/v0/tables/{schemas_with_shared_tables["patents_table"].id}/shares/{schemas_with_shared_tables["patents_table_share"].id}/regenerate/')
+    response_data = response.json()
+
+    if is_allowed:
+        assert response.status_code == 200
+        assert response_data['slug'] != old_slug
     else:
         assert response.status_code == 403
 
@@ -181,20 +202,20 @@ def test_shared_table_delete(
 @pytest.mark.parametrize('client_name,same_schema_invalid_token_status,different_schema_invalid_token_status', tables_request_client_with_different_roles)
 @pytest.mark.parametrize('endpoint', ['columns', 'constraints', 'records'])
 def test_shared_table_view_requests(
-    shared_test_table,
+    schemas_with_shared_tables,
     request,
     endpoint,
     client_name,
     same_schema_invalid_token_status,
     different_schema_invalid_token_status
 ):
-    client = request.getfixturevalue(client_name)(shared_test_table["table"].schema)
+    client = request.getfixturevalue(client_name)(schemas_with_shared_tables["patents_schema"])
 
-    table_url = f'/api/db/v0/tables/{shared_test_table["table"].id}'
-    share_uuid_param = f'shared-link-uuid={shared_test_table["share"].slug}'
+    table_url = f'/api/db/v0/tables/{schemas_with_shared_tables["patents_table"].id}'
+    share_uuid_param = f'shared-link-uuid={schemas_with_shared_tables["patents_table_share"].slug}'
     invalid_share_uuid_param = f'shared-link-uuid={uuid.uuid4()}'
-    different_schema_table_url = f'/api/db/v0/tables/{shared_test_table["different_schema_table"].id}'
-    different_schema_table_uuid_param = f'shared-link-uuid={shared_test_table["different_schema_share"].slug}'
+    different_schema_table_url = f'/api/db/v0/tables/{schemas_with_shared_tables["different_schema_table"].id}'
+    different_schema_table_uuid_param = f'shared-link-uuid={schemas_with_shared_tables["different_schema_table_share"].slug}'
 
     response = client.get(f'{table_url}/{endpoint}/?{share_uuid_param}')
     response_data = response.json()