Skip to content

Commit f57f42f

Browse files
GOATS-1357: Overhaul the management and listing of files in GOATS
1 parent c3f6777 commit f57f42f

16 files changed

Lines changed: 346 additions & 19 deletions

File tree

src/goats_cli/goats_template/{{ project_name }}/settings/base.py.jinja

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -439,5 +439,3 @@ GPP_ENV = "PRODUCTION"
439439
ANTARES_ENV = "PRODUCTION"
440440

441441
ANTARES_TIMEOUT = 60 # Timeout for requests to ANTARES in seconds.
442-
443-

src/goats_tom/api_views/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from .antares2goats import Antares2GoatsViewSet
22
from .astro_datalab import AstroDatalabViewSet
33
from .base_recipe import BaseRecipeViewSet
4+
from .dataproduct_type import DataProductTypeViewSet
45
from .dataproducts import DataProductsViewSet
56
from .dragons_caldb import DRAGONSCaldbViewSet
67
from .dragons_data import DRAGONSDataViewSet
@@ -33,6 +34,7 @@
3334
"DRAGONSProcessedFilesViewSet",
3435
"GPPViewSet",
3536
"DataProductsViewSet",
37+
"DataProductTypeViewSet",
3638
"Antares2GoatsViewSet",
3739
"RunProcessorViewSet",
3840
"DRAGONSDataViewSet",
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
"""Viewset for retagging the `data_product_type` of an existing `DataProduct`."""
2+
3+
__all__ = ["DataProductTypeViewSet"]
4+
5+
from django.conf import settings
6+
from guardian.shortcuts import get_objects_for_user
7+
from rest_framework import mixins, permissions
8+
from rest_framework.viewsets import GenericViewSet
9+
from tom_dataproducts.models import DataProduct
10+
11+
from goats_tom.serializers import DataProductTypeUpdateSerializer
12+
13+
14+
class DataProductTypeViewSet(mixins.UpdateModelMixin, GenericViewSet):
15+
"""Allows updating just the `data_product_type` of a `DataProduct`.
16+
17+
Restricted to the data products the requesting user already has permission
18+
to view, matching `tom_dataproducts.api_views.DataProductViewSet`.
19+
"""
20+
21+
queryset = DataProduct.objects.all()
22+
serializer_class = DataProductTypeUpdateSerializer
23+
permission_classes = [permissions.IsAuthenticated]
24+
# Only PATCH is used by the frontend; don't expose an untested PUT.
25+
http_method_names = ["patch", "head", "options"]
26+
27+
def get_queryset(self):
28+
if settings.TARGET_PERMISSIONS_ONLY:
29+
return super().get_queryset()
30+
return get_objects_for_user(
31+
self.request.user,
32+
"tom_dataproducts.view_dataproduct",
33+
klass=super().get_queryset(),
34+
)

src/goats_tom/facilities/overrides.py

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,26 @@ class UserAwareBLANCOSettings(UserTokenMixin, BLANCOSettings):
141141
credential_attr = "lcologin"
142142

143143

