diff --git a/app.arc b/app.arc index 2b700dd3ed..b21ce79beb 100644 --- a/app.arc +++ b/app.arc @@ -49,6 +49,9 @@ acrossapi_tle tle1 String tle2 String +burstcube_too + id *String + sessions _idx *String _ttl TTL diff --git a/app/lib/utils.ts b/app/lib/utils.ts index 27cd2bdc30..261bea0688 100644 --- a/app/lib/utils.ts +++ b/app/lib/utils.ts @@ -20,6 +20,11 @@ export function topicToFormatAndNoticeType(topic: string): { noticeFormat: string noticeType: string } { + if (topic.startsWith('gcn.notices.') || topic === 'igwn.gwalert') + return { + noticeFormat: 'json', + noticeType: topic, + } const splitString = topic.split('.') return { noticeFormat: splitString[2], noticeType: splitString[3] } } diff --git a/app/routes/_gcn.circulars._archive._index/route.tsx b/app/routes/_gcn.circulars._archive._index/route.tsx index 8cc346e20f..76db052f2b 100644 --- a/app/routes/_gcn.circulars._archive._index/route.tsx +++ b/app/routes/_gcn.circulars._archive._index/route.tsx @@ -15,6 +15,7 @@ import { useSubmit, } from '@remix-run/react' import { + Alert, Button, ButtonGroup, Icon, @@ -93,7 +94,7 @@ export async function action({ request }: ActionFunctionArgs) { const user = await getUser(request) const circularId = getFormDataString(data, 'circularId') - let result + let newCircular const props = { body, subject, ...(format ? { format } : {}) } switch (intent) { case 'correction': @@ -103,7 +104,7 @@ export async function action({ request }: ActionFunctionArgs) { { circularId: parseFloat(circularId), ...props }, user ) - result = null + newCircular = null break case 'edit': if (!circularId) @@ -115,24 +116,27 @@ export async function action({ request }: ActionFunctionArgs) { }, user ) - result = await get(parseFloat(circularId)) + newCircular = await get(parseFloat(circularId)) break case 'new': - result = await put({ ...props, submittedHow: 'web' }, user) + newCircular = await put({ ...props, submittedHow: 'web' }, user) break default: break } - return result + return { newCircular, intent } } export default function () { - const newItem = useActionData() + const result = useActionData() const { items, page, totalPages, totalItems, requestedChangeCount } = useLoaderData() // Concatenate items from the action and loader functions - const allItems = [...(newItem ? [newItem] : []), ...(items || [])] + const allItems = [ + ...(result?.newCircular ? [result.newCircular] : []), + ...(items || []), + ] const [searchParams] = useSearchParams() const userIsModerator = useModStatus() @@ -158,6 +162,17 @@ export default function () { return ( <> + {result?.intent === 'correction' && ( + + Thank you for your correction. A GCN Circulars moderator will review + it shortly. + + )} {userIsModerator && requestedChangeCount > 0 && ( diff --git a/app/routes/_gcn.docs.code-of-conduct.md b/app/routes/_gcn.docs.code-of-conduct.md new file mode 100644 index 0000000000..596e6d8a63 --- /dev/null +++ b/app/routes/_gcn.docs.code-of-conduct.md @@ -0,0 +1,34 @@ +--- +handle: + breadcrumb: Code of Conduct +--- + +# Code of Conduct + +GCN Notices and Circulars are rapid communications which are by definition preliminary, and submitted as best-effort for the accuracy of their content. They are citable in publications, but are not peer reviewed. + +The sharing of preliminary analysis and results in an open and respectful manner enables the productive exchange of information and ideas. Notices and Circulars contain factual information on observations and analysis of astronomical transients, not opinions, speculation, or disagreements with individuals or groups. The Circulars [style guide](/circulars/styleguide) provides additional criteria and community standards for Circular content. + +GCN is committed to adhere to the [NASA Astrophysics Statement of Principles](https://cor.gsfc.nasa.gov/docs/HQ/NASA-Astrophysics-Statement-of-Principles-Nov2022.pdf). + +## Retractions + +If an initial automated or manually-generated Notice/Circular is later deemed to be misclassified as an astronomical object, the generating team should promptly submit another Notice/Circular informing the community of the retracted claim. + +## Errata + +Circulars authors can request edits to their own archived Circulars to correct inaccurate information, especially when the correction is important for the follow-up community's activities. These may include author lists, subject corrections, typos, and incorrect citations. This capability should not be used to add details of new observations or analysis — instead users should submit new Circulars with subjects noting "refined analysis" or "additional observations". To request corrections, navigate to the archive page for that particular circular, and select "Request Correction." The GCN team moderators will review the requested correction, and may contact the submitter for their concurrence. + +## Reproducing GCN Notices or Circulars in External Archives + +The GCN team welcomes cross-compatibility between community astronomical resources. GCN Circulars are scholarly publications and that and the expectations of scientific conduct for citing and reproducing them apply. If GCN Notices, Circulars, or their data are used in other systems, please link back to the original source materal at https://gcn.nasa.gov or https://gcn.gsfc.nasa.gov. + +## Enforcement + +The GCN system and community aim to foster an inclusive environment for the exchange of information and ideas. Mistakes are completely understandable in these forms of rapid communication, and the GCN team is happy to work with users to correct any unintentional errors. GCN will not tolerate users who are intentionally disrespectful, misrepresent data or analysis, plagiarize, or reproduce data or analysis without permission of the originator or proper citation. + +Circulars submitting privileges are granted for new users via the GCN [peer endorsement system](/user/endorsements). Any user found to intentionally violate these policies will have their Circulars/Notices submission privileges revoked. This may lead to a review of the peer endorser who approved the offending user. + +## Acknowledgement + +The GCN team requests that any presentation, publication, or document that mentions the GCN system, specific GCN Circulars or Notices, to please reference the General Coordinates Network (https://gcn.nasa.gov) and cite Circulars using bibliographic records from the [SAO/NASA Astrophysics Data System (ADS)](https://ui.adsabs.harvard.edu). diff --git a/app/routes/_gcn.docs.tsx b/app/routes/_gcn.docs.tsx index eb6285d27a..be50f4e2de 100644 --- a/app/routes/_gcn.docs.tsx +++ b/app/routes/_gcn.docs.tsx @@ -58,6 +58,9 @@ export default function () { ]} /> , + + Code of Conduct + , <> Contributing diff --git a/app/routes/_gcn.news.email.tsx b/app/routes/_gcn.news.email.tsx index 523c5509be..050132f80d 100644 --- a/app/routes/_gcn.news.email.tsx +++ b/app/routes/_gcn.news.email.tsx @@ -11,6 +11,7 @@ import { Button, ButtonGroup, FormGroup, + Icon, InputGroup, InputPrefix, TextInput, @@ -21,6 +22,7 @@ import { useState } from 'react' import { getUser } from './_gcn._auth/user.server' import { moderatorGroup } from './_gcn.circulars/circulars.server' +import { announcementAppendedText } from './_gcn.user.email/email_announcements' import { sendAnnouncementEmail } from './_gcn.user.email/email_announcements.server' import { getFormDataString } from '~/lib/utils' @@ -45,8 +47,11 @@ export async function action({ request }: ActionFunctionArgs) { export default function () { const [subjectValid, setSubjectValid] = useState(false) const [bodyValid, setBodyValid] = useState(false) + const [showAppendedText, toggleShowAppendedText] = useState(false) const valid = subjectValid && bodyValid const submitted = useActionData() + const defaultBody = + 'The GCN Team is pleased to announce a new feature on https://gcn.nasa.gov that ...' return ( <>

GCN News Announcement

@@ -75,19 +80,30 @@ export default function () { 'usa-input--success': subjectValid, })} > - Subject + + Subject + { setSubjectValid(Boolean(value)) }} /> +
+ + Please replace "[New Feature]" with the appropriate title + +
@@ -95,13 +111,42 @@ export default function () { name="body" id="body" required={true} + defaultValue={defaultBody} className={classnames('maxw-full', { 'usa-input--success': bodyValid, })} onChange={({ target: { value } }) => { setBodyValid(Boolean(value)) }} + aria-describedby="bodyDescription" /> +
+ + The submitted body text will have additional email footer content + appended automatically.{' '} + + + {showAppendedText && ( +
+ {announcementAppendedText} +
+ )} +
Back diff --git a/app/routes/_gcn.user.email.edit.tsx b/app/routes/_gcn.user.email.edit.tsx index 707b0fd226..b207804014 100644 --- a/app/routes/_gcn.user.email.edit.tsx +++ b/app/routes/_gcn.user.email.edit.tsx @@ -35,7 +35,7 @@ import { type NoticeFormat, NoticeFormatInput } from '~/components/NoticeFormat' import { NoticeTypeCheckboxes } from '~/components/NoticeTypeCheckboxes/NoticeTypeCheckboxes' import { ReCAPTCHA, verifyRecaptcha } from '~/components/ReCAPTCHA' import { formatAndNoticeTypeToTopic } from '~/lib/utils' -import { useRecaptchaSiteKey } from '~/root' +import { useFeature, useRecaptchaSiteKey } from '~/root' import type { BreadcrumbHandle } from '~/root/Title' import { getUser } from '~/routes/_gcn._auth/user.server' @@ -59,9 +59,12 @@ export async function action({ request }: ActionFunctionArgs) { } = Object.fromEntries(data) if (intent !== 'delete') await verifyRecaptcha(recaptchaResponse?.toString()) const noticeTypes = Object.keys(rest) - const topics = noticeTypes.map((noticeType) => - formatAndNoticeTypeToTopic(noticeFormat.toString(), noticeType) - ) + const topics = + noticeFormat == 'json' + ? noticeTypes + : noticeTypes.map((noticeType) => + formatAndNoticeTypeToTopic(noticeFormat.toString(), noticeType) + ) const emailNotification: EmailNotification = { name: name.toString(), recipient: recipient.toString(), @@ -119,6 +122,7 @@ export default function () { const [recipientValid, setRecipientValid] = useState(defaultRecipientValid) const [alertsValid, setAlertsValid] = useState(false) const [recaptchaValid, setRecaptchaValid] = useState(!useRecaptchaSiteKey()) + const [defaultFormat, setFormat] = useState(format) return (
@@ -160,12 +164,18 @@ export default function () { onChange={(e) => setRecipientValid(Boolean(e.target.value))} /> - + + /> { setRecaptchaValid(Boolean(value)) diff --git a/app/routes/_gcn.user.email/email_announcements.server.ts b/app/routes/_gcn.user.email/email_announcements.server.ts index 6bfcde8df7..847dd372ab 100644 --- a/app/routes/_gcn.user.email/email_announcements.server.ts +++ b/app/routes/_gcn.user.email/email_announcements.server.ts @@ -8,9 +8,11 @@ import { tables } from '@architect/functions' import type { DynamoDBDocument } from '@aws-sdk/lib-dynamodb' import { paginateQuery, paginateScan } from '@aws-sdk/lib-dynamodb' +import { dedent } from 'ts-dedent' import type { User } from '../_gcn._auth/user.server' import { moderatorGroup } from '../_gcn.circulars/circulars.server' +import { announcementAppendedText } from './email_announcements' import { sendEmailBulk } from '~/lib/email.server' export async function createAnnouncementSubsciption( @@ -68,11 +70,18 @@ export async function sendAnnouncementEmail( getLegacyAnnouncementReceiverEmails(), ]) + const formattedBody = dedent` + ${body} + + + ${announcementAppendedText} + ` + await sendEmailBulk({ fromName: 'GCN Announcements', to: [...emails, ...legacyEmails], subject, - body, + body: formattedBody, topic: 'announcements', }) } diff --git a/app/routes/_gcn.user.email/email_announcements.ts b/app/routes/_gcn.user.email/email_announcements.ts new file mode 100644 index 0000000000..86caa55d72 --- /dev/null +++ b/app/routes/_gcn.user.email/email_announcements.ts @@ -0,0 +1,17 @@ +/*! + * Copyright © 2023 United States Government as represented by the + * Administrator of the National Aeronautics and Space Administration. + * All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export const announcementAppendedText = ` +For more details on this new feature and an archive of GCN news and announcements, see https://gcn.nasa.gov/news. + +For questions, issues, or bug reports, please contact us via: +- Contact form: + https://gcn.nasa.gov/contact +- GitHub issue tracker: + https://github.com/nasa-gcn/gcn.nasa.gov/issues +` diff --git a/package-lock.json b/package-lock.json index 15e63c902e..4748b74f02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,11 +52,11 @@ "source-map-support": "^0.5.21", "spin-delay": "^1.1.0", "tar-stream": "^3.1.6", - "tiny-invariant": "^1.3.1", + "tiny-invariant": "^1.3.3", "ts-dedent": "^2.2.0", "unified": "^10.0.0", "unist-builder": "^4.0.0", - "usehooks-ts": "^2.15.0" + "usehooks-ts": "^2.15.1" }, "devDependencies": { "@architect/architect": "^10.16.3", @@ -13153,13 +13153,14 @@ } }, "node_modules/es5-ext": { - "version": "0.10.61", - "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.61.tgz", - "integrity": "sha512-yFhIqQAzu2Ca2I4SE2Au3rxVfmohU9Y7wqGR+s7+H7krk26NXhIRAZDgqd6xqjCEFUomDEA3/Bo/7fKmIkW1kA==", + "version": "0.10.63", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.63.tgz", + "integrity": "sha512-hUCZd2Byj/mNKjfP9jXrdVZ62B8KuA/VoK7X8nUh5qT+AxDmcbvZz041oDVZdbIN1qW6XY9VDNwzkvKnZvK2TQ==", "hasInstallScript": true, "dependencies": { "es6-iterator": "^2.0.3", "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", "next-tick": "^1.1.0" }, "engines": { @@ -14294,6 +14295,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esniff/node_modules/type": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", + "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==" + }, "node_modules/espree": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", @@ -25306,9 +25326,9 @@ "dev": true }, "node_modules/tiny-invariant": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.1.tgz", - "integrity": "sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==" + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" }, "node_modules/titleize": { "version": "3.0.0", @@ -26170,9 +26190,9 @@ "dev": true }, "node_modules/usehooks-ts": { - "version": "2.15.0", - "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.15.0.tgz", - "integrity": "sha512-2bLQ632044hD1Hp879yAFOPabXfx7SbzEZYnpIiaCw6lTHiuvsNS1sc2UBVycasjvQImD6QsNERQXjKg7OuTaA==", + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.15.1.tgz", + "integrity": "sha512-AK29ODCt4FT9XleILNbkbjjmkRCNaQrgxQEkvqHjlnT76iPXzTFGvK2Y/s83JEdSxRp43YEnSa3bYBEV6HZ26Q==", "dependencies": { "lodash.debounce": "^4.0.8" }, diff --git a/package.json b/package.json index b728062fd1..4979ad9865 100644 --- a/package.json +++ b/package.json @@ -71,11 +71,11 @@ "source-map-support": "^0.5.21", "spin-delay": "^1.1.0", "tar-stream": "^3.1.6", - "tiny-invariant": "^1.3.1", + "tiny-invariant": "^1.3.3", "ts-dedent": "^2.2.0", "unified": "^10.0.0", "unist-builder": "^4.0.0", - "usehooks-ts": "^2.15.0" + "usehooks-ts": "^2.15.1" }, "devDependencies": { "@architect/architect": "^10.16.3", diff --git a/python/across_api/base/api.py b/python/across_api/base/api.py index ece647dcb0..e81fbf96f4 100644 --- a/python/across_api/base/api.py +++ b/python/across_api/base/api.py @@ -4,10 +4,14 @@ """ Base API definitions for ACROSS API. This module is imported by all other API -modules. Contains the FastAPI app definition. +modules. Contains the FastAPI app definition and global depends definition. """ +from astropy.time import Time # type: ignore[import] +from datetime import datetime +from typing import Annotated, Optional +from fastapi import Depends, FastAPI, Path, Query +import astropy.units as u # type: ignore[import] -from fastapi import FastAPI # FastAPI app definition app = FastAPI( @@ -19,3 +23,167 @@ }, root_path="/labs/api/v1", ) + +# FastAPI Depends definitions + + +# Depends functions for FastAPI calls. +async def optional_daterange( + begin: Annotated[ + Optional[datetime], + Query( + description="Start time of period to be calculated.", + title="Begin", + ), + ] = None, + end: Annotated[ + Optional[datetime], + Query( + description="Start time of period to be calculated.", + title="End", + ), + ] = None, +) -> dict: + """ + Helper function to convert begin and end to datetime objects. + """ + if begin is None or end is None: + return {"begin": None, "end": None} + return {"begin": Time(begin), "end": Time(end)} + + +OptionalDateRangeDep = Annotated[dict, Depends(optional_daterange)] + + +# Depends functions for FastAPI calls. +async def optional_length( + length: Annotated[ + Optional[float], + Query( + description="Length of time (days).", + title="Length", + ), + ] = None, +) -> Optional[u.Quantity]: + """ + Helper function to convert begin and end to datetime objects. + """ + if length is None: + return None + return length * u.day + + +OptionalLengthDep = Annotated[dict, Depends(optional_length)] + + +async def optional_limit( + limit: Annotated[ + Optional[int], + Query( + ge=0, + title="Limit", + description="Maximum number of results to return.", + ), + ] = None, +) -> Optional[int]: + return limit + + +LimitDep = Annotated[Optional[int], Depends(optional_limit)] + + +async def error_radius( + error_radius: Annotated[ + Optional[float], + Query( + ge=0, + title="Error Radius", + description="Error radius in degrees.", + ), + ] = None, +) -> Optional[float]: + if error_radius is None: + return None + return error_radius * u.deg + + +ErrorRadiusDep = Annotated[float, Depends(error_radius)] + + +async def exposure( + exposure: Annotated[ + float, + Query( + ge=0, + title="Exposure", + description="Exposure time in seconds.", + ), + ] = 200, +) -> u.Quantity: + return exposure * u.s + + +ExposureDep = Annotated[float, Depends(exposure)] + + +async def offset( + offset: Annotated[ + float, + Query( + ge=-200, + le=200, + title="Offset", + description="Offset start of dump window from T0 by this amount (seconds).", + ), + ] = -50, +) -> u.Quantity: + return offset * u.s + + +OffsetDep = Annotated[float, Depends(offset)] + + +IdDep = Annotated[str, Path(description="TOO ID string")] + + +async def trigger_time( + trigger_time: Annotated[ + datetime, + Query( + title="Trigger Time", + description="Time of trigger in UTC or ISO format.", + ), + ], +) -> Optional[datetime]: + return Time(trigger_time) + + +TriggerTimeDep = Annotated[datetime, Depends(trigger_time)] + + +async def optional_ra_dec( + ra: Annotated[ + Optional[float], + Query( + ge=0, + lt=360, + title="RA (J2000)", + description="Right Ascenscion in J2000 coordinates and units of decimal degrees.", + ), + ] = None, + dec: Annotated[ + Optional[float], + Query( + ge=-90, + le=90, + title="Dec (J2000)", + description="Declination in J2000 coordinates in units of decimal degrees.", + ), + ] = None, +) -> Optional[dict]: + if ra is None or dec is None: + return {"ra": None, "dec": None} + return {"ra": ra * u.deg, "dec": dec * u.deg} + + +OptionalRaDecDep = Annotated[dict, Depends(optional_ra_dec)] diff --git a/python/across_api/base/constraints.py b/python/across_api/base/constraints.py index fcfed27a65..ccc4107864 100644 --- a/python/across_api/base/constraints.py +++ b/python/across_api/base/constraints.py @@ -41,7 +41,7 @@ def get_slice(time: Time, ephem: EphemBase) -> slice: else: # Check that the time range is within the ephemeris range, as above. assert ( - time[0] >= ephem.begin and time[-1] <= ephem.end + time[0].jd >= ephem.begin.jd and time[-1].jd <= ephem.end.jd ), "Time outside of ephemeris of range" # Find the indices for the start and end of the time range and return a diff --git a/python/across_api/base/database.py b/python/across_api/base/database.py new file mode 100644 index 0000000000..ad4a74afca --- /dev/null +++ b/python/across_api/base/database.py @@ -0,0 +1,14 @@ +from typing import Any +import arc # type: ignore[import] +import boto3 # type: ignore[import] +import os + + +def dynamodb_table(tablename) -> Any: + """If running in Architect, return tables.table, else return boto3 dynamodb + table. This enables the use of moto to mock the dynamodb table in tests.""" + if os.environ.get("ARC_ENV") is not None: + return arc.tables.table(tablename) + else: + session = boto3.Session() + return session.resource("dynamodb", region_name="us-east-1").Table(tablename) diff --git a/python/across_api/base/schema.py b/python/across_api/base/schema.py index a20ffd18b7..a19a6b3683 100644 --- a/python/across_api/base/schema.py +++ b/python/across_api/base/schema.py @@ -4,30 +4,84 @@ from datetime import datetime -from typing import Annotated, Any, List, Optional - +import json +from typing import Annotated, Any, Dict, List, Optional, Union +from astropy.coordinates import Latitude, Longitude # type: ignore[import] import astropy.units as u # type: ignore -from arc import tables # type: ignore from astropy.time import Time # type: ignore from pydantic import ( BaseModel, + BeforeValidator, ConfigDict, Field, PlainSerializer, + WithJsonSchema, computed_field, model_validator, ) +from .database import dynamodb_table + # Define a Pydantic type for astropy Time objects, which will be serialized as # a naive UTC datetime object, or a string in ISO format for JSON. AstropyTime = Annotated[ Time, + BeforeValidator(lambda x: Time(x) if type(x) is not Time else x), PlainSerializer( lambda x: x.utc.datetime, return_type=datetime, ), + WithJsonSchema( + {"type": "string", "format": "date-time"}, + mode="serialization", + ), + WithJsonSchema( + {"type": "string", "format": "date-time"}, + mode="validation", + ), +] + + +# Pydantic type for a Astropy Time in seconds +AstropySeconds = Annotated[ + u.Quantity, + BeforeValidator(lambda x: x * u.s if type(x) is not u.Quantity else x.to(u.s)), + PlainSerializer( + lambda x: x.to(u.s).value, + return_type=float, + ), + WithJsonSchema( + {"type": "number"}, + mode="serialization", + ), + WithJsonSchema( + {"type": "number"}, + mode="validation", + ), +] + + +# Pydantic type for a scalar astropy Quantity/Latitude/Longitude in degrees +AstropyAngle = Annotated[ + Union[Latitude, Longitude, u.Quantity[u.deg]], + BeforeValidator(lambda x: x * u.deg if isinstance(x, (int, float)) else x), + PlainSerializer( + lambda x: x.to_value(u.deg), + return_type=float, + ), + WithJsonSchema( + {"type": "number"}, + mode="serialization", + ), + WithJsonSchema( + {"type": "number"}, + mode="validation", + ), ] +# Define a pydantic type for a dictionary that will be serialized as a JSON +JsonStr = Annotated[Dict, PlainSerializer(lambda x: json.dumps(x), return_type=str)] + class BaseSchema(BaseModel): """ @@ -75,6 +129,98 @@ def check_dates(cls, data: Any) -> Any: return data +class OptionalDateRangeSchema(BaseSchema): + """Schema that defines date range, which is optional + + Parameters + ---------- + begin + The beginning date of the range, by default None + end + The end date of the range, by default None + + Methods + ------- + check_dates(data: Any) -> Any + Validates the date range and ensures that the begin and end dates are set correctly. + + """ + + begin: Optional[AstropyTime] = None + end: Optional[AstropyTime] = None + + @model_validator(mode="after") + @classmethod + def check_dates(cls, data: Any) -> Any: + """Validates the date range and ensures that the begin and end dates are set correctly. + + Parameters + ---------- + data + The data to be validated. + + Returns + ------- + Any + The validated data. + + Raises + ------ + AssertionError + If the begin and end dates are not both set or both not set. + If the end date is before the begin date. + + """ + if data.begin is None or data.end is None: + assert ( + data.begin == data.end + ), "Begin/End should both be set, or both not set" + if data.begin != data.end: + assert data.begin <= data.end, "End date should not be before begin" + + return data + + +class OptionalPositionSchema(BaseSchema): + """ + Schema for representing position information with an error radius. + + Attributes + ---------- + error + The error associated with the position. Defaults to None. + """ + + ra: Optional[AstropyAngle] = Field(ge=0 * u.deg, lt=360 * u.deg, default=None) + dec: Optional[AstropyAngle] = Field(ge=-90 * u.deg, le=90 * u.deg, default=None) + error: Optional[AstropyAngle] = None + + @model_validator(mode="after") + @classmethod + def check_ra_dec(cls, data: Any) -> Any: + """Validates that RA and Dec are both set or both not set. + + Parameters + ---------- + data + The data to be validated. + + Returns + ------- + Any + The validated data. + + Raises + ------ + AssertionError + If RA and Dec are not both set or both not set. + + """ + if data.ra is None or data.dec is None: + assert data.ra == data.dec, "RA/Dec should both be set, or both not set" + return data + + class TLEGetSchema(BaseSchema): epoch: AstropyTime @@ -151,7 +297,7 @@ def find_tles_between_epochs( ------- A list of TLEEntry objects between the specified epochs. """ - table = tables.table(cls.__tablename__) + table = dynamodb_table(cls.__tablename__) # Query the table for TLEs between the two epochs response = table.query( @@ -168,7 +314,7 @@ def find_tles_between_epochs( def write(self) -> None: """Write the TLE entry to the database.""" - table = tables.table(self.__tablename__) + table = dynamodb_table(self.__tablename__) table.put_item(Item=self.model_dump(mode="json")) diff --git a/python/across_api/burstcube/api.py b/python/across_api/burstcube/api.py index ff1d227489..3542432dcb 100644 --- a/python/across_api/burstcube/api.py +++ b/python/across_api/burstcube/api.py @@ -1,3 +1,205 @@ # Copyright © 2023 United States Government as represented by the # Administrator of the National Aeronautics and Space Administration. # All Rights Reserved. + +import gzip +from datetime import datetime +from typing import Annotated, BinaryIO, Optional, Tuple, Union + +from astropy.io import fits # type: ignore +from astropy.time import Time # type: ignore +from fastapi import Depends, File, HTTPException, Query, Security, UploadFile, status + +from ..auth.api import claims, scope_authorize +from ..base.api import ( + ErrorRadiusDep, + ExposureDep, + IdDep, + LimitDep, + OffsetDep, + OptionalDateRangeDep, + OptionalLengthDep, + OptionalRaDecDep, + TriggerTimeDep, + app, +) +from .requests import BurstCubeTOORequests +from .schema import BurstCubeTOORequestsSchema, BurstCubeTOOSchema, BurstCubeTriggerInfo +from .toorequest import BurstCubeTOO + + +# BurstCube Deps +async def optional_trigger_time( + trigger_time: Annotated[ + Optional[datetime], + Query( + title="Trigger Time", + description="Time of trigger in UTC or ISO format.", + ), + ] = None, +) -> Optional[datetime]: + if trigger_time is None: + return None + return Time(trigger_time) + + +OptionalTriggerTimeDep = Annotated[datetime, Depends(optional_trigger_time)] + + +def read_healpix_file(healpix_file: UploadFile) -> Tuple[fits.FITS_rec, str]: + """Read in a HEALPix file in FITS format and return the HDUList. Supports + gzipped FITS files""" + + # Type hint for the file object + file: Union[gzip.GzipFile, BinaryIO] + + # If the file is a gzip file, open it with gzip.open, otherwise just + # pass along the filehandle + if healpix_file.content_type == "application/x-gzip": + file = gzip.open(healpix_file.file, "rb") + else: + file = healpix_file.file + + # Open HEALPix fits file and extract data and ordering scheme + try: + with fits.open(file) as hdu: + healpix_data = hdu[1].data + healpix_scheme = hdu[1].header["ORDERING"] + except (OSError, KeyError, IndexError): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid HEALPix file.", + ) + return healpix_data, healpix_scheme + + +@app.post( + "/burstcube/too", + status_code=status.HTTP_201_CREATED, + dependencies=[ + Security(scope_authorize, scopes=["gcn.nasa.gov/kafka-public-consumer"]) + ], +) +async def burstcube_too_submit( + credential: Annotated[dict, Depends(claims)], + ra_dec: OptionalRaDecDep, + error_radius: ErrorRadiusDep, + trigger_time: TriggerTimeDep, + trigger_info: BurstCubeTriggerInfo, + exposure: ExposureDep, + offset: OffsetDep, + healpix_file: UploadFile = File( + None, description="HEALPix file describing the localization." + ), +) -> BurstCubeTOOSchema: + """ + Resolve the name of an astronomical object to its coordinates. + """ + # Construct the TOO object. + too = BurstCubeTOO( + username=credential["sub"], + ra=ra_dec["ra"], + dec=ra_dec["dec"], + error_radius=error_radius, + trigger_time=trigger_time, + trigger_info=trigger_info, + exposure=exposure, + offset=offset, + ) + # If a HEALpix file was uploaded, open it and set the healpix_loc + # and healpix_scheme attributes. + if healpix_file is not None: + too.healpix_loc, too.healpix_scheme = read_healpix_file(healpix_file) + too.post() + return too.schema + + +@app.put( + "/burstcube/too/{id}", + status_code=status.HTTP_201_CREATED, + dependencies=[ + Security(scope_authorize, scopes=["gcn.nasa.gov/kafka-public-consumer"]) + ], +) +async def burstcube_too_update( + id: IdDep, + credential: Annotated[dict, Depends(claims)], + ra_dec: OptionalRaDecDep, + error_radius: ErrorRadiusDep, + trigger_time: TriggerTimeDep, + trigger_info: BurstCubeTriggerInfo, + exposure: ExposureDep, + offset: OffsetDep, + healpix_file: UploadFile = File( + None, description="HEALPix file describing the localization." + ), +) -> BurstCubeTOOSchema: + """ + Update a BurstCube TOO object with the given ID number. + """ + # Update the TOO object. + too = BurstCubeTOO( + id=id, + username=credential["sub"], + ra=ra_dec["ra"], + dec=ra_dec["dec"], + error_radius=error_radius, + trigger_time=trigger_time, + trigger_info=trigger_info, + exposure=exposure, + offset=offset, + ) + # If a HEALpix file was uploaded, open it and set the healpix_loc + # and healpix_scheme attributes. + if healpix_file is not None: + too.healpix_loc, too.healpix_scheme = read_healpix_file(healpix_file) + too.put() + return too.schema + + +@app.get("/burstcube/too/", status_code=status.HTTP_200_OK) +async def burstcube_too_requests( + daterange: OptionalDateRangeDep, + length: OptionalLengthDep, + limit: LimitDep, +) -> BurstCubeTOORequestsSchema: + """ + Endpoint to retrieve BurstCube multiple TOO requests. + """ + return BurstCubeTOORequests( + begin=daterange["begin"], + end=daterange["end"], + length=length, + limit=limit, + ).schema + + +@app.get("/burstcube/too/{id}", status_code=status.HTTP_200_OK) +async def burstcube_too( + id: IdDep, +) -> BurstCubeTOOSchema: + """ + Retrieve a BurstCube Target of Opportunity (TOO) by ID. + """ + too = BurstCubeTOO(id=id) + too.get() + return too.schema + + +@app.delete( + "/burstcube/too/{id}", + status_code=status.HTTP_200_OK, + dependencies=[ + Security(scope_authorize, scopes=["gcn.nasa.gov/kafka-public-consumer"]) + ], +) +async def burstcube_delete_too( + credential: Annotated[dict, Depends(claims)], + id: IdDep, +) -> BurstCubeTOOSchema: + """ + Delete a BurstCube Target of Opportunity (TOO) with the given ID. + """ + too = BurstCubeTOO(username=credential["sub"], id=id) + too.delete() + return too.schema diff --git a/python/across_api/burstcube/requests.py b/python/across_api/burstcube/requests.py new file mode 100644 index 0000000000..cfa9ffcd7e --- /dev/null +++ b/python/across_api/burstcube/requests.py @@ -0,0 +1,115 @@ +# Copyright © 2023 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. + +from typing import Optional + +import astropy.units as u # type: ignore +from arc import tables # type: ignore +from astropy.time import Time # type: ignore +from boto3.dynamodb.conditions import Key # type: ignore + +from ..base.common import ACROSSAPIBase +from .schema import ( + BurstCubeTOOModel, + BurstCubeTOORequestsGetSchema, + BurstCubeTOORequestsSchema, + BurstCubeTOOSchema, +) + + +class BurstCubeTOORequests(ACROSSAPIBase): + """ + Class to fetch multiple BurstCubeTOO requests, based on various filters. + + Note that the filtering right now is based on DynamoDB scan, which is not + very efficient. This should be replaced with a query at some point. + + Parameters + ---------- + begin + Start time of plan search + end + End time of plan search + limit + Limit number of searches + length + Length of time to search from now + + Attributes + ---------- + entries + List of BurstCubeTOO requests + status + Status of BurstCubeTOO query + """ + + _schema = BurstCubeTOORequestsSchema + _get_schema = BurstCubeTOORequestsGetSchema + mission = "ACROSS" + + def __getitem__(self, i): + return self.entries[i] + + def __len__(self): + return len(self.entries) + + def __init__( + self, + begin: Optional[Time] = None, + end: Optional[Time] = None, + length: Optional[u.Quantity] = None, + limit: Optional[int] = None, + ): + # Default parameters + self.limit = limit + self.begin = begin + self.end = end + self.length = length + # Attributes + self.entries: list = [] + + # Parse Arguments + if self.validate_get(): + self.get() + + def get(self) -> bool: + """ + Get a list of BurstCubeTOO requests + + Returns + ------- + bool + Did this work? True | False + """ + # Validate query + if not self.validate_get(): + return False + table = tables.table(BurstCubeTOOModel.__tablename__) + + if self.length is not None: + self.begin = Time.now() + self.end = self.begin - self.length + + # Search for events that overlap a given date range + if self.begin is not None and self.end is not None: + toos = table.scan( + FilterExpression=Key("created_on").between( + str(self.begin), str(self.end) + ) + ) + else: + toos = table.scan() + + # Convert entries for return + self.entries = [BurstCubeTOOSchema.model_validate(too) for too in toos["Items"]] + + # Sort and limit the results + self.entries.sort(key=lambda x: x.trigger_time, reverse=True) + self.entries = self.entries[: self.limit] + + return True + + +# Short aliases for classes +TOORequests = BurstCubeTOORequests diff --git a/python/across_api/burstcube/schema.py b/python/across_api/burstcube/schema.py new file mode 100644 index 0000000000..707320f627 --- /dev/null +++ b/python/across_api/burstcube/schema.py @@ -0,0 +1,219 @@ +# Copyright © 2023 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. + + +import hashlib +import json +from decimal import Decimal +from enum import Enum +from typing import List, Optional + +import astropy.units as u # type: ignore +from astropy.time import Time # type: ignore +from pydantic import ConfigDict, computed_field, model_validator # type: ignore + +from ..base.schema import ( + AstropySeconds, + AstropyTime, + BaseSchema, + JsonStr, + OptionalDateRangeSchema, + OptionalPositionSchema, +) + + +class TOOReason(str, Enum): + """ + Reasons for rejecting TOO observations + + Attributes + ---------- + saa + In SAA + earth_occult + Earth occulted + moon_occult + Moon occulted + sun_occult + Sun occulted + too_old + Too old + other + Other + none + None + """ + + saa = "In SAA" + earth_occult = "Earth occulted" + moon_occult = "Moon occulted" + sun_occult = "Sun occulted" + too_old = "Too old" + other = "Other" + none = "None" + + +class TOOStatus(str, Enum): + """ + Enumeration class representing the status of a Target of Opportunity (TOO) request. + + Attributes: + requested + The TOO request has been submitted. + rejected + The TOO request has been rejected. + declined + The TOO request has been declined. + approved + The TOO request has been approved. + executed + The TOO request has been executed. + other + The TOO request has a status other than the predefined ones. + """ + + requested = "Requested" + rejected = "Rejected" + declined = "Declined" + approved = "Approved" + executed = "Executed" + deleted = "Deleted" + other = "Other" + + +class BurstCubeTriggerInfo(BaseSchema): + """ + Metadata schema for the BurstCube Target of Opportunity (TOO) request. Note + that this schema is not strictly defined, keys are only suggested, and + additional keys can be added as needed. + """ + + trigger_name: Optional[str] = None + trigger_mission: Optional[str] = None + trigger_instrument: Optional[str] = None + trigger_id: Optional[str] = None + trigger_duration: Optional[AstropySeconds] = None + classification: Optional[str] = None + justification: Optional[str] = None + + model_config = ConfigDict(extra="allow") + + @model_validator(mode="before") + def convert_json_string_to_dict(cls, data): + if isinstance(data, str): + return json.loads(data) + return data + + +class BurstCubeTOOSchema(OptionalPositionSchema): + """ + Schema describing a BurstCube TOO Request. + """ + + id: Optional[str] = None + created_by: str + created_on: AstropyTime + modified_by: Optional[str] = None + modified_on: Optional[AstropyTime] = None + trigger_time: AstropyTime + trigger_info: BurstCubeTriggerInfo + exposure: AstropySeconds + offset: AstropySeconds + reject_reason: TOOReason = TOOReason.none + status: TOOStatus = TOOStatus.requested + too_info: str = "" + + +class BurstCubeTOODelSchema(BaseSchema): + """ + Schema for BurstCubeTOO DELETE API call. + + Attributes + ---------- + id + The ID of the BurstCubeTOODel object. + """ + + id: str + + +class BurstCubeTOOPostSchema(OptionalPositionSchema): + """ + Schema to submit a TOO request for BurstCube. + """ + + trigger_time: AstropyTime + trigger_info: BurstCubeTriggerInfo + exposure: AstropySeconds = 200 * u.s + offset: AstropySeconds = -50 * u.s + + +class BurstCubeTOOGetSchema(BaseSchema): + """ + Schema for BurstCubeTOO GET request. + """ + + id: str + + +class BurstCubeTOOPutSchema(BurstCubeTOOPostSchema): + """ + Schema for BurstCubeTOO PUT request. + """ + + id: str + + +class BurstCubeTOORequestsGetSchema(OptionalDateRangeSchema): + """ + Schema for GET requests to retrieve BurstCube Target of Opportunity (TOO) requests. + """ + + length: Optional[u.Quantity] = None + limit: Optional[int] = None + + @model_validator(mode="after") + def check_begin_and_end_or_length_set(self): + if self.begin is not None and self.end is not None and self.length is not None: + raise ValueError("Cannot set both begin and end and length.") + elif self.begin is not None and self.length is not None: + self.end = self.begin + self.length + elif self.begin is None and self.end is None and self.length is not None: + self.end = Time.now() + self.begin = self.end - self.length + + +class BurstCubeTOORequestsSchema(BaseSchema): + """ + Schema for BurstCube TOO requests. + """ + + entries: List[BurstCubeTOOSchema] + + +class BurstCubeTOOModel(BaseSchema): + """Database Model Schema for BurstCube TOO requests.""" + + __tablename__ = "burstcube_too" + ra: Optional[Decimal] = None + dec: Optional[Decimal] = None + error_radius: Optional[Decimal] = None + created_by: str + created_on: str + modified_by: Optional[str] = None + modified_on: Optional[str] = None + trigger_time: str + trigger_info: JsonStr + exposure: Decimal = Decimal(200) + offset: Decimal = Decimal(-50) + reject_reason: str = "None" + status: str = "Requested" + too_info: str = "" + + @computed_field # type: ignore + @property + def id(self) -> str: + return hashlib.md5( + f"{self.trigger_time}{self.exposure}{self.offset}".encode() + ).hexdigest() diff --git a/python/across_api/burstcube/toorequest.py b/python/across_api/burstcube/toorequest.py new file mode 100644 index 0000000000..5fc3b3f1f6 --- /dev/null +++ b/python/across_api/burstcube/toorequest.py @@ -0,0 +1,338 @@ +# Copyright © 2023 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. + +from dataclasses import dataclass # type: ignore[import] +from functools import cached_property +from typing import Optional, Union + +import astropy.units as u # type: ignore[import] +import botocore # type: ignore[import] +import numpy as np +from astropy.coordinates import ( # type: ignore[import] + Latitude, + Longitude, + SkyCoord, +) +from astropy.time import Time # type: ignore[import] +from fastapi import HTTPException + +from ..base.database import dynamodb_table + +from ..base.common import ACROSSAPIBase, round_time +from .constraints import burstcube_saa_constraint +from .ephem import BurstCubeEphem +from .fov import BurstCubeFOV +from .schema import ( + BurstCubeTOODelSchema, + BurstCubeTOOGetSchema, + BurstCubeTOOModel, + BurstCubeTOOPostSchema, + BurstCubeTOOPutSchema, + BurstCubeTOOSchema, + BurstCubeTriggerInfo, + TOOReason, + TOOStatus, +) + + +@dataclass +class BurstCubeTOO(ACROSSAPIBase): + """ + Class to handle BurstCube Target of Opportunity Requests + + Parameters + ---------- + username + Username of user making request + id + ID of BurstCubeTOO to fetch, by default None + created_by + Username of user who created the BurstCubeTOO, by default None + created_on + Time BurstCubeTOO was created, by default None + modified_by + Username of user who last modified the BurstCubeTOO, by default None + modified_on + Time BurstCubeTOO was last modified, by default None + trigger_time + Time of trigger, by default None + trigger_info + Information about the trigger, by default None + exposure + Exposure time, by default 200 * u.s + offset + Offset time, by default -50 * u.s + ra + Right Ascension, by default None + dec + Declination, by default None + error_radius + Error radius, by default None + healpix_loc + HEALPix location, by default None + healpix_scheme + HEALPix scheme, by default "nested" + reject_reason + Reason for rejection, by default `TOOReason.none` + status + Status of request, by default "Requested" + too_info + Information about the TOO, by default "" + min_prob + Minimum probability in FOV to accept a TOO request, by default 0.10 + """ + + _schema = BurstCubeTOOSchema + _get_schema = BurstCubeTOOGetSchema + _put_schema = BurstCubeTOOPutSchema + _del_schema = BurstCubeTOODelSchema + _post_schema = BurstCubeTOOPostSchema + + id: Optional[str] = None + username: Optional[str] = None + created_by: Optional[str] = None + created_on: Optional[Time] = None + modified_by: Optional[str] = None + modified_on: Optional[Time] = None + trigger_time: Optional[Time] = None + trigger_info: Optional[BurstCubeTriggerInfo] = None + exposure: u.Quantity[u.s] = 200 * u.s + offset: u.Quantity[u.s] = -50 * u.s + ra: Union[u.Quantity[u.deg], Longitude, None] = None + dec: Union[u.Quantity[u.deg], Latitude, None] = None + error_radius: Optional[u.Quantity[u.deg]] = None + healpix_loc: Optional[np.ndarray] = None + healpix_scheme: str = "nested" + reject_reason: TOOReason = TOOReason.none + status: TOOStatus = TOOStatus.requested + too_info: str = "" + min_prob: float = 0.10 # 10% of probability in FOV + + # @property + # def table(self): + # """Return the table for the BurstCubeTOO.""" + # if not hasattr(self, "_table"): + # self._table = tables.table(BurstCubeTOOModel.__tablename__) + # return self._table + + @property + def table(self): + """Return the table for the BurstCubeTOO.""" + if not hasattr(self, "_table"): + self._table = dynamodb_table(BurstCubeTOOModel.__tablename__) + return self._table + + @table.setter + def table(self, value): + self._table = value + + @cached_property + def skycoord(self) -> Optional[SkyCoord]: + """Return the skycoord of the BurstCubeTOO.""" + if self.ra is not None and self.dec is not None: + return SkyCoord(self.ra, self.dec) + return None + + def get(self) -> bool: + """ + Fetch a BurstCubeTOO for a given id. + + Returns + ------- + Did this work? True | False + """ + + # Fetch BurstCubeTOO from database + try: + response = self.table.get_item(Key={"id": self.id}) + except botocore.exceptions.ClientError as e: + raise HTTPException(500, f"Error fetching BurstCubeTOO: {e}") + if "Item" not in response: + raise HTTPException(404, "BurstCubeTOO not found.") + + # Validate the response + too = BurstCubeTOOSchema.model_validate(response["Item"]) + + # Set the attributes of the BurstCubeTOO to the values from the database + for k, v in too: + setattr(self, k, v) + return True + + def delete(self) -> bool: + """ + Delete a given too, specified by id. created_by of BurstCubeTOO has to match yours. + + Returns + ------- + Did this work? True | False + """ + if self.validate_del(): + if self.get(): + # FIXME: Need proper authentication here + if self.created_by != self.username: + raise HTTPException(401, "BurstCubeTOO not owned by user.") + + self.status = TOOStatus.deleted + self.modified_by = self.username + self.modified_on = Time.now().datetime.isoformat() + # Write updated BurstCubeTOO to the database + too = BurstCubeTOOModel(**self.schema.model_dump(mode="json")) + self.table.put_item(Item=too.model_dump()) + return True + return False + + def put(self) -> bool: + """ + Alter existing BurstCube BurstCubeTOO using ACROSS API using POST + + Returns + ------- + Did this work? True | False + """ + # Make sure the PUT request validates + if not self.validate_put(): + return False + + # Check if this BurstCubeTOO exists + response = self.table.get_item(Key={"id": self.id}) + + # Check if the TOO exists + if "Item" not in response: + raise HTTPException(404, "BurstCubeTOO not found.") + + # Reconstruct the TOO as it exists in the database + old_too = BurstCubeTOO(**response["Item"]) + + # FIXME: Some validation as to whether the user is allowed to update + # this entry + + # Check if the coordinates are being changed, if yes, run the + # check_constraints again. If a healpix file is being uploaded, run + # the check_constraints again, as we don't record the healpix_loc for comparison. + if ( + self.ra != old_too.ra + or self.dec != old_too.dec + or self.error_radius != old_too.error_radius + or self.healpix_loc is not None + ): + # Check for various TOO constraints + if not self.check_constraints(): + self.status = TOOStatus.rejected + + # Write BurstCubeTOO to the database + self.modified_by = self.username + self.modified_on = Time.now().datetime.isoformat() + too = BurstCubeTOOModel(**self.schema.model_dump(mode="json")) + self.table.put_item(Item=too.model_dump()) + + return True + + def check_constraints(self): + """ + Check if BurstCubeTOO parameters are valid. + + Returns + ------- + Are BurstCubeTOO parameters valid? True | False + """ + # Reset too_info field + self.too_info = "" + # Check if the trigger time is in the future + # This is really just a sanity check, as the trigger time should be in the past + if self.trigger_time > Time.now(): + self.too_info += "Trigger time is in the future. " + self.reject_reason = TOOReason.other + return False + + # Reject if trigger is > 48 hours old + if self.trigger_time < Time.now() - 48 * u.hour: + self.reject_reason = TOOReason.too_old + self.too_info += "Trigger is too old. " + return False + + # Calculate Ephemeris for the requested dump time at one second time + # resolution + ephem = BurstCubeEphem( + begin=round_time(self.trigger_time + self.offset, 1 * u.s), + end=round_time(self.trigger_time + self.offset + self.exposure, 1 * u.s), + stepsize=1 * u.s, + ) + + # Check if the trigger time is in the SAA + saa = burstcube_saa_constraint( + time=round_time(self.trigger_time, 1 * u.s), ephem=ephem + ) + if saa is True: + self.too_info += "Trigger time inside SAA. " + self.reject_reason = TOOReason.saa + return False + + # Check if the trigger is inside FOV at T0 + if self.skycoord is not None or self.healpix_loc is not None: + fov = BurstCubeFOV(ephem=ephem, time=self.trigger_time) + infov = fov.probability_in_fov( + skycoord=self.skycoord, + error_radius=self.error_radius, + healpix_loc=self.healpix_loc, + healpix_scheme=self.healpix_scheme, + ) + + # Flag up if the required probability is not met + if infov < self.min_prob: + self.too_info += f"Probability inside FOV: {100*infov:.2f}%. Trigger was occulted at T0. " + self.reject_reason = TOOReason.earth_occult + return False + else: + self.too_info += f"Probability inside FOV: {100*infov:.2f}%. " + + # Check if any part of the dump time is inside the SAA, warn if so + if True in burstcube_saa_constraint(time=ephem.timestamp, ephem=ephem): + self.too_info += "Dump time partially inside SAA." + + # Strip excess whitespace + self.too_info = self.too_info.strip() + return True + + def post(self) -> bool: + """ + Upload BurstCubeTOO to ACROSS API using POST + + Returns + ------- + Did this work? True | False + """ + # Shouldn't run this unless a created_by is set + if self.username is None: + raise HTTPException(401, "Username not set.") + + # Validate supplied BurstCubeTOO values against the Schema + if not self.validate_post(): + return False + + # Set the created_on and created_by fields + self.created_on = Time.now() + self.created_by = self.username + + # Create a BurstCubeTOOModel from the BurstCubeTOO + too = BurstCubeTOOModel(**self.schema.model_dump(mode="json")) + + # Check if this BurstCubeTOO exists. As the id is just a hash of the + # trigger_time, exposure and offset, then repeated requests for values + # that match this will be caught. + try: + response = self.table.get_item(Key={"id": too.id}) + if response.get("Item"): + raise HTTPException(409, "BurstCubeTOO already exists.") + except botocore.exceptions.ClientError: + pass + + # Check for various TOO constraints + if not self.check_constraints(): + self.status = TOOStatus.rejected + + # Write BurstCubeTOO to the database + self.table.put_item(Item=too.model_dump()) + self.id = too.id + + return True diff --git a/python/requirements.in b/python/requirements.in index b36ffe91d7..1a9de6a92f 100644 --- a/python/requirements.in +++ b/python/requirements.in @@ -6,6 +6,7 @@ email-validator fastapi healpy mangum +python-multipart requests shapely sgp4 diff --git a/python/requirements.txt b/python/requirements.txt index 0fa5cd2909..e91e9d0d16 100644 --- a/python/requirements.txt +++ b/python/requirements.txt @@ -106,6 +106,8 @@ python-dateutil==2.8.2 # via matplotlib python-jose==3.3.0 # via architect-functions +python-multipart==0.0.9 + # via -r requirements.in pyyaml==6.0.1 # via astropy represent==2.0 diff --git a/python/tests/burstcubetoo/conftest.py b/python/tests/burstcubetoo/conftest.py new file mode 100644 index 0000000000..44945b576f --- /dev/null +++ b/python/tests/burstcubetoo/conftest.py @@ -0,0 +1,119 @@ +import os +from unittest.mock import Mock + +import astropy.units as u # type: ignore[import] +import boto3 # type: ignore +import pytest +from across_api.base.schema import TLEEntry # type: ignore[import] +from across_api.burstcube.schema import BurstCubeTOOModel # type: ignore[import] +from across_api.burstcube.toorequest import BurstCubeTOO, BurstCubeTriggerInfo # type: ignore[import] +from astropy.time import Time # type: ignore +from astropy.time.core import TimeDelta # type: ignore[import] +from moto import mock_aws + + +@pytest.fixture +def burstcube_tle(): + return TLEEntry( + epoch=Time.now(), + tle1="1 25544U 98067A 24059.70586912 .00019555 00000-0 35623-3 0 9995", + tle2="2 25544 51.6410 137.9505 0005676 302.8794 193.7648 15.49465684441577", + satname="ISS (ZARYA)", + ) + + +@pytest.fixture +def mock_read_tle_db(mocker, burstcube_tle): + """Mock TLEEntry find_tles_between_epochs to return burstcube_tle fixture, + so we don't spam space-track.org or CelesTrak.""" + mock = Mock() + mocker.patch.object( + TLEEntry, "find_tles_between_epochs", return_value=[burstcube_tle] + ) + return mock + + +@pytest.fixture(scope="function") +def aws_credentials(): + """Mocked AWS Credentials for moto.""" + os.environ["AWS_ACCESS_KEY_ID"] = "testing" + os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" + os.environ["AWS_SECURITY_TOKEN"] = "testing" + os.environ["AWS_SESSION_TOKEN"] = "testing" + os.environ["AWS_DEFAULT_REGION"] = "us-east-1" + + +@pytest.fixture +def dynamodb(aws_credentials): + with mock_aws(): + yield boto3.client("dynamodb", region_name="us-east-1") + + +@pytest.fixture(scope="function") +def create_too_table(dynamodb): + """Create the TOO Table""" + dynamodb.create_table( + AttributeDefinitions=[ + {"AttributeName": "id", "AttributeType": "S"}, + ], + TableName=BurstCubeTOOModel.__tablename__, + KeySchema=[ + {"AttributeName": "id", "KeyType": "HASH"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + + +@pytest.fixture(scope="function") +def create_tle_table(dynamodb): + """Create the TLE Table so we can use BurstCubeEphem in tests""" + dynamodb.create_table( + TableName="acrossapi_tle", + AttributeDefinitions=[ + {"AttributeName": "satname", "AttributeType": "S"}, + {"AttributeName": "epoch", "AttributeType": "S"}, + ], + KeySchema=[ + {"AttributeName": "satname", "KeyType": "HASH"}, + {"AttributeName": "epoch", "KeyType": "RANGE"}, + ], + BillingMode="PAY_PER_REQUEST", + ) + + +@pytest.fixture +def now(): + yield Time("2024-02-28 15:00:00") + + +@pytest.fixture +def username(): + yield "testuser" + + +@pytest.fixture +def trigger_info(): + yield BurstCubeTriggerInfo( + trigger_mission="BurstCube", + trigger_type="GRB", + ) + + +@pytest.fixture +def burstcube_too(username, now, trigger_info): + too = BurstCubeTOO( + trigger_time=now, + trigger_info=trigger_info, + username=username, + ) + yield too + + +@pytest.fixture +def burstcube_old_too(username, trigger_info): + too = BurstCubeTOO( + trigger_time=Time.now() - TimeDelta(48 * u.hr), + trigger_info=trigger_info, + username=username, + ) + yield too diff --git a/python/tests/burstcubetoo/test_burstcube_too.py b/python/tests/burstcubetoo/test_burstcube_too.py new file mode 100644 index 0000000000..9083b709d9 --- /dev/null +++ b/python/tests/burstcubetoo/test_burstcube_too.py @@ -0,0 +1,70 @@ +import astropy.units as u # type: ignore[import] +from across_api.burstcube.schema import TOOReason # type: ignore[import] +from across_api.burstcube.toorequest import BurstCubeTOO # type: ignore[import] +from astropy.time.core import Time, TimeDelta # type: ignore[import] +from moto import mock_aws + + +@mock_aws +def test_burstcube_too_crud( + username, burstcube_too, create_tle_table, create_too_table, mock_read_tle_db +): + assert burstcube_too.post() is True + assert burstcube_too.id is not None + + # Test fetching posted TOO + too = BurstCubeTOO(id=burstcube_too.id) + too.get() + assert too.id == burstcube_too.id + + assert too.trigger_time == burstcube_too.trigger_time + assert too.trigger_info == burstcube_too.trigger_info + assert too.created_by == burstcube_too.username + assert too.status == "Requested" or too.status == "Rejected" + assert too.trigger_info.trigger_mission == "BurstCube" + assert too.trigger_info.trigger_type == "GRB" + # If the TOO was rejected, it should only be rejected due to SAA as no + # coordinates were given, and trigger time is less than 48 hours old + assert ( + too.reject_reason == TOOReason.none + and too.status == "Requested" + or too.reject_reason == TOOReason.saa + and too.status == "Rejected" + ) + + # Test deleting posted TOO + too = BurstCubeTOO(id=burstcube_too.id, username=username) + too.delete() + + too = BurstCubeTOO(id=burstcube_too.id) + too.get() + assert too.status == "Deleted" + + # Test changing status to Approved + too.status = "Approved" + too.put() + + too = BurstCubeTOO(id=burstcube_too.id) + too.get() + assert too.status == "Approved" + + +def test_burstcube_old_too( + burstcube_old_too, create_tle_table, create_too_table, mock_read_tle_db +): + assert burstcube_old_too.post() is True + assert burstcube_old_too.trigger_time < Time.now() - TimeDelta(48 * u.hr) + assert burstcube_old_too.status == "Rejected" + assert "Trigger is too old." in burstcube_old_too.too_info + assert burstcube_old_too.reject_reason == TOOReason.too_old + + +def test_burstcube_too_double_post( + burstcube_too, create_tle_table, create_too_table, mock_read_tle_db +): + assert burstcube_too.post() is True + try: + burstcube_too.post() + except Exception as e: + assert e.status_code == 409 + assert e.detail == "BurstCubeTOO already exists." diff --git a/requirements.txt b/requirements.txt index e29250cc5f..8b8b28d493 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,9 @@ boto3 mypy ruff hypothesis +moto pytest +pytest-mock types-requests types-cachetools types-python-jose