Skip to content

Commit

Permalink
task/DES-2707: add filemeta app (#1194)
Browse files Browse the repository at this point in the history
* Add filemeta app

* Add AuthenticatedApiView to main

from f19997b#diff-f1bbb364f4c92d6f513be2ef3c46a1a4e14453cadd8b18ef25bc124125c0d927

* Update designsafe/apps/api/filemeta/models.py

Co-authored-by: Jake Rosenberg <[email protected]>

* Add app to INSTALLED_APPS

* Rename data to value

* Fix url's by path

* Add access check

* Add unit tests

* Extend tests

* Fix pylint issues

* Fix black formatting issues

* Add migration

* Rework model so all info is in json value column

* Add test

* Update docstring for publications

* Add todo

* Add mising migration

---------

Co-authored-by: Jake Rosenberg <[email protected]>
  • Loading branch information
nathanfranklin and jarosenb authored Apr 22, 2024
1 parent d168f1d commit 6a15778
Show file tree
Hide file tree
Showing 14 changed files with 422 additions and 4 deletions.
Empty file.
10 changes: 10 additions & 0 deletions designsafe/apps/api/filemeta/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
"""File Meta app config"""
from django.apps import AppConfig


class FileMetaAppConfig(AppConfig):
"""App config for File Meta"""

name = "designsafe.apps.api.filemeta"
label = "filemeta_api"
verbose_name = "Designsafe File Meta"
36 changes: 36 additions & 0 deletions designsafe/apps/api/filemeta/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Generated by Django 4.2.6 on 2024-04-18 16:44

from django.db import migrations, models
import django.utils.timezone


class Migration(migrations.Migration):
initial = True

dependencies = []

operations = [
migrations.CreateModel(
name="FileMetaModel",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("value", models.JSONField()),
("created", models.DateTimeField(default=django.utils.timezone.now)),
("last_updated", models.DateTimeField(auto_now=True)),
],
options={
"indexes": [
models.Index(models.F("value__system"), name="idx_value_system"),
models.Index(models.F("value__path"), name="idx_value_path"),
],
},
),
]
21 changes: 21 additions & 0 deletions designsafe/apps/api/filemeta/migrations/0002_auto_20240418_1650.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Generated by Django 4.2.6 on 2024-04-18 16:50
# Custom migration to handle unique constraint of system/path in "value"
from django.db import migrations, models
import django.db.models.expressions as expressions


class Migration(migrations.Migration):
dependencies = [
("filemeta_api", "0001_initial"),
]