144+
class InferDataProductTypeMixin:
145+
"""
146+
Infer and persist `data_product_type` for products saved without one.
147+
148+
TOMToolkit's generic `save_data_products` creates `DataProduct` rows with
149+
an empty `data_product_type`; views like the visualizer filter on the
150+
stored value, so tag them at ingestion instead of at display time.
151+
"""
152+
153+
def save_data_products(self, observation_record, product_id=None):
154+
products = super().save_data_products(observation_record, product_id)
155+
for dp in products:
156+
if not dp.data_product_type and dp.data.name.endswith(
157+
(".fits", ".fits.fz")
158+
):
159+
dp.data_product_type = "fits_file"
160+
dp.save(update_fields=["data_product_type"])
161+
return products
162+
163+
144164
class UserAwareFacilityMixin:
145165
"""
146166
Mixin for facility classes that ensures user-aware facility settings
@@ -202,23 +222,25 @@ def get_form_classes_for_display(self, **kwargs):
202222
return {key: self._wrap_form_class(cls) for key, cls in base_map.items()}
203223

204224

205-
class LCOFacility(UserAwareFacilityMixin, BaseLCOFacility):
225+
class LCOFacility(InferDataProductTypeMixin, UserAwareFacilityMixin, BaseLCOFacility):
206226
"""
207227
LCO facility with per-user API keys.
208228
"""
209229

210230
settings_cls = UserAwareLCOSettings
211231

212232

213-
class SOARFacility(UserAwareFacilityMixin, BaseSOARFacility):
233+
class SOARFacility(InferDataProductTypeMixin, UserAwareFacilityMixin, BaseSOARFacility):
214234
"""
215235
SOAR facility with per-user API keys.
216236
"""
217237

218238
settings_cls = UserAwareSOARSettings
219239

220240

221-
class BLANCOFacility(UserAwareFacilityMixin, BaseBLANCOFacility):
241+
class BLANCOFacility(
242+
InferDataProductTypeMixin, UserAwareFacilityMixin, BaseBLANCOFacility
243+
):
222244
"""
223245
BLANCO facility with per-user API keys.
224246
"""

src/goats_tom/serializers/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from .antares2goats import Antares2GoatsSerializer
22
from .astro_datalab import AstroDatalabSerializer
33
from .base_recipe import BaseRecipeSerializer
4-
from .dataproduct import DataProductSerializer
4+
from .dataproduct import DataProductSerializer, DataProductTypeUpdateSerializer
55
from .dataproduct_metadata import DataProductMetadataSerializer
66
from .dragons_caldb import DRAGONSCaldbSerializer
77
from .dragons_file import DRAGONSFileFilterSerializer, DRAGONSFileSerializer
@@ -33,6 +33,7 @@
3333
"BaseRecipeSerializer",
3434
"DRAGONSProcessedFilesSerializer",
3535
"DataProductSerializer",
36+
"DataProductTypeUpdateSerializer",
3637
"RunProcessorSerializer",
3738
"DataProductMetadataSerializer",
3839
"Antares2GoatsSerializer",

src/goats_tom/serializers/dataproduct.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,14 @@
1313
This custom serializer will only be used in DRAGONS reductions.
1414
"""
1515

16-
__all__ = ["DataProductSerializer"]
16+
__all__ = ["DataProductSerializer", "DataProductTypeUpdateSerializer"]
1717

1818
from pathlib import Path
1919

2020
from django.conf import settings
2121
from django.core.files.storage import default_storage
2222
from rest_framework import serializers
23+
from tom_dataproducts.models import DataProduct
2324
from tom_dataproducts.serializers import (
2425
DataProductSerializer as BaseDataProductSerializer,
2526
)
@@ -83,3 +84,15 @@ def create(self, validated_data):
8384
dp.save()
8485

8586
return dp
87+
88+
89+
class DataProductTypeUpdateSerializer(serializers.ModelSerializer):
90+
"""Serializer to handle retagging a `DataProduct`'s `data_product_type`."""
91+
92+
data_product_type = serializers.ChoiceField(
93+
choices=list(settings.DATA_PRODUCT_TYPES.keys())
94+
)
95+
96+
class Meta:
97+
model = DataProduct
98+
fields = ["data_product_type"]

src/goats_tom/tasks/download_goa_files.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from goats_tom.utils import create_name_reduction_map
2424

2525
logger = logging.getLogger(__name__)
26+
SPEC_FILE_SUFFIX = ("_1D", "_2D")
2627

2728

2829
@dramatiq.actor(
@@ -252,9 +253,12 @@ def download_goa_files(
252253

253254
product_id = str(file_path.relative_to(settings.MEDIA_ROOT))
254255

255-
# Use the mapping to get the data product type.
256-
# If not found, return default for calibration.
257-
data_product_type = name_reduction_map.get(file_path.name, "fits_file")
256+
# Spectra follow the _1D/_2D naming convention; otherwise use the
257+
# reduction mapping, defaulting to calibration ("fits_file").
258+
if file_path.stem.endswith(SPEC_FILE_SUFFIX):
259+
data_product_type = "spectroscopy"
260+
else:
261+
data_product_type = name_reduction_map.get(file_path.name, "fits_file")
258262
# Query DataProduct by product_id.
259263
candidates = DataProduct.objects.filter(
260264
product_id=product_id,

src/goats_tom/templates/partials/dataproduct_visualizer.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,4 @@ <h5>Select and Plot Data Products</h5>
9090
noMatchesRow.style.display = visibleCount === 0 ? "" : "none";
9191
});
9292
});
93-
</script>
93+
</script>

src/goats_tom/templates/tom_dataproducts/partials/dataproduct_list_for_target.html

