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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 2 additions & 4 deletions uvdat/core/frame_previews/lookup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from typing import TYPE_CHECKING

from uvdat.core.frame_previews.fingerprint import params_fingerprint, style_fingerprint
from uvdat.core.frame_previews.fingerprint import params_fingerprint
from uvdat.core.frame_previews.types import FramePreviewData
from uvdat.core.models.frame_preview import PreviewStatus, RasterFramePreview

Expand Down Expand Up @@ -79,9 +79,7 @@ def previews_current_for_fingerprint(layer: Layer, fingerprint: str) -> bool:


def layer_default_fingerprint(layer: Layer) -> str:
"""Fingerprint for layer-level default previews (default style params, else ``{}``)."""
if layer.default_style_id is not None:
return style_fingerprint(layer.default_style)
"""Fingerprint for layer-level default previews with no styling applied."""
return params_fingerprint({})


Expand Down
23 changes: 23 additions & 0 deletions uvdat/core/migrations/0030_proj_scoped_default_styles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Generated by Django 6.0.7 on 2026-09-18 19:18

from __future__ import annotations

from django.db import migrations, models


class Migration(migrations.Migration):
dependencies = [
("core", "0029_bookmarks"),
]

operations = [
migrations.RemoveField(
model_name="layer",
name="default_style",
),
migrations.AddField(
model_name="layerstyle",
name="is_default",
field=models.BooleanField(default=False),
),
]
3 changes: 0 additions & 3 deletions uvdat/core/models/layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@ class Layer(models.Model):
name = models.CharField(max_length=255, default="Layer")
dataset = models.ForeignKey(Dataset, related_name="layers", on_delete=models.CASCADE)
metadata = models.JSONField(blank=True, null=True)
default_style = models.ForeignKey(
"LayerStyle", null=True, related_name="default_layer", on_delete=models.SET_NULL
)

project_filter_path = "dataset__project"
objects = ProjectQuerySet.as_manager()
Expand Down
1 change: 1 addition & 0 deletions uvdat/core/models/styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class LayerStyle(models.Model):
name = models.CharField(max_length=255, default="Layer Style")
layer = models.ForeignKey(Layer, related_name="styles", on_delete=models.CASCADE)
project = models.ForeignKey(Project, related_name="styles", on_delete=models.CASCADE)
is_default = models.BooleanField(default=False)
default_frame = models.IntegerField(default=0, validators=[MinValueValidator(0)])
opacity = models.DecimalField(
default=1,
Expand Down
2 changes: 1 addition & 1 deletion uvdat/core/rest/dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def tags(self, request, **kwargs):
def layers(self, request, **kwargs):
dataset: Dataset = self.get_object()
layers = list(layer_queryset_with_previews().filter(dataset=dataset))
serializer = LayerSerializer(layers, many=True)
serializer = LayerSerializer(layers, many=True, context={"request": request})
return Response(serializer.data, status=200)

@action(detail=True, methods=["get"])
Expand Down
29 changes: 17 additions & 12 deletions uvdat/core/rest/layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,44 +56,49 @@ def create(self, request, **kwargs):
"You do not have permission to create styles in this project.",
status=403,
)
is_default = request.data.pop("is_default", False)
is_default = request.data.get("is_default", False)
serializer = LayerStyleWithPreviewsSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
with transaction.atomic():
try:
instance = serializer.save()
except jsonschema.exceptions.ValidationError as e:
return Response(e.message, status=400)
if is_default and instance.layer.default_style != instance:
instance.layer.default_style = instance
instance.layer.save()
if is_default:
instance.layer.styles.filter(project=instance.project).exclude(
id=instance.id
).update(is_default=False)
# Enqueue after commit so a worker cannot start before style rows exist.
invalidate_and_enqueue_previews(instance)
return Response(serializer.data, status=200)

def partial_update(self, request, **kwargs):
instance = self.get_object()
is_default = request.data.pop("is_default", False)
is_default = request.data.get("is_default", False)
serializer = LayerStyleWithPreviewsSerializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
with transaction.atomic():
try:
serializer.save()
except jsonschema.exceptions.ValidationError as e:
return Response(e.message, status=400)
if is_default and instance.layer.default_style != instance:
instance.layer.default_style = instance
instance.layer.save()
if is_default:
instance.layer.styles.filter(project=instance.project).exclude(
id=instance.id
).update(is_default=False)
invalidate_and_enqueue_previews(instance)
return Response(serializer.data, status=200)