operations = [
migrations.AddConstraint(
model_name="filemetamodel",
constraint=models.UniqueConstraint(
expressions.F("value__system"),
expressions.F("value__path"),
name="unique_system_path",
),
),
]
Empty file.
67 changes: 67 additions & 0 deletions designsafe/apps/api/filemeta/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""File Meta model"""

from django.db import models, transaction
from django.db.models import Q
from django.utils import timezone


class FileMetaModel(models.Model):
"""Model for File Meta"""

value = models.JSONField() # 'path' and 'system' keys are always in value
created = models.DateTimeField(default=timezone.now)
last_updated = models.DateTimeField(auto_now=True)

class Meta:
indexes = [
models.Index(
models.F(
"value__system"
), # Functional index on 'system' within 'value'
name="idx_value_system",
),
models.Index(
models.F("value__path"), # Functional index on 'path' within 'value'
name="idx_value_path",
),
]

def __str__(self):
return f"{self.value}"

@classmethod
def create_or_update_file_meta(cls, value):
"""
Create or update a FileMetaModel instance based on the provided value dict containing 'system' and 'path'.
Parameters:
- value (dict): A dictionary containing file metadata including required 'system' and 'path'.
Returns:
- tuple (instance, created): The FileMetaModel instance and a boolean indicating if it was created (True) or updated (False).
"""
system = value.get("system")
path = value.get("path")

# Use a transaction to ensure atomicity
with transaction.atomic():
try:
file_meta = FileMetaModel.objects.select_for_update().get(
Q(value__system=system) & Q(value__path=path)
)
file_meta.value = value
file_meta.save()
return file_meta, False
except FileMetaModel.DoesNotExist:
file_meta = FileMetaModel.objects.create(value=value)
return file_meta, True

@classmethod
def get_by_path_and_system(cls, system, path):
"""
Retrieve file metadata by 'system' and 'path'.
Raises:
- DoesNotExist: if file metadata entry not found
"""
return cls.objects.get(value__system=system, value__path=path)
161 changes: 161 additions & 0 deletions designsafe/apps/api/filemeta/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import pytest
import json
from django.db import IntegrityError
from django.test import Client
from designsafe.apps.api.filemeta.models import FileMetaModel


@pytest.fixture
def filemeta_value_mock():
value = {"system": "project-1234", "path": "/test/path.txt"}
return value


@pytest.fixture
def filemeta_db_mock(filemeta_value_mock):
system_id = filemeta_value_mock["system"]
path = filemeta_value_mock["path"]
value = filemeta_value_mock
file_meta_obj = FileMetaModel.objects.create(value=value)
return system_id, path, file_meta_obj


@pytest.fixture
def mock_access_success(mocker):
"""Fixture to mock the listing function to always succeed."""
mocker.patch("designsafe.apps.api.filemeta.views.listing")


@pytest.fixture
def mock_access_failure(mocker):
"""Fixture to mock the listing function to always raise an exception."""
mocker.patch(
"designsafe.apps.api.filemeta.views.listing",
side_effect=Exception("Access Denied"),
)


@pytest.mark.django_db
def test_database_constraint(filemeta_value_mock):
FileMetaModel.objects.create(value=filemeta_value_mock)
with pytest.raises(IntegrityError) as excinfo:
FileMetaModel.objects.create(value=filemeta_value_mock)
assert "Unique" in str(excinfo.value)


@pytest.mark.django_db
def test_database_get_by_path_system(filemeta_db_mock, filemeta_value_mock):
FileMetaModel.get_by_path_and_system(
system=filemeta_value_mock["system"], path=filemeta_value_mock["path"]
)

with pytest.raises(FileMetaModel.DoesNotExist):
FileMetaModel.get_by_path_and_system(system="foo", path="bar/baz.txt")


@pytest.mark.django_db
def test_database_constraint(filemeta_value_mock):
FileMetaModel.objects.create(value=filemeta_value_mock)
with pytest.raises(IntegrityError) as excinfo:
FileMetaModel.objects.create(value=filemeta_value_mock)
assert "Unique" in str(excinfo.value)


@pytest.mark.django_db
def test_get_file_meta_unauthenticated(client, filemeta_db_mock, mock_access_success):
system_id, path, file_meta = filemeta_db_mock
response = client.get(f"/api/filemeta/{system_id}/{path}")
assert response.status_code == 401


@pytest.mark.django_db
def test_get_file_meta(
client, authenticated_user, filemeta_db_mock, mock_access_success
):
system_id, path, file_meta = filemeta_db_mock
response = client.get(f"/api/filemeta/{system_id}/{path}")
assert response.status_code == 200

assert response.json() == {
"value": file_meta.value,
"name": "designsafe.file",
"lastUpdated": file_meta.last_updated.isoformat(
timespec="milliseconds"
).replace("+00:00", "Z"),
}


@pytest.mark.django_db
def test_create_file_meta_no_access(
client, authenticated_user, filemeta_value_mock, mock_access_failure
):
response = client.post(
"/api/filemeta/",
data=json.dumps(filemeta_value_mock),
content_type="application/json",
)
assert response.status_code == 403


@pytest.mark.django_db
def test_create_file_meta_unauthenticated(client, filemeta_value_mock):
response = client.post(
"/api/filemeta/",
data=json.dumps(filemeta_value_mock),
content_type="application/json",
)
assert response.status_code == 401


@pytest.mark.django_db
def test_create_file_meta(
client, authenticated_user, filemeta_value_mock, mock_access_success
):
response = client.post(
"/api/filemeta/",
data=json.dumps(filemeta_value_mock),
content_type="application/json",
)
assert response.status_code == 200

file_meta = FileMetaModel.objects.first()
assert file_meta.value == filemeta_value_mock


@pytest.mark.django_db
def test_create_file_meta_update_existing_entry(
client,
authenticated_user,
filemeta_db_mock,
filemeta_value_mock,
mock_access_success,
):
updated_value = {**filemeta_value_mock, "new_key": "new_value"}

response = client.post(
"/api/filemeta/",
data=json.dumps(updated_value),
content_type="application/json",
)
assert response.status_code == 200

file_meta = FileMetaModel.objects.first()
assert file_meta.value == updated_value


@pytest.mark.django_db
def test_create_file_metadata_missing_system_or_path(
client,
authenticated_user,
filemeta_db_mock,
filemeta_value_mock,
mock_access_success,
):
value_missing_system_path = {"foo": "bar"}

response = client.post(
"/api/filemeta/",
data=json.dumps(value_missing_system_path),
content_type="application/json",
)
assert response.status_code == 400
9 changes: 9 additions & 0 deletions designsafe/apps/api/filemeta/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""File Meta API routes"""

from django.urls import path
from .views import FileMetaView, CreateFileMetaView

urlpatterns = [
path("<str:system_id>/<path:path>", FileMetaView.as_view(), name="filemeta-get"),
path("", CreateFileMetaView.as_view(), name="filemeta-create"),
]
Loading

0 comments on commit 6a15778

Please sign in to comment.