Lines changed: 97 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ <h4>Data</h4>
3131
<th></th>
3232
</tr>
3333
</thead>
34-
{% define_data_product_type products %}
3534
<tbody id="manageDataTBody">
3635
{% for product in products %}
3736
<tr data-file="{{ product.product_id }}">
@@ -81,7 +80,31 @@ <h4>Data</h4>
8180
>
8281
</td>
8382
<td>
84-
{% if product.data_product_type %} {{ product.get_type_display }} {% endif %}
83+
<div class="dropdown">
84+
<button
85+
class="btn btn-secondary dropdown-toggle"
86+
type="button"
87+
data-bs-toggle="dropdown"
88+
aria-expanded="false">
89+
<span class="button-text">{{ product.data_product_type_label }}</span>
90+
<span
91+
class="spinner-border spinner-border-sm d-none"
92+
role="status"
93+
aria-hidden="true"></span>
94+
</button>
95+
<ul class="dropdown-menu" data-id="{{ product.id }}">
96+
{% for value, label in data_product_type_choices %}
97+
<li>
98+
<a
99+
class="dropdown-item {% if value == product.data_product_type %}active{% endif %}"
100+
href="#"
101+
data-value="{{ value }}"
102+
>{{ label }}</a
103+
>
104+
</li>
105+
{% endfor %}
106+
</ul>
107+
</div>
85108
</td>
86109
<!-- <td>
87110
{% if sharing_destinations %}
@@ -220,6 +243,78 @@ <h4>Data</h4>
220243
await actions[action]?.();
221244
});
222245

246+
// Event listener for handling data product type changes.
247+
tableBody.addEventListener("click", async (event) => {
248+
const item = event.target.closest("a.dropdown-item[data-value]");
249+
250+
if (!item) return;
251+
event.preventDefault();
252+
253+
const menu = item.closest("ul.dropdown-menu");
254+
const { id } = menu.dataset;
255+
const { value } = item.dataset;
256+
const label = item.textContent.trim();
257+
258+
const dropdown = item.closest(".dropdown");
259+
const mainButton = dropdown.querySelector(".btn-secondary");
260+
const buttonText = mainButton.querySelector(".button-text");
261+
const spinner = mainButton.querySelector(".spinner-border");
262+
263+
buttonText.classList.add("d-none");
264+
spinner.classList.remove("d-none");
265+
mainButton.disabled = true;
266+
267+
try {
268+
await updateDataProductType(id, value);
269+
buttonText.textContent = label;
270+
menu.querySelectorAll(".dropdown-item").forEach((dropdownItem) => {
271+
dropdownItem.classList.toggle("active", dropdownItem === item);
272+
});
273+
window.toast?.show({
274+
label: "Data Product Type Updated",
275+
message: `Type changed to "${label}".`,
276+
color: "success",
277+
});
278+
} catch (error) {
279+
window.toast?.show({
280+
label: "Failed to Update Data Product Type",
281+
message: error.message,
282+
color: "danger",
283+
});
284+
} finally {
285+
buttonText.classList.remove("d-none");
286+
spinner.classList.add("d-none");
287+
mainButton.disabled = false;
288+
}
289+
});
290+
291+
/**
292+
* Updates the `data_product_type` of a data product.
293+
* @param {string} id - The unique identifier of the file.
294+
* @param {string} dataProductType - The new data product type.
295+
* @returns {Promise<void>} - Resolves when the request is completed.
296+
*/
297+
const updateDataProductType = async (id, dataProductType) => {
298+
const url = `/api/dataproducttype/${id}/`;
299+
300+
const response = await fetch(url, {
301+
method: "PATCH",
302+
headers: {
303+
"Content-Type": "application/json",
304+
"X-CSRFToken": "{{ csrf_token }}",
305+
},
306+
credentials: "same-origin",
307+
body: JSON.stringify({ data_product_type: dataProductType }),
308+
});
309+
if (!response.ok) {
310+
// The error response may not be JSON (e.g. an HTML error page).
311+
const data = await response.json().catch(() => null);
312+
throw new Error(
313+
data?.data_product_type?.[0] || "Failed to update data product type."
314+
);
315+
}
316+
};
317+
223318
/**
224319
* Sends a file to Astro DataLab.
225320
* @param {string} id - The unique identifier of the file.

src/goats_tom/templatetags/dataproduct_visualizer.py

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,6 @@ def dataproduct_visualizer(
3939

4040
active_filter = Q(data_product_type=data_type)
4141

42-
if data_type == "spectroscopy":
43-
active_filter |= Q(data_product_type="fits_file")
44-
active_filter |= Q(data__iendswith="fits.fz")
45-
4642
dataproducts = DataProduct.objects.filter(target=target).filter(active_filter)
4743

4844
return {"target": target, "dataproducts": dataproducts, "data_type": data_type}

0 commit comments

Comments
 (0)