def destroy(self, request, *args, **kwargs):
instance = self.get_object()
with transaction.atomic():
if instance.layer.default_style == instance:
instance.layer.default_style = (
LayerStyle.objects.filter(layer=instance.layer).exclude(id=instance.id).first()
if instance.is_default:
new_default = (
instance.layer.styles.filter(project=instance.project)
.exclude(id=instance.id)
.first()
)
instance.layer.save()
new_default.is_default = True
new_default.save()
self.perform_destroy(instance)
return Response(status=204)
4 changes: 2 additions & 2 deletions uvdat/core/rest/querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ def layer_queryset_with_previews(
# fingerprint are loaded in ``previews_by_frame_id`` with a filtered query.
if for_layer_style:
qs = queryset if queryset is not None else LayerStyle.objects.all()
return qs.select_related("layer", "layer__default_style").prefetch_related(
return qs.select_related("layer").prefetch_related(
STYLE_LAYER_RASTER_FRAMES_PREFETCH,
)

qs = queryset if queryset is not None else Layer.objects.all()
return qs.select_related("dataset", "default_style").prefetch_related(
return qs.select_related("dataset").prefetch_related(
LAYER_RASTER_FRAMES_PREFETCH,
)
21 changes: 13 additions & 8 deletions uvdat/core/rest/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,15 +169,9 @@ def _omit_null_field(data: dict, field: str) -> None:


class LayerStyleSerializer(serializers.ModelSerializer):
is_default = serializers.SerializerMethodField("get_is_default")
# Client-computed django-large-image style JSON; write-only (used for previews).
raster_style_params = serializers.JSONField(required=False, allow_null=True, write_only=True)

def get_is_default(self, obj):
if obj.layer.default_style is None:
return False
return obj.layer.default_style.id == obj.id

def to_representation(self, instance):
data = super().to_representation(instance)
data["style_spec"] = instance.repr_style_configs()
Expand All @@ -191,7 +185,8 @@ def create(self, validated_data):

def update(self, instance, validated_data):
style_spec = self.initial_data.pop("style_spec", None)
instance.save_style_configs(style_spec)
if style_spec is not None:
instance.save_style_configs(style_spec)
return super().update(instance, validated_data)

class Meta:
Expand Down Expand Up @@ -222,10 +217,20 @@ def to_representation(self, instance):


class LayerSerializer(serializers.ModelSerializer):
default_style = LayerStyleSerializer()
default_style = serializers.SerializerMethodField("get_project_default_style")
multiframe_previews = serializers.SerializerMethodField()
preview_status = serializers.SerializerMethodField()

def get_project_default_style(self, obj):
request = self.context.get("request")
if request is not None:
project_id = request.query_params.get("project")
if project_id is not None:
style = obj.styles.filter(project__id=project_id, is_default=True).first()
if style is not None:
return LayerStyleSerializer(style).data
return None

def get_preview_status(self, obj):
return get_layer_preview_status(obj)

Expand Down
6 changes: 1 addition & 5 deletions uvdat/core/tasks/analytics/flood_simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,12 +192,8 @@ def flood_simulation(result_id):
# Create a default style for new layer
layer = dataset.layers.first()
style = LayerStyle.objects.create(
name="Flood Depth",
layer=layer,
project=result.project,
name="Flood Depth", layer=layer, project=result.project, is_default=True
)
layer.default_style = style
layer.save()
cmap, _ = Colormap.objects.get_or_create(
name="flood",
project=result.project,
Expand Down
1 change: 1 addition & 0 deletions uvdat/core/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ class Meta:
project = factory.SubFactory(ProjectFactory)
default_frame = factory.fuzzy.FuzzyInteger(1, 10)
opacity = factory.fuzzy.FuzzyFloat(0.0, 1.0)
is_default = False


class NetworkFactory(factory.django.DjangoModelFactory):
Expand Down
5 changes: 1 addition & 4 deletions uvdat/core/tests/test_frame_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -619,10 +619,8 @@ def test_layer_api_includes_multiframe_previews(
project,
user,
):
layer_style = layer_style_factory()
layer_style = layer_style_factory(is_default=True)
layer = layer_style.layer
layer.default_style = layer_style
layer.save(update_fields=["default_style"])
project.set_collaborators([user])
project.datasets.set([layer.dataset])
frame_0 = layer_frame_factory(layer=layer, index=0)
Expand Down Expand Up @@ -664,7 +662,6 @@ def test_layer_api_includes_multiframe_previews(
"bounds": {},
},
]
assert "multiframe_previews" not in data.get("default_style", {})


@pytest.mark.django_db
Expand Down
102 changes: 102 additions & 0 deletions uvdat/core/tests/test_styles.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,3 +208,105 @@ def test_rest_style_create_and_update(client, expected_status, layer, project, u
assert serialized_result.pop("id") is not None
assert serialized_result.pop("is_default") is not None
assert serialized_result == style


@pytest.mark.django_db
def test_layer_default_style_project_scope(
authenticated_api_client,
user,
project_factory,
layer,
layer_style_factory,
):
def assert_default_style_is(expected, proj_id=None):
url = f"/api/v1/datasets/{layer.dataset.id}/layers/"
if proj_id is not None:
url += f"?project={proj_id}"
resp = authenticated_api_client.get(url)
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
default_style = data[0].get("default_style")
if expected is None:
assert default_style is None
else:
assert default_style is not None
assert default_style.get("id") == expected.get("id")

def create_style(name, proj_id, is_default):
resp = authenticated_api_client.post(
"/api/v1/layer-styles/",
{
"name": name,
"layer": layer.id,
"project": proj_id,
"is_default": is_default,
"style_spec": SIMPLE_SPEC,
},
)
return resp.json()

# None when no project specified in request
assert_default_style_is(None)

# Create two separate projects
proj_1 = project_factory()
proj_1.set_owner(user)
proj_1.datasets.add(layer.dataset)
proj_2 = project_factory()
proj_2.set_owner(user)
proj_2.datasets.add(layer.dataset)

# None for projects with no styles created yet
assert_default_style_is(None, proj_1.id)
assert_default_style_is(None, proj_2.id)

# Create a style on project 1 with is_default=False
style_1 = create_style("Style 1", proj_1.id, False) # noqa: FBT003
# Both projects still have None default styles
assert_default_style_is(None, proj_1.id)
assert_default_style_is(None, proj_2.id)

# Set is_default to True on style 1
style_1 = authenticated_api_client.patch(
f"/api/v1/layer-styles/{style_1.get('id')}/", {"is_default": True}
).json()
# Project 1 has default style 1 and project 2 still has None
assert_default_style_is(style_1, proj_1.id)
assert_default_style_is(None, proj_2.id)

# Create a style on project 2 with is_default=True
style_2 = create_style("Style 2", proj_2.id, True) # noqa: FBT003
# Project 1 has Style 1 and Project 2 has Style 2
assert_default_style_is(style_1, proj_1.id)
assert_default_style_is(style_2, proj_2.id)

# Create another style on project 2 with is_default=True
style_3 = create_style("Style 3", proj_2.id, True) # noqa: FBT003
# Project 1 has Style 1 and Project 2 has Style 3
assert_default_style_is(style_1, proj_1.id)
assert_default_style_is(style_3, proj_2.id)
# Style 2 now has is_default=False
style_2 = authenticated_api_client.get(f"/api/v1/layer-styles/{style_2.get('id')}/").json()
assert not style_2.get("is_default")

# Set is_default back to True on style 2
style_2 = authenticated_api_client.patch(
f"/api/v1/layer-styles/{style_2.get('id')}/", {"is_default": True}
).json()
# Project 1 has Style 1 and Project 2 has Style 2
assert_default_style_is(style_1, proj_1.id)
assert_default_style_is(style_2, proj_2.id)
# Style 3 now has is_default=False
style_3 = authenticated_api_client.get(f"/api/v1/layer-styles/{style_3.get('id')}/").json()
assert not style_3.get("is_default")

# Delete style 2
resp = authenticated_api_client.delete(f"/api/v1/layer-styles/{style_2.get('id')}/")
assert resp.status_code == 204
# Style 3 now has is_default=True
style_3 = authenticated_api_client.get(f"/api/v1/layer-styles/{style_3.get('id')}/").json()
assert style_3.get("is_default")
# Project 1 has Style 1 and Project 2 has Style 3
assert_default_style_is(style_1, proj_1.id)
assert_default_style_is(style_3, proj_2.id)
9 changes: 7 additions & 2 deletions web/src/api/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,13 @@ export async function spawnDatasetConversion(
.data;
}

export async function getDatasetLayers(datasetId: number): Promise<Layer[]> {
return (await apiClient.get(`datasets/${datasetId}/layers/`)).data;
export async function getDatasetLayers(
datasetId: number,
projectId: number,
): Promise<Layer[]> {
return (
await apiClient.get(`datasets/${datasetId}/layers/?project=${projectId}`)
).data;
}

export async function getLayer(layerId: number): Promise<Layer> {
Expand Down
6 changes: 5 additions & 1 deletion web/src/store/layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ export const useLayerStore = defineStore("layer", () => {
async function fetchAvailableLayersForDataset(datasetId: number) {
// fetch all layers on a dataset and update availableLayers
// such that any existing layers are overwritten and new ones are added
const datasetLayers = await getDatasetLayers(datasetId);
if (!projectStore.currentProject) return;
const datasetLayers = await getDatasetLayers(
datasetId,
projectStore.currentProject.id,
);
const datasetLayerIds = new Set(datasetLayers.map((l: Layer) => l.id));
const existingLayerIds = new Set(
availableLayers.value.map((l: Layer) => l.id),
Expand Down
1 change: 1 addition & 0 deletions web/src/store/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ export const useProjectStore = defineStore("project", () => {
availableBookmarks.value = [];

layerStore.selectedLayers = [];
layerStore.availableLayers = [];
styleStore.selectedLayerStyles = {};
styleStore.clearStyleEditing();
useFramePreviewStore().clearAll();
Expand Down