diff --git a/README.md b/README.md index d250a25..8531988 100644 --- a/README.md +++ b/README.md @@ -11,28 +11,30 @@ Install the module into your TOM environment: pip install tom-cfht ``` -1. In your project `settings.py`, add `tom_cfht` to your `INSTALLED_APPS` setting: +Then, in your project `settings.py`, add `tom_cfht` to your `INSTALLED_APPS` setting: - ```python - INSTALLED_APPS = [ - ... - 'tom_cfht', - ] - ``` - -2. Add `tom_cfht.cfht.CFHTFacility` to the `TOM_FACILITY_CLASSES` in your TOM's -`settings.py`: - ```python - TOM_FACILITY_CLASSES = [ - 'tom_observations.facilities.lco.LCOFacility', - ... - 'tom_cfht.cfht.CFHTFacility', - ] - ``` +```python +INSTALLED_APPS = [ + ... + 'tom_cfht', +] +``` + +That's it. `tom_cfht` implements the `observation_facilities()` AppConfig integration point, +so the CFHT facility is discovered automatically — it does not need to be added to +`TOM_FACILITY_CLASSES` in your `settings.py`. ## Configuration -Include the following settings inside the `FACILITIES` dictionary inside `settings.py`: +### Kealahou API access token + +Observing-program and target-sync features talk to CFHT's Kealahou API, which authenticates +with an **API access token** — this is not your Kealahou web-UI password. Generate one in the +Kealahou web UI under *Account → Manage Tokens* (it is only shown once, at creation). + +Each TOM user should save their token on their CFHT user profile (User Profile page). A +TOM-wide fallback token can be configured in the `FACILITIES` dictionary in `settings.py`; +a user's profile token, when set, takes precedence: ```python FACILITIES = { @@ -43,3 +45,23 @@ Include the following settings inside the `FACILITIES` dictionary inside `settin } ``` +## Target syncing with Kealahou + +The CFHT facility page (*Facilities → CFHT* in the navbar) shows one tab per Kealahou +observing program. Each program's Targets section shows which targets are only in the +program's Target Grouping (the section is titled with the Target Grouping's name), only in +Kealahou, or in both — in agreement, or with property discrepancies shown field by field. Check rows in either direction and press **Sync selected targets** to import the +checked Kealahou targets into the TOM and upload the checked TOM targets to Kealahou. + +Before syncing, each program must be associated with a **Target Grouping** — you choose an +existing one or create one (a name like `CFHT-MEGACAM-25BE25` is suggested) the first time +you open the program's Targets section. Membership in that Target Grouping is what marks a +target for syncing with the program: adding a target marks it for upload, downloads from +Kealahou land in it, and removing a member withdraws the target from TOM-side syncing (it +then shows under "In Kealahou only" again, ready to re-download). Only sidereal targets are +supported for now. + +The CFHT observation form also shows the target's Kealahou status per program, with a +one-click upload (which also adds the target to the program's Target Grouping) for targets +that are not in Kealahou yet. + diff --git a/tom_cfht/admin.py b/tom_cfht/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/tom_cfht/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/tom_cfht/apps.py b/tom_cfht/apps.py index 8698533..4cf5bd3 100644 --- a/tom_cfht/apps.py +++ b/tom_cfht/apps.py @@ -1,6 +1,47 @@ from django.apps import AppConfig +from django.urls import path, include class TomCFHTConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'tom_cfht' + name = 'tom_cfht' # python path to the application, like 'django.contrib.admin' + url_prefix = 'cfht' # URL path prefix for this app's pages: HOST:PORT/cfht/... (see include_url_paths()) + + def include_url_paths(self): + """ + Integration point for adding URL patterns to the Tom Common URL configuration. + This method should return a list of URL patterns to be included in the main URL configuration. + + Note: url_prefix only affects the path; the URL namespace remains self.label ('tom_cfht'), + so reverses like 'tom_cfht:facility-index' are unaffected. + """ + urlpatterns = [ + path(f'{self.url_prefix}/', include(f'{self.name}.urls', namespace=f'{self.label}')) + ] + return urlpatterns + + def observation_facilities(self): + """ + Integration point for including this app's observation facilities in the TOM. + + This method should return a list of dictionaries, each with a `class` key giving the dot separated + path to a Facility class (consumed by ``tom_observations.facility.get_service_classes()``, so the + facility is available without being listed in ``settings.TOM_FACILITY_CLASSES``), and an optional + `url` key giving the namespaced URL name of the facility's landing page (used by the navbar + "Facilities" menu). Omit `url` for a facility with no landing page: it is still registered, but + gets no navbar menu item. + """ + return [{'class': f'{self.name}.cfht.CFHTFacility', + 'url': f'{self.label}:facility-index'}] + + def profile_details(self): + """ + Integration point for adding items to the user profile page. + + This method should return a list of dictionaries that include a `partial` key pointing to the path of the html + profile partial. The `context` key should point to the dot separated string path to the templatetag that will + return a dictionary containing new context for the accompanying partial. + Typically, this partial will be a bootstrap card displaying some app specific user data. + """ + return [{'partial': f'{self.name}/partials/profile_cfht.html', + 'context': f'{self.name}.templatetags.cfht_extras.cfht_profile_data'}] diff --git a/tom_cfht/cfht.py b/tom_cfht/cfht.py new file mode 100644 index 0000000..7b16f34 --- /dev/null +++ b/tom_cfht/cfht.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from crispy_forms.layout import Layout +from django import forms +from django.core.exceptions import ImproperlyConfigured + +from aeonlib.cfht.facility import CFHTFacility as AeonCFHTFacility +from aeonlib.cfht.models import ProgramInfo + +from tom_observations.facility import BaseRoboticObservationFacility, BaseRoboticObservationForm, CredentialStatus + +from tom_cfht.models import CFHTProfile + + +class CFHTFacilityForm(BaseRoboticObservationForm): + exposure_time = forms.IntegerField() + exposure_count = forms.IntegerField() + + def layout(self): + return Layout( + 'exposure_time', + 'exposure_count' + ) + + +class CFHTFacility(BaseRoboticObservationFacility): + name = 'CFHT' + # Facility-specific observation form page: adds the Kealahou target status/upload panel. + # ObservationCreateView.get_template_names() tries this template first. + template_name = 'tom_cfht/observation_form.html' + observation_types: list[tuple[str, str]] = [ + ('OBSERVATION', 'Custom Observation') + ] + + observation_forms: dict[str, type[BaseRoboticObservationForm]] = { + 'OBSERVATION': CFHTFacilityForm, + } + + def get_access_token(self) -> str: + """Return the Kealahou API access token for the current user. + + The per-user token from the ``CFHTProfile`` (set via ``set_user()``) takes + precedence; falls back to the TOM-wide default in + ``settings.FACILITIES['CFHT']['CFHT_ACCESS_TOKEN']``. Tracks the outcome in + ``self.credential_status``. + + Note: this is the Bearer credential for API authentication -- unrelated to + Kealahou's entity identifiers, which are unfortunately also called "tokens". + + Raises: + ImproperlyConfigured: if neither source provides a token. + """ + if self.user is not None and self.user.is_authenticated: + try: + profile_access_token = str(self.user.cfhtprofile.cfht_access_token or '').strip() + except CFHTProfile.DoesNotExist: + profile_access_token = '' + if profile_access_token: + self.credential_status = CredentialStatus.USING_USER_CREDS + return profile_access_token + + # fall back to the TOM-wide default from settings.FACILITIES + setting_credentials = self._get_setting_credentials('CFHT', ['CFHT_ACCESS_TOKEN']) + default_access_token = str(setting_credentials['CFHT_ACCESS_TOKEN'] or '').strip() + if self._is_credential_empty(default_access_token): + self.credential_status = CredentialStatus.PROFILE_EMPTY + raise ImproperlyConfigured( + 'No CFHT access token found. Generate one on the Kealahou "Manage Tokens" page and ' + "save it in your CFHT user profile (or in settings.FACILITIES['CFHT'])." + ) + self.credential_status = CredentialStatus.USING_DEFAULTS + return default_access_token + + def get_aeon_facility(self) -> AeonCFHTFacility: + """Return an aeonlib Kealahou client authenticated as the current user.""" + return AeonCFHTFacility(access_token=self.get_access_token()) + + def get_observing_programs(self) -> list[ProgramInfo]: + """Return the current user's CFHT observing programs from the Kealahou API.""" + return self.get_aeon_facility().programs() + + def data_products(self): + pass + + def get_form(self, observation_type: str | None) -> type[BaseRoboticObservationForm]: + """Return the observation form class for ``observation_type``. + """ + if observation_type is None: + return CFHTFacilityForm + return self.observation_forms.get(observation_type, CFHTFacilityForm) + + def get_observation_status(self): + pass + + def get_observation_url(self): + pass + + def get_observing_sites(self) -> dict[str, dict]: + """Return the facility's observing site(s) for the visibility and airmass planner. + + From CFHT Observatory Manual: The telescope itself is of 3.58 meters aperture. + It is located on Mauna Kea at an altitude (declination axis) of 4204 m (13,793 feet), + at latitude +19o 49' 41.86" and longitude 155o 28' 18.00". + """ + cfht_location_params = { + 'Mauna Kea': { + 'sitecode': 'cfht', + 'latitude': 19.8283, + 'longitude': -155.4716, + 'elevation': 4204, + } + } + return cfht_location_params + + def get_terminal_observing_states(self): + pass + + def submit_observation(self): + pass + + def validate_observation(self): + pass diff --git a/tom_cfht/kealahou.py b/tom_cfht/kealahou.py new file mode 100644 index 0000000..356c383 --- /dev/null +++ b/tom_cfht/kealahou.py @@ -0,0 +1,478 @@ +"""Service layer between the aeonlib Kealahou client and the tom_cfht views. + +Terminology: Kealahou identifies entities by a field it calls ``token`` -- an immutable +unique identifier, unrelated to the API *access* token. This module always qualifies the +word: ``program_token`` (e.g. '25BE25') and ``kealahou_target_token`` +(e.g. '25BE25-1758314224958', client-generated). + +Each CFHT program is mirrored in the TOM by a user-chosen Target Grouping (``TargetList``); +the mapping lives in ``KealahouProgramAssociation`` and callers resolve it before invoking +the sync functions here. + +The functions here are deliberately free of view/HTTP concerns so they can be unit tested +with a mocked aeonlib facade. +""" +from __future__ import annotations + +import logging +import random +import re +from dataclasses import dataclass, field + +import httpx +import pydantic +from django.db import transaction + +from aeonlib.cfht.facility import CFHTFacility as AeonCFHTFacility +from aeonlib.cfht.models import ( + DoubleValue, + FixedTargetProperMotion, + Instrument, + SkyCoordinate, + TargetData, + TargetDataFixedTarget, + TargetDataMagnitude, +) + +from tom_targets.models import Target, TargetList + +from tom_cfht.models import KealahouTargetLink + +logger = logging.getLogger(__name__) + +# Kealahou requires one specific magnitude band per instrument (workshop README). +REQUIRED_MAGNITUDE_BAND_BY_INSTRUMENT: dict[Instrument, str] = { + Instrument.spirou: 'h', + Instrument.espadons: 'v', + Instrument.megacam: 'ab', +} +# The spectrographs additionally require an effective temperature; SPIRou also wants +# an estimated radial velocity. (Not used for MegaCam.) +INSTRUMENTS_REQUIRING_TEMPERATURE = frozenset({Instrument.spirou, Instrument.espadons}) +INSTRUMENTS_REQUIRING_RADIAL_VELOCITY = frozenset({Instrument.spirou}) + +# Kealahou target-name rules: the name becomes the FITS OBJECT keyword. +TARGET_NAME_MAX_LENGTH = 39 +_TARGET_NAME_ALLOWED = re.compile(r'^[A-Za-z0-9 !@#$%^&*()_\-+.,?/\[\]<>]+$') + +# Tolerance for the sync-state discrepancy display. The Kealahou web UI round-trips RA +# through sexagesimal at 0.1-second-of-time precision (measured on staging: an untouched +# RA came back 0.60 arcsec different; the rounding quantum is 1.5 arcsec, so errors up to +# 0.75 arcsec are pure representation noise). 1.0 arcsec absorbs that noise while still +# flagging genuinely different positions. Proper motions compare at 0.01 mas/yr. +COORDINATE_TOLERANCE_ARCSEC = 1.0 +_COORDINATE_TOLERANCE_DEGREES = COORDINATE_TOLERANCE_ARCSEC / 3600.0 +PROPER_MOTION_TOLERANCE_MAS = 0.01 + +# Kealahou requires every target to reference a pointing offset. '00AZ00-PO++1' +# is the system-owned "no offset" default (offset {}, user_token SYSTEM), observed on the +# staging programs and hardcoded the same way in the Kealahou workshop examples. +DEFAULT_POINTING_OFFSET_TOKEN_TEMPLATE = '00AZ00-PO+{instrument}+1' + + +@dataclass +class FieldDiscrepancy: + """One field whose value differs between the TOM target and its Kealahou counterpart.""" + field_name: str + tom_value: object + kealahou_value: object + + +@dataclass +class LinkedTargetPair: + """A TOM target and its linked Kealahou counterpart, with any field discrepancies.""" + link: KealahouTargetLink + target: Target + target_data: TargetData + discrepancies: list[FieldDiscrepancy] = field(default_factory=list) + # Kealahou-side values of the instrument's required fields, for display + required_field_values: dict = field(default_factory=dict) + + +@dataclass +class ImportCandidate: + """A Kealahou target not yet linked to the TOM. + + If a TOM target with the same name already exists, importing will link it rather + than create a duplicate; ``existing_target`` carries that target for UI display. + """ + target_data: TargetData + existing_target: Target | None = None + # Kealahou-side values of the instrument's required fields, for display + required_field_values: dict = field(default_factory=dict) + + +@dataclass +class SyncState: + """The four sync buckets displayed by the target-sync panel.""" + tom_only: list[Target] = field(default_factory=list) + kealahou_only: list[ImportCandidate] = field(default_factory=list) + linked_in_sync: list[LinkedTargetPair] = field(default_factory=list) + linked_discrepant: list[LinkedTargetPair] = field(default_factory=list) + + +@dataclass +class UploadRequest: + """One TOM target to upload to Kealahou, with the instrument's required extra fields.""" + target: Target + magnitude: float | None = None + temperature_effective: float | None = None + radial_velocity_kmps: float | None = None + + +@dataclass +class UploadResult: + """Outcome of one target upload attempt.""" + target: Target + success: bool + message: str = '' + + +@dataclass +class ResultMessage: + """A user-facing outcome message with a severity, for the sync panels. + + Failures render in a bootstrap danger alert; successes/notices in an info alert. + """ + text: str + success: bool = True + + +def kealahou_api_error_messages(error: httpx.HTTPStatusError) -> list[str]: + """Extract Kealahou's human-readable error messages from an HTTP error response. + + Kealahou 4xx responses carry ``{"error": {"messages": [...]}}`` explaining exactly + what was rejected (e.g. a value out of range); surfacing them beats httpx's generic + status-code message. + """ + try: + payload = error.response.json() + except ValueError: # not JSON (e.g. an HTML error page from a proxy) + return [] + error_info = payload.get('error') or {} + return error_info.get('messages') or [] + + +def required_field_values(target_data: TargetData, instrument: Instrument | None) -> dict: + """Return the Kealahou-side values of the instrument's required target fields. + + Used for display in the sync tables (the TOM stores none of these fields, so the + Kealahou copy is the only source). Values may be None when Kealahou has no value. + """ + magnitude_band = REQUIRED_MAGNITUDE_BAND_BY_INSTRUMENT.get(instrument) + magnitude = None + if magnitude_band is not None and target_data.magnitude is not None: + band_value = getattr(target_data.magnitude, magnitude_band, None) + magnitude = band_value.value if band_value is not None else None + radial_velocity_kmps = None + if target_data.fixed_target is not None and target_data.fixed_target.estimated_radial_velocity_kmps is not None: + radial_velocity_kmps = target_data.fixed_target.estimated_radial_velocity_kmps.value + return { + 'magnitude': magnitude, + 'temperature_effective': target_data.temperature_effective, + 'radial_velocity_kmps': radial_velocity_kmps, + } + + +def default_target_list_name(program_token: str, instrument: Instrument | None) -> str: + """Return the suggested name for a new Target Grouping mirroring a CFHT program. + + Includes the instrument so users see more than a bare runid, e.g. 'CFHT-MEGACAM-25BE25'. + """ + instrument_name = instrument.value if instrument is not None else 'UNKNOWN' + return f'CFHT-{instrument_name}-{program_token}' + + +def validate_kealahou_target_name(name: str) -> None: + """Raise ValueError unless ``name`` satisfies Kealahou's target-name rules. + + The name becomes the FITS OBJECT keyword: at most 39 characters; letters, digits, + spaces, or ``!@#$%^&*()_-+.,?/[]<>``. + """ + if len(name) > TARGET_NAME_MAX_LENGTH: + raise ValueError(f'Target name {name!r} exceeds Kealahou limit of {TARGET_NAME_MAX_LENGTH} characters.') + if not _TARGET_NAME_ALLOWED.match(name): + raise ValueError(f'Target name {name!r} contains characters Kealahou does not allow ' + '(allowed: letters, digits, spaces, and !@#$%^&*()_-+.,?/[]<>).') + + +def generate_kealahou_target_token(program_token: str) -> str: + """Return a new client-generated kealahou_target_token: ``-<10 digits>``.""" + return f'{program_token}-{random.randint(1_000_000_000, 9_999_999_999)}' + + +def target_data_from_target(target: Target, program_token: str, instrument: Instrument, + magnitude: float | None = None, + temperature_effective: float | None = None, + radial_velocity_kmps: float | None = None, + kealahou_target_token: str | None = None, + version: int | None = None) -> TargetData: + """Build an aeonlib ``TargetData`` from a (sidereal) TOM target for upload to Kealahou. + + Args: + target: the TOM target (must be SIDEREAL; moving targets are not yet supported). + program_token: Kealahou program identifier the target is being uploaded to. + instrument: the program's instrument; selects the required magnitude band. + magnitude: value for the instrument's required band (H/V/AB). + temperature_effective: Kelvin; required by Kealahou for SPIRou/ESPaDOnS. + radial_velocity_kmps: km/s; used for SPIRou. + kealahou_target_token: reuse an existing identifier (updates); generated if None. + version: Kealahou lock version when updating an existing Kealahou target; None creates. + + Returns: + A populated ``TargetData`` ready for ``create_or_update_target()``. + + Raises: + ValueError: if the target is not sidereal, or its name violates Kealahou's rules, + or a field required by ``instrument`` is missing. + """ + if target.type != Target.SIDEREAL: + raise ValueError(f'Target {target.name!r} is not sidereal; only sidereal targets can be uploaded (for now).') + validate_kealahou_target_name(target.name) + + magnitude_band = REQUIRED_MAGNITUDE_BAND_BY_INSTRUMENT.get(instrument) + if magnitude_band is not None and magnitude is None: + raise ValueError(f'{instrument.value} requires a {magnitude_band.upper()}-band magnitude.') + if instrument in INSTRUMENTS_REQUIRING_TEMPERATURE and temperature_effective is None: + raise ValueError(f'{instrument.value} requires an effective temperature (Kelvin).') + if instrument in INSTRUMENTS_REQUIRING_RADIAL_VELOCITY and radial_velocity_kmps is None: + raise ValueError(f'{instrument.value} requires an estimated radial velocity (km/s).') + + proper_motion = None + if target.pm_ra is not None or target.pm_dec is not None: + # TOM and Kealahou both use milliarcseconds/year + proper_motion = FixedTargetProperMotion(ra_mas=target.pm_ra, dec_mas=target.pm_dec) + + target_magnitude = None + if magnitude_band is not None: + target_magnitude = TargetDataMagnitude() + setattr(target_magnitude, magnitude_band, DoubleValue(value=magnitude)) + + return TargetData( + token=kealahou_target_token or generate_kealahou_target_token(program_token), + name=target.name, + version=version, + fixed_target=TargetDataFixedTarget( + coordinate=SkyCoordinate(ra=target.ra, dec=target.dec), + proper_motion=proper_motion, + estimated_radial_velocity_kmps=( + DoubleValue(value=radial_velocity_kmps) if radial_velocity_kmps is not None else None), + ), + magnitude=target_magnitude, + temperature_effective=temperature_effective, + standard_star=False, + pointing_offset_token=DEFAULT_POINTING_OFFSET_TOKEN_TEMPLATE.format(instrument=instrument.value), + ) + + +def target_from_target_data(target_data: TargetData) -> Target: + """Build an (unsaved) sidereal TOM ``Target`` from a Kealahou fixed target for import. + + Raises: + ValueError: for moving targets (not yet supported) or targets without coordinates. + """ + if target_data.fixed_target is None or target_data.fixed_target.coordinate is None: + raise ValueError(f'Kealahou target {target_data.name!r} has no fixed coordinate; ' + 'moving targets are not yet supported.') + coordinate = target_data.fixed_target.coordinate + proper_motion = target_data.fixed_target.proper_motion + return Target( + name=target_data.name, + type=Target.SIDEREAL, + ra=coordinate.ra, + dec=coordinate.dec, + pm_ra=proper_motion.ra_mas if proper_motion else None, + pm_dec=proper_motion.dec_mas if proper_motion else None, + ) + + +def _floats_differ(tom_value: float | None, kealahou_value: float | None, tolerance: float) -> bool: + if tom_value is None and kealahou_value is None: + return False + if tom_value is None or kealahou_value is None: + return True + return abs(tom_value - kealahou_value) > tolerance + + +def compare_target_fields(target: Target, target_data: TargetData) -> list[FieldDiscrepancy]: + """Compare the fields shared by a TOM target and its Kealahou counterpart. + + Compared fields: name, ra, dec, pm_ra, pm_dec (with float tolerances). Fields that + exist on only one side (magnitudes, Teff, ...) are not compared. + """ + discrepancies = [] + if target.name != target_data.name: + discrepancies.append(FieldDiscrepancy('name', target.name, target_data.name)) + + coordinate = target_data.fixed_target.coordinate if target_data.fixed_target else None + kealahou_ra = coordinate.ra if coordinate else None + kealahou_dec = coordinate.dec if coordinate else None + if _floats_differ(target.ra, kealahou_ra, _COORDINATE_TOLERANCE_DEGREES): + discrepancies.append(FieldDiscrepancy('ra', target.ra, kealahou_ra)) + if _floats_differ(target.dec, kealahou_dec, _COORDINATE_TOLERANCE_DEGREES): + discrepancies.append(FieldDiscrepancy('dec', target.dec, kealahou_dec)) + + proper_motion = target_data.fixed_target.proper_motion if target_data.fixed_target else None + kealahou_pm_ra = proper_motion.ra_mas if proper_motion else None + kealahou_pm_dec = proper_motion.dec_mas if proper_motion else None + if _floats_differ(target.pm_ra, kealahou_pm_ra, PROPER_MOTION_TOLERANCE_MAS): + discrepancies.append(FieldDiscrepancy('pm_ra', target.pm_ra, kealahou_pm_ra)) + if _floats_differ(target.pm_dec, kealahou_pm_dec, PROPER_MOTION_TOLERANCE_MAS): + discrepancies.append(FieldDiscrepancy('pm_dec', target.pm_dec, kealahou_pm_dec)) + + return discrepancies + + +def compute_sync_state(program_token: str, kealahou_targets: list[TargetData], + target_list: TargetList, instrument: Instrument | None = None) -> SyncState: + """Partition targets into the four sync buckets for one program. + + Args: + program_token: the program being synced. + kealahou_targets: the program's targets as fetched from the Kealahou API. + target_list: the Target Grouping associated with the program (from + ``KealahouProgramAssociation``; callers resolve it -- the association is a + prerequisite for syncing). + instrument: the program's instrument; selects which required-field values + (magnitude band, Teff, RV) are extracted for display in the sync tables. + + Membership in the associated Target Grouping is the TOM-side participation flag, so + the buckets are a pure function of (in the Target Grouping?) x (live in Kealahou?): + - kealahou_only: live Kealahou targets whose TOM counterpart is not a member of the + Target Grouping (never downloaded, or removed from the Target Grouping to withdraw + it from TOM-side syncing). Downloading (re-)adds the membership. + - tom_only: Target Grouping members with no *live* Kealahou counterpart (never + uploaded, or their Kealahou target has vanished -- staging data resets can do that); + - linked_in_sync / linked_discrepant: a Target Grouping member linked to a live + Kealahou target, split by field comparison. + """ + kealahou_by_token = {target_data.token: target_data for target_data in kealahou_targets} + links = (KealahouTargetLink.objects.filter(program_token=program_token) + .select_related('target')) + links_by_token = {link.kealahou_target_token: link for link in links} + member_target_ids = set(target_list.targets.values_list('id', flat=True)) + + state = SyncState() + + linked_target_ids = set() + for kealahou_target_token, target_data in kealahou_by_token.items(): + link = links_by_token.get(kealahou_target_token) + if link is not None and link.target_id in member_target_ids: + linked_target_ids.add(link.target_id) + pair = LinkedTargetPair(link=link, target=link.target, target_data=target_data, + discrepancies=compare_target_fields(link.target, target_data), + required_field_values=required_field_values(target_data, instrument)) + if pair.discrepancies: + state.linked_discrepant.append(pair) + else: + state.linked_in_sync.append(pair) + else: + # not participating on the TOM side: the previously-linked target (if any) + # takes precedence over a name match for the "already in TOM" display + existing_target = link.target if link is not None else Target.objects.filter( + name=target_data.name).first() + state.kealahou_only.append(ImportCandidate( + target_data=target_data, existing_target=existing_target, + required_field_values=required_field_values(target_data, instrument))) + + state.tom_only = list(target_list.targets.exclude(id__in=linked_target_ids)) + + return state + + +def import_targets(program_token: str, selected_kealahou_target_tokens: list[str], + kealahou_targets: list[TargetData], target_list: TargetList) -> list[ResultMessage]: + """Import the selected Kealahou targets into the TOM. + + Creates a TOM Target per selection (or links an existing same-name target), adds it + to the program's associated Target Grouping, and records the ``KealahouTargetLink``. + + Returns: + Per-target ``ResultMessage``s for display in the sync panel. + """ + kealahou_by_token = {target_data.token: target_data for target_data in kealahou_targets} + messages = [] + with transaction.atomic(): + for kealahou_target_token in selected_kealahou_target_tokens: + target_data = kealahou_by_token.get(kealahou_target_token) + if target_data is None: + messages.append(ResultMessage( + f'{kealahou_target_token}: no longer present in Kealahou; skipped.', success=False)) + continue + # a previously-linked target takes precedence (survives TOM-side renames); + # then a same-name target; only then create a new one + existing_link = KealahouTargetLink.objects.filter( + kealahou_target_token=kealahou_target_token).select_related('target').first() + existing_target = (existing_link.target if existing_link is not None + else Target.objects.filter(name=target_data.name).first()) + if existing_target is not None: + target = existing_target + messages.append(ResultMessage( + f'{target_data.name}: added existing TOM target to {target_list.name}.')) + else: + try: + target = target_from_target_data(target_data) + except ValueError as e: + messages.append(ResultMessage(str(e), success=False)) + continue + target.save() + messages.append(ResultMessage(f'{target_data.name}: created TOM target.')) + target_list.targets.add(target) + KealahouTargetLink.objects.update_or_create( + kealahou_target_token=kealahou_target_token, + defaults={'target': target, 'program_token': program_token, 'version': target_data.version}, + ) + return messages + + +def upload_targets(aeon_facility: AeonCFHTFacility, program_token: str, instrument: Instrument, + upload_requests: list[UploadRequest], target_list: TargetList) -> list[UploadResult]: + """Upload TOM targets to Kealahou, one PUT per target. + + Rows fail independently: a validation or API error on one target is reported in its + ``UploadResult`` and does not stop the rest. Successful uploads record/update the + ``KealahouTargetLink`` (with Kealahou's returned lock version) and ensure membership + in the program's associated Target Grouping. + """ + results = [] + for upload_request in upload_requests: + target = upload_request.target + existing_link = KealahouTargetLink.objects.filter(target=target, program_token=program_token).first() + try: + target_data = target_data_from_target( + target, program_token, instrument, + magnitude=upload_request.magnitude, + temperature_effective=upload_request.temperature_effective, + radial_velocity_kmps=upload_request.radial_velocity_kmps, + kealahou_target_token=existing_link.kealahou_target_token if existing_link else None, + version=existing_link.version if existing_link else None, + ) + returned = aeon_facility.create_or_update_target(program_token, target_data, instrument) + except ValueError as e: + results.append(UploadResult(target=target, success=False, message=str(e))) + continue + except pydantic.ValidationError as e: + results.append(UploadResult(target=target, success=False, message=f'Invalid target data: {e}')) + continue + except httpx.HTTPStatusError as e: + # Kealahou explains rejections (e.g. out-of-range values) in the response body + api_messages = kealahou_api_error_messages(e) + reason = '; '.join(api_messages) if api_messages else str(e) + logger.warning(f'Kealahou rejected target {target.name!r} for program {program_token}: {reason}') + results.append(UploadResult(target=target, success=False, + message=f'Kealahou rejected the upload: {reason}')) + continue + except httpx.HTTPError as e: # network-level failure (timeout, DNS, connection) + logger.warning(f'Kealahou upload failed for target {target.name!r} in program {program_token}: {e}') + results.append(UploadResult(target=target, success=False, message=f'Kealahou API error: {e}')) + continue + + with transaction.atomic(): + target_list.targets.add(target) + KealahouTargetLink.objects.update_or_create( + kealahou_target_token=returned.token, + defaults={'target': target, 'program_token': program_token, 'version': returned.version}, + ) + results.append(UploadResult(target=target, success=True, + message=f'Uploaded to Kealahou (version {returned.version}).')) + return results diff --git a/tom_cfht/migrations/0001_initial.py b/tom_cfht/migrations/0001_initial.py new file mode 100644 index 0000000..38b6858 --- /dev/null +++ b/tom_cfht/migrations/0001_initial.py @@ -0,0 +1,49 @@ +# Generated by Django 5.2.16 on 2026-07-28 22:43 + +import django.db.models.deletion +import tom_common.encryption +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('tom_targets', '0030_alter_basetarget_slope'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CFHTProfile', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('cfht_access_token', tom_common.encryption.EncryptedModelField(blank=True, editable=True, null=True)), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='KealahouProgramAssociation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('program_token', models.CharField(help_text="Kealahou's unique identifier for the program (runid-shaped, e.g. '25BE25').", max_length=32, unique=True)), + ('target_list', models.ForeignKey(help_text='The Target Grouping whose membership mirrors this program in the TOM.', on_delete=django.db.models.deletion.CASCADE, related_name='kealahou_program_associations', to='tom_targets.targetlist')), + ], + ), + migrations.CreateModel( + name='KealahouTargetLink', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('program_token', models.CharField(help_text="Kealahou's unique identifier for the program (runid-shaped, e.g. '25BE25').", max_length=32)), + ('kealahou_target_token', models.CharField(help_text="Kealahou's unique identifier for the target within its program, e.g. '25BE25-1758314224958'. Client-generated at upload.", max_length=64, unique=True)), + ('version', models.IntegerField(blank=True, help_text="Kealahou's optimistic-lock version, as returned by the API after the last sync. Sent back as lock_version on updates to prevent overwriting concurrent edits.", null=True)), + ('synced_at', models.DateTimeField(auto_now=True, help_text='When this link was last created or refreshed by a sync operation.')), + ('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='kealahou_links', to='tom_targets.basetarget')), + ], + options={ + 'constraints': [models.UniqueConstraint(fields=('target', 'program_token'), name='unique_target_per_program')], + }, + ), + ] diff --git a/tom_cfht/models.py b/tom_cfht/models.py index 71a8362..83a2972 100644 --- a/tom_cfht/models.py +++ b/tom_cfht/models.py @@ -1,3 +1,74 @@ +import logging + +from django.contrib.auth.models import User from django.db import models -# Create your models here. +from tom_common.encryption import EncryptedModelField +from tom_targets.base_models import BaseTarget +from tom_targets.models import TargetList + + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + + +class CFHTProfile(models.Model): + """User Profile for the TOMToolkit CFHT Facility + """ + # connect this Profile to it's User + user = models.OneToOneField(User, on_delete=models.CASCADE) + + cfht_access_token = EncryptedModelField(null=True, blank=True) + + +class KealahouProgramAssociation(models.Model): + """Associates a CFHT observing program with the TOM Target Grouping (``TargetList``) + that mirrors it. + + The association is user-controlled: until one exists for a program, target-sync + features for that program present a select-or-create Target Grouping form (the + "gate"). CASCADE deletion means removing the Target Grouping dissolves the + association and the gate simply reappears. One Target Grouping may serve several + programs; each program has exactly one Target Grouping. + """ + program_token = models.CharField( + max_length=32, unique=True, + help_text="Kealahou's unique identifier for the program (runid-shaped, e.g. '25BE25').") + target_list = models.ForeignKey( + TargetList, on_delete=models.CASCADE, related_name='kealahou_program_associations', + help_text='The Target Grouping whose membership mirrors this program in the TOM.') + + def __str__(self) -> str: + return f'{self.program_token} <-> {self.target_list.name}' + + +class KealahouTargetLink(models.Model): + """Links a TOM ``Target`` to its Kealahou counterpart within one CFHT program. + + Kealahou identifies entities by a field it calls ``token`` (an immutable unique + identifier -- unrelated to the API *access* token). A TOM target may be shared with + several CFHT programs, and each program keeps its own copy with its own + ``kealahou_target_token``, so the link is per-(target, program). + """ + target = models.ForeignKey(BaseTarget, on_delete=models.CASCADE, related_name='kealahou_links') + program_token = models.CharField( + max_length=32, + help_text="Kealahou's unique identifier for the program (runid-shaped, e.g. '25BE25').") + kealahou_target_token = models.CharField( + max_length=64, unique=True, + help_text="Kealahou's unique identifier for the target within its program, " + "e.g. '25BE25-1758314224958'. Client-generated at upload.") + version = models.IntegerField( + null=True, blank=True, + help_text="Kealahou's optimistic-lock version, as returned by the API after the last sync. " + 'Sent back as lock_version on updates to prevent overwriting concurrent edits.') + synced_at = models.DateTimeField( + auto_now=True, help_text='When this link was last created or refreshed by a sync operation.') + + class Meta: + constraints = [ + models.UniqueConstraint(fields=['target', 'program_token'], name='unique_target_per_program'), + ] + + def __str__(self) -> str: + return f'{self.target} <-> {self.kealahou_target_token}' diff --git a/tom_cfht/templates/tom_cfht/facility_index.html b/tom_cfht/templates/tom_cfht/facility_index.html new file mode 100644 index 0000000..e3fbf9f --- /dev/null +++ b/tom_cfht/templates/tom_cfht/facility_index.html @@ -0,0 +1,36 @@ +{% extends 'tom_common/base.html' %} +{% block title %}CFHT{% endblock %} +{% block content %} +

Canada-France-Hawaii Telescope

+ +
+
Facility
+
+

3.58 m optical/infrared telescope.

+
    + {% for site_name, site in observing_sites.items %} +
  • {{ site_name }} — + lat {{ site.latitude }}°, lon {{ site.longitude }}°, {{ site.elevation }} m
  • + {% endfor %} +
+

Instruments: {{ instruments|join:", " }}

+ CFHT · + Kealahou (staging) · + Kealahou API workshop +
+
+ +{# one tab per observing program; tab panes lazy-load their sections #} +
+
Observing Programs
+
+
+ + Loading programs from Kealahou… +
+
+
+{% endblock %} diff --git a/tom_cfht/templates/tom_cfht/observation_form.html b/tom_cfht/templates/tom_cfht/observation_form.html new file mode 100644 index 0000000..44a02d9 --- /dev/null +++ b/tom_cfht/templates/tom_cfht/observation_form.html @@ -0,0 +1,13 @@ +{% extends 'tom_observations/observation_form.html' %} +{% block content %} +{# Kealahou state for this target, at the top of the page. Loads asynchronously so a #} +{# slow/unavailable Kealahou API never blocks the observation form itself. #} +
+
+ + Checking Kealahou status… +
+
+{{ block.super }} +{% endblock %} diff --git a/tom_cfht/templates/tom_cfht/partials/kealahou_target_status.html b/tom_cfht/templates/tom_cfht/partials/kealahou_target_status.html new file mode 100644 index 0000000..94be43c --- /dev/null +++ b/tom_cfht/templates/tom_cfht/partials/kealahou_target_status.html @@ -0,0 +1,79 @@ +{# Observation-form fragment: this target's Kealahou state per CFHT program. #} +{# Each program row shows exactly the next action in the chain: associate a Target #} +{# Grouping -> upload -> (rarely) re-add to the Target Grouping -> done. #} +
+
Kealahou status
+
+ {% if error %} +
+ {{ error }}
+ Check the API access token on your user profile. +
+ {% else %} + {% if error_messages %} +
+
    {% for message in error_messages %}
  • {{ message }}
  • {% endfor %}
+
+ {% endif %} + {% if info_messages %} +
+
    {% for message in info_messages %}
  • {{ message }}
  • {% endfor %}
+
+ {% endif %} + {% for status in program_statuses %} +
+ {{ status.program_token }} ({{ status.instrument }}) — {{ status.title }}: + {% if status.target_grouping_gate %} + {% include 'tom_cfht/partials/target_grouping_gate.html' with gate=status.target_grouping_gate %} + {% elif not status.link %} + not in Kealahou +
+ {% csrf_token %} + + +
+ +
+ {% if status.needs_temperature %} +
+ +
+ {% endif %} + {% if status.needs_radial_velocity %} +
+ +
+ {% endif %} +
+ +
+
+ {% elif not status.in_target_list %} + in Kealahou +
+ {% csrf_token %} + + + +
+ {% else %} + in Kealahou + {% endif %} +
+ {% empty %} +

No Kealahou observing programs found for your account.

+ {% endfor %} + {% endif %} +
+
diff --git a/tom_cfht/templates/tom_cfht/partials/observing_programs.html b/tom_cfht/templates/tom_cfht/partials/observing_programs.html new file mode 100644 index 0000000..e535f9b --- /dev/null +++ b/tom_cfht/templates/tom_cfht/partials/observing_programs.html @@ -0,0 +1,42 @@ +{% if error %} +
+ Could not reach Kealahou: {{ error }}
+ Check the API access token on your user profile + (generate one on the Kealahou Manage Tokens page — the web-UI password will not work). +
+{% elif not program_rows %} +

No observing programs found for your Kealahou account.

+{% else %} + {# one lazy-loaded tab per observing program #} + +
+ {% for program in program_rows %} +
+

+ {{ program.title }} — PI {{ program.pi_name }} — {{ program.program_type }} + {% if program.percent_complete is not None %}— {{ program.percent_complete }}% complete{% endif %} +

+
+
+ + Loading… +
+
+
+ {% endfor %} +
+{% endif %} diff --git a/tom_cfht/templates/tom_cfht/partials/profile_cfht.html b/tom_cfht/templates/tom_cfht/partials/profile_cfht.html new file mode 100644 index 0000000..b336fd9 --- /dev/null +++ b/tom_cfht/templates/tom_cfht/partials/profile_cfht.html @@ -0,0 +1,36 @@ +{% load tom_common_extras %} + +
+
+
+

CFHT Facility Info

+
+ +
+
+
+
+

This the place to store user-specific information for your app.

+
+
+ {% for key, value in cfht_profile_data.items %} +
{% verbose_name user key %}
+
{{ value|default:"(not set)" }}
+ {% empty %} + {% if not cfht_profile %} +

No CFHTFacility Profile yet for this user.

+ {% endif %} + {% endfor %} + {# cfht_access_token is rendered with the revealable-password partial. The plaintext is #} + {# passed in via the cfht_profile_data templatetag using direct attribute access. #} +
CFHT Access Token
+
+ {% if cfht_access_token %} + {% include 'tom_common/partials/revealable_password_input.html' with value=cfht_access_token %} + {% else %} + (not set) + {% endif %} +
+
+
+
diff --git a/tom_cfht/templates/tom_cfht/partials/program_panel.html b/tom_cfht/templates/tom_cfht/partials/program_panel.html new file mode 100644 index 0000000..73564c2 --- /dev/null +++ b/tom_cfht/templates/tom_cfht/partials/program_panel.html @@ -0,0 +1,25 @@ +{# One program's tab pane: four full-width sections. Renders instantly; the Targets #} +{# section fetches its own Kealahou data, and the other three await AEONlib support. #} +
+
Targets
+
+
+ + Loading targets from Kealahou… +
+
+
+
+
Observing Templates
+
Coming soon — awaiting AEONlib support.
+
+
+
Observing Groups
+
Coming soon — awaiting AEONlib support.
+
+
+
Exposures
+
Coming soon — awaiting AEONlib support.
+
diff --git a/tom_cfht/templates/tom_cfht/partials/target_grouping_gate.html b/tom_cfht/templates/tom_cfht/partials/target_grouping_gate.html new file mode 100644 index 0000000..f30202a --- /dev/null +++ b/tom_cfht/templates/tom_cfht/partials/target_grouping_gate.html @@ -0,0 +1,40 @@ +{# The prerequisite gate: a program needs an associated Target Grouping before target #} +{# syncing can proceed. Context: `gate` (see views._target_grouping_gate_context). #} +
+

+ Program {{ gate.program_token }} is not yet associated with a Target Grouping. + Membership in the Target Grouping is what marks a target for syncing with this program: + add a target to mark it for upload, downloads from Kealahou land in it, and removing a + member withdraws that target from TOM-side syncing. +

+
+ {% csrf_token %} + + {% if gate.target %}{% endif %} +
+ +
+
+ +
+
+ +
+
+
diff --git a/tom_cfht/templates/tom_cfht/partials/targets_section.html b/tom_cfht/templates/tom_cfht/partials/targets_section.html new file mode 100644 index 0000000..2221828 --- /dev/null +++ b/tom_cfht/templates/tom_cfht/partials/targets_section.html @@ -0,0 +1,245 @@ +{# The Targets section of a program panel: the Target Grouping gate until the user #} +{# associates one, then the four sync buckets. Re-rendered whole by every sync action. #} +
+ {% if error %} +
+ Could not load Kealahou targets: {{ error }}
+ Check the API access token on your user profile. +
+ {% elif target_grouping_gate %} + {% include 'tom_cfht/partials/target_grouping_gate.html' with gate=target_grouping_gate %} + {% else %} + {% if error_messages %} +
+
    {% for message in error_messages %}
  • {{ message }}
  • {% endfor %}
+
+ {% endif %} + {% if info_messages %} +
+
    {% for message in info_messages %}
  • {{ message }}
  • {% endfor %}
+
+ {% endif %} + +

+ Target Grouping: {{ target_list.name }} — membership marks a target + for syncing with this program; removing a member withdraws it from TOM-side syncing. + Sidereal targets only, for now. +

+ + {# ---- in Kealahou but not the TOM: check to download ---- #} +
+ {% csrf_token %} +
+ {# header in the Kealahou web site's distinctive green #} +
+ In Kealahou only {{ sync_state.kealahou_only|length }} + check rows to download them into the TOM +
+
+ {% if sync_state.kealahou_only %} + + + + + + + + + {% if needs_temperature %}{% endif %} + {% if needs_radial_velocity %}{% endif %} + {# the only width-unspecified column: absorbs ALL surplus width under table-layout fixed, #} + {# so every other column (incl. the 3rem checkbox) renders at exactly its declared width #} + + + + + {% for candidate in sync_state.kealahou_only %} + + + + + + + {% if needs_temperature %} + + {% endif %} + {% if needs_radial_velocity %} + + {% endif %} + + + {% endfor %} + +
NameRADec{{ magnitude_band }} magTeff (K)RV (km/s)
{{ candidate.target_data.name }}{% if candidate.target_data.fixed_target %}{{ candidate.target_data.fixed_target.coordinate.ra|floatformat:5 }}{% endif %}{% if candidate.target_data.fixed_target %}{{ candidate.target_data.fixed_target.coordinate.dec|floatformat:5 }}{% endif %}{{ candidate.required_field_values.magnitude|default_if_none:'—'|floatformat:2 }}{{ candidate.required_field_values.temperature_effective|default_if_none:'—'|floatformat:1 }}{{ candidate.required_field_values.radial_velocity_kmps|default_if_none:'—'|floatformat:1 }}{% if candidate.existing_target %} + {# badge matches the dark end of the TOM Toolkit gradient #} + already in TOM + {% endif %}
+ {% else %} +

None.

+ {% endif %} +
+ {% if sync_state.kealahou_only %} + + {% endif %} +
+
+ + {# ---- in the TOM's Target Grouping but not Kealahou: check to upload ---- #} +
+ {% csrf_token %} +
+ {# header fades like the TOM Toolkit logo (its teal gradient) #} +
+ In {{ target_list.name }} only {{ sync_state.tom_only|length }} + check rows to upload them to Kealahou +
+
+ {% if sync_state.tom_only %} + + + + + + + + + {% if needs_temperature %} + + {% endif %} + {% if needs_radial_velocity %} + + {% endif %} + {# the only width-unspecified column: absorbs ALL surplus width under table-layout fixed, #} + {# so every other column (incl. the 3rem checkbox) renders at exactly its declared width #} + + + + + {% for target in sync_state.tom_only %} + + + + + + + {% if needs_temperature %} + + {% endif %} + {% if needs_radial_velocity %} + + {% endif %} + + + {% endfor %} + +
NameRADec{{ magnitude_band }} mag (required by {{ instrument }})Teff (K) (required by {{ instrument }})RV (km/s) (required by {{ instrument }})
{{ target.name }}{{ target.ra|floatformat:5 }}{{ target.dec|floatformat:5 }}
+ {% else %} +

None.

+ {% endif %} +
+ {% if sync_state.tom_only %} + + {% endif %} +
+
+ + {# ---- linked and in agreement ---- #} +
+
+ In both, in sync {{ sync_state.linked_in_sync|length }} +
+
+ {% if sync_state.linked_in_sync %} + + + + {# empty leading column keeps Name/RA/Dec aligned with the actionable tables #} + + + + + + {% if needs_temperature %}{% endif %} + {% if needs_radial_velocity %}{% endif %} + {# the only width-unspecified column: absorbs ALL surplus width under table-layout fixed, #} + {# so every other column (incl. the 3rem checkbox) renders at exactly its declared width #} + + + + + {% for pair in sync_state.linked_in_sync %} + + + + + + + {% if needs_temperature %} + + {% endif %} + {% if needs_radial_velocity %} + + {% endif %} + + + {% endfor %} + +
NameRADec{{ magnitude_band }} magTeff (K)RV (km/s)
+ {{ pair.target.name }}{{ pair.target.ra|floatformat:5 }}{{ pair.target.dec|floatformat:5 }}{{ pair.required_field_values.magnitude|default_if_none:'—'|floatformat:2 }}{{ pair.required_field_values.temperature_effective|default_if_none:'—'|floatformat:1 }}{{ pair.required_field_values.radial_velocity_kmps|default_if_none:'—'|floatformat:1 }}
+ {% else %} +

None.

+ {% endif %} +
+
+ + {# ---- linked with property discrepancies (displayed, never auto-resolved) ---- #} +
+
+ In both, with discrepancies {{ sync_state.linked_discrepant|length }} +
+
+ {% if sync_state.linked_discrepant %} + + + + {% for pair in sync_state.linked_discrepant %} + {% for discrepancy in pair.discrepancies %} + + {% if forloop.first %} + + {% endif %} + + + + + {% endfor %} + {% endfor %} + +
NamePropertyTOM valueKealahou value
+ {{ pair.target.name }}{{ discrepancy.field_name }}{{ discrepancy.tom_value }}{{ discrepancy.kealahou_value }}
+

Shown for review; syncing does not overwrite either side.

+ {% else %} +

None.

+ {% endif %} +
+
+ + {% endif %} +
diff --git a/tom_cfht/templates/tom_cfht/update_profile.html b/tom_cfht/templates/tom_cfht/update_profile.html new file mode 100644 index 0000000..7325a9d --- /dev/null +++ b/tom_cfht/templates/tom_cfht/update_profile.html @@ -0,0 +1,12 @@ +{% extends 'tom_common/base.html' %} +{% load django_bootstrap5 %} +{% block title %}Update CFHT Profile{% endblock %} +{% block content %} + +

This is an update form for the CFHT Profile

+
+
{% csrf_token %} + {{ form.as_p }} + +
+{% endblock %} \ No newline at end of file diff --git a/tom_cfht/templatetags/cfht_extras.py b/tom_cfht/templatetags/cfht_extras.py new file mode 100644 index 0000000..dbfb64e --- /dev/null +++ b/tom_cfht/templatetags/cfht_extras.py @@ -0,0 +1,33 @@ +from django import template +from django.forms.models import model_to_dict + +from tom_cfht.models import CFHTProfile + +register = template.Library() + + +@register.inclusion_tag('tom_cfht/partials/profile_cfht.html') +def cfht_profile_data(user): + """ + Returns the app specific user information as a dictionary to be used in the context of the above partial. + """ + + # cfht_access_token is rendered separately via tom_common's revealable_password_input + # partial, so exclude it from the auto-iteration loop. model_to_dict goes + # through EncryptedModelField.value_from_object, which returns the REDACTED + # placeholder string for security; the partial needs the actual plaintext, + # which only direct attribute access provides. + exclude_fields = ['user', 'id', 'cfht_access_token'] + try: + cfht_profile_dict = model_to_dict(user.cfhtprofile, exclude=exclude_fields) + profile_data = { + 'user': user, + 'cfht_profile': user.cfhtprofile, + 'cfht_profile_data': cfht_profile_dict, + 'cfht_access_token': user.cfhtprofile.cfht_access_token, # direct access → plaintext + } + return profile_data + except CFHTProfile.DoesNotExist: + CFHTProfile.objects.create(user=user) + profile_data = {'user': user} + return profile_data diff --git a/tom_cfht/tests/tests.py b/tom_cfht/tests/tests.py index 9e12164..5fff103 100644 --- a/tom_cfht/tests/tests.py +++ b/tom_cfht/tests/tests.py @@ -1,9 +1,565 @@ -from django.test import TestCase +"""Tests for the tom_cfht Kealahou target-sync feature. +NOTE: to run these tests in your venv: python ./tom_cfht/tests/run_tests.py +The aeonlib Kealahou facade is mocked with unittest.mock throughout: aeonlib uses httpx, +which the `responses` library cannot intercept, and mocking at the facade keeps the tests +offline and fast. +""" +from unittest import mock -class TestApp(TestCase): - """NOTE: to run these tests in your venv: python ./{{tom_app}}/tests/run_tests.py""" +import httpx +from django.contrib.auth.models import User +from django.core.exceptions import ImproperlyConfigured +from django.test import TestCase, override_settings +from django.urls import reverse - def test_unittest(self): - """Ensure the testing infrastructure is working.""" - self.assertTrue(True) +from aeonlib.cfht.models import ( + AllocationData, + FixedTargetProperMotion, + Instrument, + ProgramData, + ProgramInfo, + ProgramInfoPiInfo, + SkyCoordinate, + TargetData, + TargetDataFixedTarget, +) + +from tom_observations.facility import CredentialStatus +from tom_targets.models import Target, TargetList + +from tom_cfht import kealahou +from tom_cfht.cfht import CFHTFacility +from tom_cfht.models import CFHTProfile, KealahouProgramAssociation, KealahouTargetLink +from tom_cfht.tests.factories import NonSiderealTargetFactory, SiderealTargetFactory + +PROGRAM_TOKEN = '25BE25' +CFHT_FACILITIES_SETTING = {'CFHT': {'CFHT_ACCESS_TOKEN': 'tom-wide-access-token'}} + + +def make_target_data(kealahou_target_token: str, name: str, ra: float = 150.0, dec: float = 30.0, + pm_ra: float = None, pm_dec: float = None, version: int = 1) -> TargetData: + """Build a Kealahou fixed-target TargetData for test fixtures.""" + proper_motion = None + if pm_ra is not None or pm_dec is not None: + proper_motion = FixedTargetProperMotion(ra_mas=pm_ra, dec_mas=pm_dec) + return TargetData( + token=kealahou_target_token, name=name, version=version, + fixed_target=TargetDataFixedTarget(coordinate=SkyCoordinate(ra=ra, dec=dec), + proper_motion=proper_motion)) + + +def make_program(program_token: str = PROGRAM_TOKEN, instrument: Instrument = Instrument.megacam) -> ProgramInfo: + """Build a Kealahou ProgramInfo for test fixtures.""" + return ProgramInfo( + program_data=ProgramData(token=program_token, title='Test Program', + time_allocation=[AllocationData(instrument=instrument)]), + pi_info=ProgramInfoPiInfo(first_name='AEON', last_name='Test')) + + +def make_association(program_token: str = PROGRAM_TOKEN, + target_list_name: str = 'CFHT-MEGACAM-25BE25') -> KealahouProgramAssociation: + """Create the program's Target Grouping and its association, for tests past the gate.""" + target_list, _ = TargetList.objects.get_or_create(name=target_list_name) + association, _ = KealahouProgramAssociation.objects.get_or_create( + program_token=program_token, defaults={'target_list': target_list}) + return association + + +class TestTargetDataMapping(TestCase): + """tom_cfht.kealahou: TOM Target <-> aeonlib TargetData mapping.""" + + def test_target_data_from_target_maps_fields(self): + target = SiderealTargetFactory.create(name='SN 2026abc', ra=150.0, dec=30.0, pm_ra=1.5, pm_dec=-2.0) + target_data = kealahou.target_data_from_target(target, PROGRAM_TOKEN, Instrument.megacam, magnitude=20.5) + self.assertEqual(target_data.name, 'SN 2026abc') + self.assertRegex(target_data.token, rf'^{PROGRAM_TOKEN}-\d{{10}}$') + self.assertEqual(target_data.fixed_target.coordinate.ra, 150.0) + self.assertEqual(target_data.fixed_target.coordinate.dec, 30.0) + self.assertEqual(target_data.fixed_target.proper_motion.ra_mas, 1.5) + self.assertEqual(target_data.fixed_target.proper_motion.dec_mas, -2.0) + self.assertEqual(target_data.magnitude.ab.value, 20.5) # MegaCam requires AB + self.assertIsNone(target_data.version) # a new upload carries no lock version + # Kealahou requires a pointing offset; we default to the system "no offset" entity + self.assertEqual(target_data.pointing_offset_token, '00AZ00-PO+MEGACAM+1') + + def test_existing_identifiers_are_reused_for_updates(self): + target = SiderealTargetFactory.create(name='ReUpload') + target_data = kealahou.target_data_from_target( + target, PROGRAM_TOKEN, Instrument.megacam, magnitude=20.0, + kealahou_target_token=f'{PROGRAM_TOKEN}-1234567890', version=4) + self.assertEqual(target_data.token, f'{PROGRAM_TOKEN}-1234567890') + self.assertEqual(target_data.version, 4) + + def test_megacam_requires_magnitude(self): + target = SiderealTargetFactory.create(name='NoMag') + with self.assertRaises(ValueError): + kealahou.target_data_from_target(target, PROGRAM_TOKEN, Instrument.megacam) + + def test_spirou_requires_temperature_and_radial_velocity(self): + target = SiderealTargetFactory.create(name='SpirouTarget') + with self.assertRaises(ValueError): # missing Teff + kealahou.target_data_from_target(target, PROGRAM_TOKEN, Instrument.spirou, + magnitude=9.5, radial_velocity_kmps=12.0) + with self.assertRaises(ValueError): # missing RV + kealahou.target_data_from_target(target, PROGRAM_TOKEN, Instrument.spirou, + magnitude=9.5, temperature_effective=4800.0) + target_data = kealahou.target_data_from_target( + target, PROGRAM_TOKEN, Instrument.spirou, + magnitude=9.5, temperature_effective=4800.0, radial_velocity_kmps=12.0) + self.assertEqual(target_data.magnitude.h.value, 9.5) # SPIRou requires H + self.assertEqual(target_data.temperature_effective, 4800.0) + self.assertEqual(target_data.fixed_target.estimated_radial_velocity_kmps.value, 12.0) + + def test_name_rules_enforced(self): + too_long = SiderealTargetFactory.create(name='X' * (kealahou.TARGET_NAME_MAX_LENGTH + 1)) + with self.assertRaises(ValueError): + kealahou.target_data_from_target(too_long, PROGRAM_TOKEN, Instrument.megacam, magnitude=20.0) + bad_characters = SiderealTargetFactory.create(name='naïve~name') + with self.assertRaises(ValueError): + kealahou.target_data_from_target(bad_characters, PROGRAM_TOKEN, Instrument.megacam, magnitude=20.0) + + def test_non_sidereal_target_rejected(self): + moving_target = NonSiderealTargetFactory.create(name='Comet') + with self.assertRaises(ValueError): + kealahou.target_data_from_target(moving_target, PROGRAM_TOKEN, Instrument.megacam, magnitude=20.0) + + def test_target_from_target_data(self): + target_data = make_target_data(f'{PROGRAM_TOKEN}-1111111111', 'Imported', ra=10.0, dec=-5.0, + pm_ra=3.0, pm_dec=4.0) + target = kealahou.target_from_target_data(target_data) + self.assertEqual(target.name, 'Imported') + self.assertEqual(target.type, Target.SIDEREAL) + self.assertEqual((target.ra, target.dec, target.pm_ra, target.pm_dec), (10.0, -5.0, 3.0, 4.0)) + + def test_moving_target_import_rejected(self): + moving_target_data = TargetData(token=f'{PROGRAM_TOKEN}-2222222222', name='Mover') + with self.assertRaises(ValueError): + kealahou.target_from_target_data(moving_target_data) + + def test_required_field_values_extracts_kealahou_side_values(self): + target_data = kealahou.target_data_from_target( + SiderealTargetFactory.create(name='SpirouValues'), '25BE30', Instrument.spirou, + magnitude=9.5, temperature_effective=4800.0, radial_velocity_kmps=12.0) + values = kealahou.required_field_values(target_data, Instrument.spirou) + self.assertEqual(values, {'magnitude': 9.5, 'temperature_effective': 4800.0, + 'radial_velocity_kmps': 12.0}) + # a Kealahou target with none of the fields set yields Nones (displayed as em-dashes) + bare_target_data = make_target_data(f'{PROGRAM_TOKEN}-3333333333', 'Bare') + values = kealahou.required_field_values(bare_target_data, Instrument.megacam) + self.assertEqual(values, {'magnitude': None, 'temperature_effective': None, + 'radial_velocity_kmps': None}) + + def test_default_target_list_name_includes_instrument(self): + self.assertEqual(kealahou.default_target_list_name(PROGRAM_TOKEN, Instrument.megacam), + 'CFHT-MEGACAM-25BE25') + self.assertEqual(kealahou.default_target_list_name(PROGRAM_TOKEN, None), 'CFHT-UNKNOWN-25BE25') + + +class TestCompareTargetFields(TestCase): + """tom_cfht.kealahou.compare_target_fields: the discrepancy display's field diff.""" + + def test_agreeing_targets_have_no_discrepancies(self): + target = SiderealTargetFactory.create(name='Same', ra=150.0, dec=30.0, pm_ra=1.0, pm_dec=2.0) + target_data = make_target_data('t', 'Same', ra=150.0, dec=30.0, pm_ra=1.0, pm_dec=2.0) + self.assertEqual(kealahou.compare_target_fields(target, target_data), []) + + def test_sexagesimal_round_trip_noise_ignored(self): + # the Kealahou web UI stores RA re-parsed from 0.1s-of-time sexagesimal, introducing + # up to 0.75 arcsec of representation noise (0.60 arcsec measured on staging for M107) + target = SiderealTargetFactory.create(name='M107ish', ra=248.13275, dec=-13.0537778, + pm_ra=None, pm_dec=None) + round_tripped_ra = 248.13291666666672 # = 16h32m31.9s, 0.60 arcsec away + target_data = make_target_data('t', 'M107ish', ra=round_tripped_ra, dec=-13.053777777777778) + self.assertEqual(kealahou.compare_target_fields(target, target_data), []) + + def test_coordinate_drift_beyond_tolerance_reported(self): + target = SiderealTargetFactory.create(name='Drifted', ra=150.0, dec=30.0, pm_ra=None, pm_dec=None) + two_arcsec = 2.0 / 3600.0 + target_data = make_target_data('t', 'Drifted', ra=150.0 + two_arcsec, dec=30.0) + discrepancies = kealahou.compare_target_fields(target, target_data) + self.assertEqual(len(discrepancies), 1) + self.assertEqual(discrepancies[0].field_name, 'ra') + self.assertEqual((discrepancies[0].tom_value, discrepancies[0].kealahou_value), + (150.0, 150.0 + two_arcsec)) + + def test_one_sided_proper_motion_reported(self): + target = SiderealTargetFactory.create(name='PM', ra=1.0, dec=1.0, pm_ra=5.0, pm_dec=None) + target_data = make_target_data('t', 'PM', ra=1.0, dec=1.0) # Kealahou side has no proper motion + field_names = [d.field_name for d in kealahou.compare_target_fields(target, target_data)] + self.assertEqual(field_names, ['pm_ra']) + + +class TestComputeSyncState(TestCase): + """tom_cfht.kealahou.compute_sync_state: the four-bucket partition.""" + + def setUp(self): + self.association = make_association() + self.target_list = self.association.target_list + self.linked_target = SiderealTargetFactory.create(name='Linked', ra=150.0, dec=30.0, + pm_ra=None, pm_dec=None) + self.linked_target_data = make_target_data(f'{PROGRAM_TOKEN}-1000000001', 'Linked', + ra=150.0, dec=30.0, version=2) + KealahouTargetLink.objects.create(target=self.linked_target, program_token=PROGRAM_TOKEN, + kealahou_target_token=self.linked_target_data.token, version=2) + self.target_list.targets.add(self.linked_target) + + def test_buckets_partition_correctly(self): + kealahou_only_data = make_target_data(f'{PROGRAM_TOKEN}-1000000002', 'KealahouOnly') + tom_only_target = SiderealTargetFactory.create(name='TOMOnly') + self.target_list.targets.add(tom_only_target) + + sync_state = kealahou.compute_sync_state( + PROGRAM_TOKEN, [self.linked_target_data, kealahou_only_data], self.target_list) + + self.assertEqual([c.target_data.name for c in sync_state.kealahou_only], ['KealahouOnly']) + self.assertEqual([t.name for t in sync_state.tom_only], ['TOMOnly']) + self.assertEqual([p.target.name for p in sync_state.linked_in_sync], ['Linked']) + self.assertEqual(sync_state.linked_discrepant, []) + + def test_discrepant_linked_target_bucketed_with_field_details(self): + drifted_data = make_target_data(self.linked_target_data.token, 'Linked', ra=151.0, dec=30.0, version=3) + sync_state = kealahou.compute_sync_state(PROGRAM_TOKEN, [drifted_data], self.target_list) + self.assertEqual(sync_state.linked_in_sync, []) + self.assertEqual(len(sync_state.linked_discrepant), 1) + self.assertEqual([d.field_name for d in sync_state.linked_discrepant[0].discrepancies], ['ra']) + + def test_name_collision_candidate_carries_existing_target(self): + SiderealTargetFactory.create(name='AlreadyHere') + colliding_data = make_target_data(f'{PROGRAM_TOKEN}-1000000003', 'AlreadyHere') + sync_state = kealahou.compute_sync_state( + PROGRAM_TOKEN, [self.linked_target_data, colliding_data], self.target_list) + self.assertEqual(sync_state.kealahou_only[0].existing_target.name, 'AlreadyHere') + + def test_stale_link_puts_target_back_in_tom_only(self): + # the linked target's Kealahou counterpart vanished (e.g. staging data reset) + sync_state = kealahou.compute_sync_state(PROGRAM_TOKEN, [], self.target_list) + self.assertEqual([t.name for t in sync_state.tom_only], ['Linked']) + + def test_removed_member_returns_to_kealahou_only(self): + # the M107 scenario: linked to the program, but not (or no longer) a member of the + # associated Target Grouping -> TOM-side participation withdrawn, so it shows as + # "In Kealahou only", with the previously-linked target offered for re-linking + self.target_list.targets.remove(self.linked_target) + sync_state = kealahou.compute_sync_state(PROGRAM_TOKEN, [self.linked_target_data], self.target_list) + self.assertEqual(sync_state.linked_in_sync, []) + self.assertEqual(sync_state.tom_only, []) + self.assertEqual([c.target_data.name for c in sync_state.kealahou_only], ['Linked']) + self.assertEqual(sync_state.kealahou_only[0].existing_target, self.linked_target) + + +class TestImportTargets(TestCase): + """tom_cfht.kealahou.import_targets.""" + + def setUp(self): + self.target_list = make_association().target_list + + def test_import_creates_target_and_link_in_associated_target_grouping(self): + target_data = make_target_data(f'{PROGRAM_TOKEN}-1000000004', 'NewImport', ra=12.0, dec=34.0, version=7) + messages = kealahou.import_targets(PROGRAM_TOKEN, [target_data.token], [target_data], self.target_list) + + target = Target.objects.get(name='NewImport') + self.assertEqual((target.ra, target.dec), (12.0, 34.0)) + self.assertIn(target, self.target_list.targets.all()) + link = KealahouTargetLink.objects.get(kealahou_target_token=target_data.token) + self.assertEqual((link.target, link.program_token, link.version), (target, PROGRAM_TOKEN, 7)) + self.assertTrue(any('created' in message.text for message in messages)) + + def test_import_links_existing_same_name_target(self): + existing_target = SiderealTargetFactory.create(name='AlreadyInTOM') + target_data = make_target_data(f'{PROGRAM_TOKEN}-1000000005', 'AlreadyInTOM') + messages = kealahou.import_targets(PROGRAM_TOKEN, [target_data.token], [target_data], self.target_list) + + self.assertEqual(Target.objects.filter(name='AlreadyInTOM').count(), 1) # no duplicate + link = KealahouTargetLink.objects.get(kealahou_target_token=target_data.token) + self.assertEqual(link.target, existing_target) + self.assertTrue(any('added existing' in message.text for message in messages)) + + def test_reimport_reuses_previously_linked_target_despite_rename(self): + # withdraw-then-redownload must find the linked target by its link, not its name, + # so a TOM-side rename doesn't produce a duplicate target on re-download + renamed_target = SiderealTargetFactory.create(name='NewName') + kealahou_target_token = f'{PROGRAM_TOKEN}-1000000006' + KealahouTargetLink.objects.create(target=renamed_target, program_token=PROGRAM_TOKEN, + kealahou_target_token=kealahou_target_token, version=1) + target_data = make_target_data(kealahou_target_token, 'OldName', version=2) + kealahou.import_targets(PROGRAM_TOKEN, [kealahou_target_token], [target_data], self.target_list) + + self.assertFalse(Target.objects.filter(name='OldName').exists()) # no duplicate created + self.assertIn(renamed_target, self.target_list.targets.all()) + link = KealahouTargetLink.objects.get(kealahou_target_token=kealahou_target_token) + self.assertEqual((link.target, link.version), (renamed_target, 2)) + + +class TestUploadTargets(TestCase): + """tom_cfht.kealahou.upload_targets, with the aeonlib facade mocked.""" + + def setUp(self): + self.target_list = make_association().target_list + self.aeon_facility = mock.Mock() + # echo back the submitted TargetData with a server-incremented version + self.aeon_facility.create_or_update_target.side_effect = ( + lambda program_token, target_data, instrument: target_data.model_copy( + update={'version': (target_data.version or 0) + 1})) + + def test_successful_upload_records_link_and_version(self): + target = SiderealTargetFactory.create(name='Uploadable', pm_ra=None, pm_dec=None) + results = kealahou.upload_targets( + self.aeon_facility, PROGRAM_TOKEN, Instrument.megacam, + [kealahou.UploadRequest(target=target, magnitude=21.0)], self.target_list) + + self.assertTrue(results[0].success) + self.aeon_facility.create_or_update_target.assert_called_once() + link = KealahouTargetLink.objects.get(target=target, program_token=PROGRAM_TOKEN) + self.assertEqual(link.version, 1) + self.assertIn(target, self.target_list.targets.all()) + + def test_row_failures_are_isolated(self): + good_target = SiderealTargetFactory.create(name='Good', pm_ra=None, pm_dec=None) + no_magnitude_target = SiderealTargetFactory.create(name='NoMag', pm_ra=None, pm_dec=None) + results = kealahou.upload_targets( + self.aeon_facility, PROGRAM_TOKEN, Instrument.megacam, + [kealahou.UploadRequest(target=no_magnitude_target), # fails validation, no API call + kealahou.UploadRequest(target=good_target, magnitude=20.0)], self.target_list) + + self.assertEqual([result.success for result in results], [False, True]) + self.assertEqual(self.aeon_facility.create_or_update_target.call_count, 1) + + def test_api_error_reported_per_row(self): + self.aeon_facility.create_or_update_target.side_effect = httpx.HTTPError('Kealahou is down') + target = SiderealTargetFactory.create(name='Unlucky', pm_ra=None, pm_dec=None) + results = kealahou.upload_targets( + self.aeon_facility, PROGRAM_TOKEN, Instrument.megacam, + [kealahou.UploadRequest(target=target, magnitude=20.0)], self.target_list) + self.assertFalse(results[0].success) + self.assertIn('Kealahou is down', results[0].message) + self.assertFalse(KealahouTargetLink.objects.filter(target=target).exists()) + + def test_kealahou_rejection_surfaces_api_error_messages(self): + # a 422 rejection carries {"error": {"messages": [...]}} explaining what was wrong; + # the row message must show that, not httpx's generic status line + rejection = httpx.HTTPStatusError( + "Client error '422 Unprocessable Entity'", + request=httpx.Request('PUT', 'https://api-stage.cfht.hawaii.edu/'), + response=httpx.Response( + 422, json={'error': {'messages': ['temperature_effective must be between 2500 and 10000.']}}, + request=httpx.Request('PUT', 'https://api-stage.cfht.hawaii.edu/'))) + self.aeon_facility.create_or_update_target.side_effect = rejection + target = SiderealTargetFactory.create(name='Rejected', pm_ra=None, pm_dec=None) + results = kealahou.upload_targets( + self.aeon_facility, PROGRAM_TOKEN, Instrument.megacam, + [kealahou.UploadRequest(target=target, magnitude=20.0)], self.target_list) + self.assertFalse(results[0].success) + self.assertIn('temperature_effective must be between 2500 and 10000.', results[0].message) + + +@override_settings(FACILITIES=CFHT_FACILITIES_SETTING) +class TestGetAccessToken(TestCase): + """CFHTFacility.get_access_token: profile token > settings default, with status tracking.""" + + def setUp(self): + self.user = User.objects.create_user(username='astronomer', password='pw') + self.facility = CFHTFacility() + self.facility.set_user(self.user) + + def test_profile_token_wins(self): + CFHTProfile.objects.create(user=self.user, cfht_access_token='profile-access-token') + self.assertEqual(self.facility.get_access_token(), 'profile-access-token') + self.assertEqual(self.facility.credential_status, CredentialStatus.USING_USER_CREDS) + + def test_settings_fallback_when_profile_empty(self): + CFHTProfile.objects.create(user=self.user, cfht_access_token='') + self.assertEqual(self.facility.get_access_token(), 'tom-wide-access-token') + self.assertEqual(self.facility.credential_status, CredentialStatus.USING_DEFAULTS) + + @override_settings(FACILITIES={'CFHT': {'CFHT_ACCESS_TOKEN': ''}}) + def test_no_token_anywhere_raises(self): + with self.assertRaises(ImproperlyConfigured): + self.facility.get_access_token() + self.assertEqual(self.facility.credential_status, CredentialStatus.PROFILE_EMPTY) + + +@override_settings(FACILITIES=CFHT_FACILITIES_SETTING) +class TestFacilityPageViews(TestCase): + """The /cfht/ page and its htmx partials, with the aeonlib boundary mocked.""" + + def setUp(self): + self.user = User.objects.create_user(username='astronomer', password='pw') + self.client.force_login(self.user) + + def test_index_requires_login(self): + self.client.logout() + response = self.client.get(reverse('tom_cfht:facility-index')) + self.assertEqual(response.status_code, 302) + + def test_index_renders_static_info(self): + response = self.client.get(reverse('tom_cfht:facility-index')) + self.assertContains(response, 'Canada-France-Hawaii Telescope') + self.assertContains(response, 'Observing Programs') + + @mock.patch('tom_cfht.views.CFHTFacility') + def test_observing_programs_renders_lazy_tabs(self, mock_facility_class): + mock_facility_class.return_value.get_observing_programs.return_value = [ + make_program(), make_program('25BE30', Instrument.spirou)] + response = self.client.get(reverse('tom_cfht:observing-programs')) + self.assertContains(response, '25BE25 · MEGACAM') + self.assertContains(response, '25BE30 · SPIROU') + # tabs lazy-load their panes: first on load, the rest on first click + self.assertContains(response, 'hx-trigger="load once"', count=1) + self.assertContains(response, 'hx-trigger="click once"', count=1) + + @mock.patch('tom_cfht.views.CFHTFacility') + def test_observing_programs_api_error_shows_alert(self, mock_facility_class): + mock_facility_class.return_value.get_observing_programs.side_effect = httpx.ConnectError('no route') + response = self.client.get(reverse('tom_cfht:observing-programs')) + self.assertEqual(response.status_code, 200) # htmx partial, never a 500 + self.assertContains(response, 'Could not reach Kealahou') + + def test_program_panel_has_four_sections(self): + response = self.client.get(reverse('tom_cfht:program-panel', args=[PROGRAM_TOKEN])) + for section_title in ['Targets', 'Observing Templates', 'Observing Groups', 'Exposures']: + self.assertContains(response, section_title) + + @mock.patch('tom_cfht.views.CFHTFacility') + def test_targets_section_shows_gate_until_associated(self, mock_facility_class): + aeon_facility = mock_facility_class.return_value.get_aeon_facility.return_value + aeon_facility.programs.return_value = [make_program()] + response = self.client.get(reverse('tom_cfht:targets-section', args=[PROGRAM_TOKEN])) + self.assertContains(response, 'not yet associated with a Target Grouping') + self.assertContains(response, 'CFHT-MEGACAM-25BE25') # suggested default name + aeon_facility.targets.assert_not_called() # gated: no target fetch before association + + @mock.patch('tom_cfht.views.CFHTFacility') + def test_associate_by_creating_new_target_grouping(self, mock_facility_class): + aeon_facility = mock_facility_class.return_value.get_aeon_facility.return_value + aeon_facility.programs.return_value = [make_program()] + aeon_facility.targets.return_value = [] + response = self.client.post( + reverse('tom_cfht:associate-target-grouping', args=[PROGRAM_TOKEN]), + {'new_target_list_name': 'CFHT-MEGACAM-25BE25', 'origin': 'targets-section'}) + self.assertEqual(response.status_code, 200) + association = KealahouProgramAssociation.objects.get(program_token=PROGRAM_TOKEN) + self.assertEqual(association.target_list.name, 'CFHT-MEGACAM-25BE25') + self.assertContains(response, 'Target Grouping:') # gate replaced by the sync panel + + @mock.patch('tom_cfht.views.CFHTFacility') + def test_associate_with_existing_target_grouping(self, mock_facility_class): + existing_target_list = TargetList.objects.create(name='My Grouping') + aeon_facility = mock_facility_class.return_value.get_aeon_facility.return_value + aeon_facility.programs.return_value = [make_program()] + aeon_facility.targets.return_value = [] + self.client.post(reverse('tom_cfht:associate-target-grouping', args=[PROGRAM_TOKEN]), + {'target_list_id': existing_target_list.id, 'origin': 'targets-section'}) + association = KealahouProgramAssociation.objects.get(program_token=PROGRAM_TOKEN) + self.assertEqual(association.target_list, existing_target_list) + + @mock.patch('tom_cfht.views.CFHTFacility') + def test_targets_section_renders_four_buckets_when_associated(self, mock_facility_class): + make_association() + aeon_facility = mock_facility_class.return_value.get_aeon_facility.return_value + aeon_facility.programs.return_value = [make_program()] + aeon_facility.targets.return_value = [make_target_data(f'{PROGRAM_TOKEN}-1000000006', 'KealahouOnly')] + response = self.client.get(reverse('tom_cfht:targets-section', args=[PROGRAM_TOKEN])) + self.assertContains(response, 'In Kealahou only') + self.assertContains(response, 'In CFHT-MEGACAM-25BE25 only') # named for the associated Target Grouping + self.assertContains(response, 'in sync') + self.assertContains(response, 'with discrepancies') + self.assertContains(response, 'KealahouOnly') + # per-section action buttons render only when their bucket has rows + self.assertContains(response, 'Download selected targets') # one kealahou-only row above + self.assertNotContains(response, 'Upload selected targets') # tom_only is empty + + @mock.patch('tom_cfht.views.CFHTFacility') + def test_sync_selected_acts_on_exactly_the_checked_rows(self, mock_facility_class): + association = make_association() + checked_token = f'{PROGRAM_TOKEN}-1000000007' + unchecked_token = f'{PROGRAM_TOKEN}-1000000008' + checked_target = SiderealTargetFactory.create(name='CheckedUpload', pm_ra=None, pm_dec=None) + unchecked_target = SiderealTargetFactory.create(name='UncheckedUpload', pm_ra=None, pm_dec=None) + association.target_list.targets.add(checked_target, unchecked_target) + + aeon_facility = mock_facility_class.return_value.get_aeon_facility.return_value + aeon_facility.programs.return_value = [make_program()] + aeon_facility.targets.return_value = [make_target_data(checked_token, 'CheckedImport'), + make_target_data(unchecked_token, 'UncheckedImport')] + aeon_facility.create_or_update_target.side_effect = ( + lambda program_token, target_data, instrument: target_data.model_copy(update={'version': 1})) + + response = self.client.post( + reverse('tom_cfht:sync-selected-targets', args=[PROGRAM_TOKEN]), + {'kealahou_target_token': [checked_token], + 'target_id': [str(checked_target.id)], + f'magnitude_{checked_target.id}': '20.5'}) + + self.assertEqual(response.status_code, 200) + # import: only the checked Kealahou row + self.assertTrue(Target.objects.filter(name='CheckedImport').exists()) + self.assertFalse(Target.objects.filter(name='UncheckedImport').exists()) + # upload: only the checked TOM row + self.assertEqual(aeon_facility.create_or_update_target.call_count, 1) + uploaded_target_data = aeon_facility.create_or_update_target.call_args.args[1] + self.assertEqual(uploaded_target_data.name, 'CheckedUpload') + + @mock.patch('tom_cfht.views.CFHTFacility') + def test_upload_failure_renders_danger_alert(self, mock_facility_class): + association = make_association() + target = SiderealTargetFactory.create(name='WillFail', pm_ra=None, pm_dec=None) + association.target_list.targets.add(target) + aeon_facility = mock_facility_class.return_value.get_aeon_facility.return_value + aeon_facility.programs.return_value = [make_program()] + aeon_facility.targets.return_value = [] + aeon_facility.create_or_update_target.side_effect = httpx.HTTPStatusError( + "Client error '422 Unprocessable Entity'", + request=httpx.Request('PUT', 'https://api-stage.cfht.hawaii.edu/'), + response=httpx.Response(422, json={'error': {'messages': ['magnitude out of range.']}}, + request=httpx.Request('PUT', 'https://api-stage.cfht.hawaii.edu/'))) + response = self.client.post( + reverse('tom_cfht:sync-selected-targets', args=[PROGRAM_TOKEN]), + {'target_id': [str(target.id)], f'magnitude_{target.id}': '99.0'}) + # failures go in a bootstrap danger alert (not info) and carry Kealahou's own message + self.assertContains(response, 'alert-danger') + self.assertContains(response, 'magnitude out of range.') + + @mock.patch('tom_cfht.views.CFHTFacility') + def test_kealahou_target_status_walks_the_state_chain(self, mock_facility_class): + target = SiderealTargetFactory.create(name='FormTarget', pm_ra=None, pm_dec=None) + mock_facility_class.return_value.get_observing_programs.return_value = [make_program()] + status_url = reverse('tom_cfht:kealahou-target-status') + + # state 2: no association -> the Target Grouping gate + response = self.client.get(status_url, {'target_id': target.id}) + self.assertContains(response, 'not yet associated with a Target Grouping') + + # state 3: associated, not uploaded -> upload button naming the Target Grouping + association = make_association() + response = self.client.get(status_url, {'target_id': target.id}) + self.assertContains(response, 'not in Kealahou') + self.assertContains(response, 'Upload to Kealahou (adds to') + + # state 4: uploaded but removed from the Target Grouping -> add button + KealahouTargetLink.objects.create(target=target, program_token=PROGRAM_TOKEN, + kealahou_target_token=f'{PROGRAM_TOKEN}-1000000009', version=1) + response = self.client.get(status_url, {'target_id': target.id}) + self.assertContains(response, 'Add to Target Grouping') + + # state 5: uploaded and in the Target Grouping -> just the badge + association.target_list.targets.add(target) + response = self.client.get(status_url, {'target_id': target.id}) + self.assertContains(response, 'in Kealahou') + self.assertNotContains(response, 'Add to Target Grouping') + + @mock.patch('tom_cfht.views.CFHTFacility') + def test_add_target_to_target_grouping_endpoint(self, mock_facility_class): + association = make_association() + target = SiderealTargetFactory.create(name='ReAdd', pm_ra=None, pm_dec=None) + KealahouTargetLink.objects.create(target=target, program_token=PROGRAM_TOKEN, + kealahou_target_token=f'{PROGRAM_TOKEN}-1000000010', version=1) + mock_facility_class.return_value.get_observing_programs.return_value = [make_program()] + response = self.client.post(reverse('tom_cfht:add-target-to-target-grouping'), + {'target_id': target.id, 'program_token': PROGRAM_TOKEN}) + self.assertEqual(response.status_code, 200) + self.assertIn(target, association.target_list.targets.all()) + + def test_facility_declares_observation_form_template(self): + # ObservationCreateView.get_template_names() picks this up (tom_observations/views.py) + self.assertEqual(CFHTFacility.template_name, 'tom_cfht/observation_form.html') diff --git a/tom_cfht/urls.py b/tom_cfht/urls.py new file mode 100644 index 0000000..e3b3ab3 --- /dev/null +++ b/tom_cfht/urls.py @@ -0,0 +1,40 @@ +from django.urls import path + +from .views import ( + CFHTFacilityIndexView, + ProfileUpdateView, + add_target_to_target_grouping, + associate_target_grouping, + kealahou_target_status, + observing_programs, + program_panel, + sync_selected_targets, + targets_section, + upload_single_target, +) + + +app_name = 'tom_cfht' + +urlpatterns = [ + # facility landing page, linked from the navbar "Facilities" menu + # (see TomCFHTConfig.observation_facilities()) + path('', CFHTFacilityIndexView.as_view(), name='facility-index'), + + # htmx partials for the facility page (program tabs and their sections) + path('observing-programs/', observing_programs, name='observing-programs'), + path('programs//panel/', program_panel, name='program-panel'), + path('programs//targets-section/', targets_section, name='targets-section'), + path('programs//associate-target-grouping/', associate_target_grouping, + name='associate-target-grouping'), + path('programs//sync-selected-targets/', sync_selected_targets, + name='sync-selected-targets'), + + # htmx partials for the CFHT observation form (Kealahou state chain per program) + path('kealahou-target-status/', kealahou_target_status, name='kealahou-target-status'), + path('upload-target/', upload_single_target, name='upload-single-target'), + path('add-target-to-target-grouping/', add_target_to_target_grouping, + name='add-target-to-target-grouping'), + + path('users//update/', ProfileUpdateView.as_view(), name='cfht-profile-update'), +] diff --git a/tom_cfht/views.py b/tom_cfht/views.py index 91ea44a..46d64fd 100644 --- a/tom_cfht/views.py +++ b/tom_cfht/views.py @@ -1,3 +1,431 @@ +from __future__ import annotations + +import logging + +import httpx +import pydantic +from django.contrib.auth.decorators import login_required +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import ImproperlyConfigured +from django.http import HttpRequest, HttpResponse from django.shortcuts import render +from django.urls import reverse_lazy +from django.views.generic import TemplateView +from django.views.generic.edit import UpdateView + +from aeonlib.cfht.models import Instrument, ProgramInfo + +from tom_targets.models import Target, TargetList + +from tom_cfht import kealahou +from tom_cfht.cfht import CFHTFacility +from tom_cfht.models import CFHTProfile, KealahouProgramAssociation, KealahouTargetLink + +logger = logging.getLogger(__name__) + +# Exceptions that the htmx endpoints report as an alert in the partial (HTTP 200) +# rather than letting the request 500. Anything else is a genuine bug and should raise. +KEALAHOU_ERRORS = (httpx.HTTPError, pydantic.ValidationError, ImproperlyConfigured, ValueError) + + +class ProfileUpdateView(UpdateView): + """ + View that handles updating of a user's ``CFHTProfile``. + + The CFHT Facility has a ``CFHTProfile`` model (see ``models.py``). This view updates + the properties of that model. + + The ``CFHTProfile`` properties are displayed by the `cfht_user_profile.html`` template. + This typically happens on the on the User Profile page via the ``show_app_profiles`` + inclusion tag (see ``tom_base/tom_common/templates/tom_common/user_profile.html`` and + ``tom_base/tom_common/templatetags/user_extras.py::show_app_profiles``). + """ + model = CFHTProfile + template_name = 'tom_cfht/update_profile.html' + fields = ['cfht_access_token'] # required by ModelFormMixin, a base class of this ProfileUpdateView + + def get_success_url(self): + return reverse_lazy('user-profile') # back to the TOMToolkit user-profile + + +class CFHTFacilityIndexView(LoginRequiredMixin, TemplateView): + """The CFHT facility page (``/cfht/``), linked from the navbar Facilities menu. + + Renders immediately with static facility information; the observing-program tabs + (and each program's sections) load asynchronously via htmx so a slow or unavailable + Kealahou API never blocks the page. + """ + template_name = 'tom_cfht/facility_index.html' + + def get_context_data(self, **kwargs) -> dict: + context = super().get_context_data(**kwargs) + facility = CFHTFacility() + context['observing_sites'] = facility.get_observing_sites() + context['instruments'] = ['MegaCam', 'ESPaDOnS', 'SPIRou'] # what AEONlib/Kealahou support today + return context + + +def _facility_for(request: HttpRequest) -> CFHTFacility: + """Return a CFHTFacility bound to the requesting user (the tom_base credential pattern).""" + facility = CFHTFacility() + facility.set_user(request.user) + return facility + + +def _program_by_token(programs: list[ProgramInfo], program_token: str) -> ProgramInfo | None: + """Find a program in ``programs`` by its Kealahou program token.""" + for program in programs: + if program.program_data is not None and program.program_data.token == program_token: + return program + return None + + +def _program_instrument(program: ProgramInfo) -> Instrument | None: + """Return the program's (single) instrument, per Kealahou's one-instrument-per-program rule.""" + program_data = program.program_data + if program_data is None or not program_data.time_allocation: + return None + return program_data.time_allocation[0].instrument + + +def _association_for(program_token: str) -> KealahouProgramAssociation | None: + """Return the program's Target Grouping association, or None if the user hasn't made one.""" + return (KealahouProgramAssociation.objects.select_related('target_list') + .filter(program_token=program_token).first()) + + +def _target_grouping_gate_context(program_token: str, instrument: Instrument | None, + origin: str, target: Target | None = None) -> dict: + """Context for the select-or-create Target Grouping gate partial. + + Args: + program_token: the program awaiting an association. + instrument: the program's instrument (for the suggested default name). + origin: which fragment the gate is rendered in and should re-render on submit: + 'targets-section' or 'target-status'. + target: the target whose status fragment hosts the gate (origin 'target-status'). + """ + return { + 'program_token': program_token, + 'available_target_lists': TargetList.objects.order_by('name'), + 'default_target_list_name': kealahou.default_target_list_name(program_token, instrument), + 'origin': origin, + 'target': target, + } + + +def _instrument_requirements_context(instrument: Instrument | None) -> dict: + """Context describing the instrument's required upload fields, for the inline inputs.""" + return { + 'instrument': instrument.value if instrument else None, + 'magnitude_band': kealahou.REQUIRED_MAGNITUDE_BAND_BY_INSTRUMENT.get(instrument, '').upper(), + 'needs_temperature': instrument in kealahou.INSTRUMENTS_REQUIRING_TEMPERATURE, + 'needs_radial_velocity': instrument in kealahou.INSTRUMENTS_REQUIRING_RADIAL_VELOCITY, + } + + +def _split_result_messages(result_messages: list[kealahou.ResultMessage] | None) -> dict: + """Split ResultMessages into context lists: failures (danger alert) vs notices (info alert).""" + result_messages = result_messages or [] + return { + 'error_messages': [message.text for message in result_messages if not message.success], + 'info_messages': [message.text for message in result_messages if message.success], + } + + +def _render_targets_section(request: HttpRequest, program_token: str, + result_messages: list[kealahou.ResultMessage] | None = None) -> HttpResponse: + """(Re-)render the Targets section of a program panel. + + Shows the Target Grouping gate until the user associates one with the program; + afterwards, the four-bucket sync panel. Used by the GET endpoint and re-used by the + POST endpoints (associate, sync-selected) so the section always reflects current state. + """ + template_name = 'tom_cfht/partials/targets_section.html' + try: + facility = _facility_for(request) + aeon_facility = facility.get_aeon_facility() + program = _program_by_token(aeon_facility.programs(), program_token) + if program is None: + return render(request, template_name, + {'program_token': program_token, + 'error': f'Program {program_token} was not found in Kealahou.'}) + instrument = _program_instrument(program) + + association = _association_for(program_token) + if association is None: + # the prerequisite gate: sync features wait until a Target Grouping is chosen + return render(request, template_name, { + 'program_token': program_token, + 'target_grouping_gate': _target_grouping_gate_context( + program_token, instrument, origin='targets-section'), + }) + + kealahou_targets = aeon_facility.targets(program_token) + except KEALAHOU_ERRORS as e: + logger.warning(f'Kealahou targets section unavailable for program {program_token}: {e}') + return render(request, template_name, {'program_token': program_token, 'error': str(e)}) + + sync_state = kealahou.compute_sync_state(program_token, kealahou_targets, association.target_list, + instrument=instrument) + context = { + 'program_token': program_token, + 'sync_state': sync_state, + 'target_list': association.target_list, + } + context.update(_split_result_messages(result_messages)) + context.update(_instrument_requirements_context(instrument)) + return render(request, template_name, context) + + +@login_required +def observing_programs(request: HttpRequest) -> HttpResponse: + """htmx partial: nav-tabs bar with one lazy-loaded tab per CFHT observing program.""" + template_name = 'tom_cfht/partials/observing_programs.html' + try: + facility = _facility_for(request) + programs = facility.get_observing_programs() + except KEALAHOU_ERRORS as e: + logger.warning(f'Could not fetch Kealahou observing programs: {e}') + return render(request, template_name, {'error': str(e)}) + + # flatten the ProgramInfo models into what the tab bar displays + program_rows = [] + for program in programs: + program_data = program.program_data + if program_data is None: + continue + instrument = _program_instrument(program) + pi_info = program.pi_info + completion_ratio = None + if program_data.time_accounting is not None: + completion_ratio = program_data.time_accounting.completion_ratio + program_rows.append({ + 'program_token': program_data.token, + 'title': program_data.title, + 'program_type': program_data.program_type.value if program_data.program_type else '', + 'instrument': instrument.value if instrument else '', + 'pi_name': f'{pi_info.first_name} {pi_info.last_name}' if pi_info else '', + 'percent_complete': round(completion_ratio * 100) if completion_ratio is not None else None, + }) + return render(request, template_name, {'program_rows': program_rows}) + + +@login_required +def program_panel(request: HttpRequest, program_token: str) -> HttpResponse: + """htmx partial: one program's tab pane -- four full-width sections. + + Renders instantly with no Kealahou API call; the Targets section lazy-loads itself, + and the Observing Templates / Observing Groups / Exposures sections are stubs until + AEONlib wraps their endpoints. + """ + return render(request, 'tom_cfht/partials/program_panel.html', {'program_token': program_token}) + + +@login_required +def targets_section(request: HttpRequest, program_token: str) -> HttpResponse: + """htmx partial: the Targets section (Target Grouping gate or four-bucket sync panel).""" + return _render_targets_section(request, program_token) + + +@login_required +def associate_target_grouping(request: HttpRequest, program_token: str) -> HttpResponse: + """htmx POST: satisfy the prerequisite by associating a Target Grouping with a program. + + Accepts either ``target_list_id`` (an existing Target Grouping) or + ``new_target_list_name`` (creates one). Re-renders the fragment named by ``origin``. + """ + target_list = None + target_list_id = request.POST.get('target_list_id', '').strip() + new_target_list_name = request.POST.get('new_target_list_name', '').strip() + if target_list_id: + target_list = TargetList.objects.filter(id=target_list_id).first() + elif new_target_list_name: + target_list, _ = TargetList.objects.get_or_create(name=new_target_list_name) + + if target_list is not None: + KealahouProgramAssociation.objects.update_or_create( + program_token=program_token, defaults={'target_list': target_list}) + + # re-render whichever fragment hosted the gate + if request.POST.get('origin') == 'target-status': + target = Target.objects.filter(id=request.POST.get('target_id')).first() + if target is None: + return render(request, 'tom_cfht/partials/kealahou_target_status.html', + {'error': 'Unknown target.'}) + return _render_kealahou_status(request, target) + return _render_targets_section(request, program_token) + + +def _upload_request_from_post(request: HttpRequest, target: Target) -> kealahou.UploadRequest: + """Build an UploadRequest from the per-target inline inputs.""" + def parse_float(input_name: str) -> float | None: + raw_value = request.POST.get(f'{input_name}_{target.id}', '').strip() + return float(raw_value) if raw_value else None + + return kealahou.UploadRequest( + target=target, + magnitude=parse_float('magnitude'), + temperature_effective=parse_float('temperature_effective'), + radial_velocity_kmps=parse_float('radial_velocity_kmps'), + ) + + +def _do_upload(request: HttpRequest, program_token: str, + targets: list[Target]) -> list[kealahou.ResultMessage]: + """Upload ``targets`` to Kealahou using the inline form values; returns display messages.""" + association = _association_for(program_token) + if association is None: + return [kealahou.ResultMessage( + f'Program {program_token} has no associated Target Grouping yet.', success=False)] + facility = _facility_for(request) + aeon_facility = facility.get_aeon_facility() + program = _program_by_token(aeon_facility.programs(), program_token) + if program is None: + return [kealahou.ResultMessage(f'Program {program_token} was not found in Kealahou.', success=False)] + instrument = _program_instrument(program) + if instrument is None: + return [kealahou.ResultMessage( + f'Program {program_token} has no instrument allocation; cannot upload targets.', success=False)] + + try: + upload_requests = [_upload_request_from_post(request, target) for target in targets] + except ValueError as e: # unparseable magnitude/Teff/RV input + return [kealahou.ResultMessage(f'Invalid input: {e}', success=False)] + upload_results = kealahou.upload_targets(aeon_facility, program_token, instrument, + upload_requests, association.target_list) + return [kealahou.ResultMessage(f'{result.target.name}: {result.message}', success=result.success) + for result in upload_results] + + +@login_required +def sync_selected_targets(request: HttpRequest, program_token: str) -> HttpResponse: + """htmx POST: sync exactly the checked rows, both directions. + + Checked "In Kealahou only" rows (``kealahou_target_token``) are imported into the TOM; + checked "In TOM only" rows (``target_id``) are uploaded using their inline + required-field inputs. Rows fail independently. Field-value discrepancies on linked + targets are displayed by the panel but never auto-resolved. + """ + selected_kealahou_target_tokens = request.POST.getlist('kealahou_target_token') + selected_targets = list(Target.objects.filter(id__in=request.POST.getlist('target_id'))) + if not selected_kealahou_target_tokens and not selected_targets: + return _render_targets_section(request, program_token, + [kealahou.ResultMessage('No targets selected.')]) + + result_messages = [] + association = _association_for(program_token) + if association is None: + return _render_targets_section( + request, program_token, + [kealahou.ResultMessage(f'Program {program_token} has no associated Target Grouping yet.', + success=False)]) + if selected_kealahou_target_tokens: + try: + facility = _facility_for(request) + kealahou_targets = facility.get_aeon_facility().targets(program_token) + result_messages += kealahou.import_targets(program_token, selected_kealahou_target_tokens, + kealahou_targets, association.target_list) + except KEALAHOU_ERRORS as e: + result_messages.append(kealahou.ResultMessage(f'Download failed: {e}', success=False)) + if selected_targets: + try: + result_messages += _do_upload(request, program_token, selected_targets) + except KEALAHOU_ERRORS as e: + result_messages.append(kealahou.ResultMessage(f'Upload failed: {e}', success=False)) + return _render_targets_section(request, program_token, result_messages) + + +def _render_kealahou_status(request: HttpRequest, target: Target, + result_messages: list[kealahou.ResultMessage] | None = None) -> HttpResponse: + """(Re-)render the observation form's Kealahou status fragment for one target. + + Each program row walks a state chain and shows exactly the next action: + no association -> Target Grouping gate; not uploaded -> upload button; uploaded but + missing from the Target Grouping -> add button; otherwise an "in Kealahou" badge. + """ + template_name = 'tom_cfht/partials/kealahou_target_status.html' + try: + facility = _facility_for(request) + programs = facility.get_observing_programs() + except KEALAHOU_ERRORS as e: + logger.warning(f'Kealahou status unavailable for target {target.id}: {e}') + return render(request, template_name, {'error': str(e), 'target': target}) + + links_by_program = {link.program_token: link + for link in KealahouTargetLink.objects.filter(target=target)} + program_statuses = [] + for program in programs: + program_data = program.program_data + if program_data is None: + continue + instrument = _program_instrument(program) + association = _association_for(program_data.token) + link = links_by_program.get(program_data.token) + in_target_list = (association is not None + and association.target_list.targets.filter(id=target.id).exists()) + status = { + 'program_token': program_data.token, + 'title': program_data.title, + 'link': link, + 'association': association, + 'in_target_list': in_target_list, + } + status.update(_instrument_requirements_context(instrument)) + if association is None: + status['target_grouping_gate'] = _target_grouping_gate_context( + program_data.token, instrument, origin='target-status', target=target) + program_statuses.append(status) + context = {'target': target, 'program_statuses': program_statuses} + context.update(_split_result_messages(result_messages)) + return render(request, template_name, context) + + +@login_required +def kealahou_target_status(request: HttpRequest) -> HttpResponse: + """htmx partial for the observation form: this target's Kealahou state per program.""" + try: + target = Target.objects.get(id=request.GET.get('target_id')) + except (Target.DoesNotExist, ValueError): + return render(request, 'tom_cfht/partials/kealahou_target_status.html', {'error': 'Unknown target.'}) + return _render_kealahou_status(request, target) + + +@login_required +def upload_single_target(request: HttpRequest) -> HttpResponse: + """htmx POST from the observation form: upload one target to one program, then + re-render the Kealahou status fragment. The upload also adds the target to the + program's associated Target Grouping.""" + program_token = request.POST.get('program_token', '') + try: + target = Target.objects.get(id=request.POST.get('target_id')) + except (Target.DoesNotExist, ValueError): + return render(request, 'tom_cfht/partials/kealahou_target_status.html', {'error': 'Unknown target.'}) + + try: + result_messages = _do_upload(request, program_token, [target]) + except KEALAHOU_ERRORS as e: + logger.warning(f'Kealahou upload of target {target.id} to {program_token} failed: {e}') + result_messages = [kealahou.ResultMessage(f'Upload failed: {e}', success=False)] + return _render_kealahou_status(request, target, result_messages) + + +@login_required +def add_target_to_target_grouping(request: HttpRequest) -> HttpResponse: + """htmx POST from the observation form: add an already-uploaded target to the + program's associated Target Grouping (the rare state where it was removed by hand).""" + program_token = request.POST.get('program_token', '') + try: + target = Target.objects.get(id=request.POST.get('target_id')) + except (Target.DoesNotExist, ValueError): + return render(request, 'tom_cfht/partials/kealahou_target_status.html', {'error': 'Unknown target.'}) -# Create your views here. + association = _association_for(program_token) + if association is None: + result_messages = [kealahou.ResultMessage( + f'Program {program_token} has no associated Target Grouping yet.', success=False)] + else: + association.target_list.targets.add(target) + result_messages = [kealahou.ResultMessage( + f'{target.name}: added to Target Grouping "{association.target_list.name}".')] + return _render_kealahou_status(request, target, result_messages)