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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion frontend/src/lib/api/services/pointing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import type {
PointingResponse,
PointingUpdate,
CancelAllRequest,
PointingFilters
PointingFilters,
PointingDeleteRequest,
PointingUpdateBody
} from '../types/pointing.types';
import type { DOIRequest, DOIRequestResponse } from '../types/doi.types';

Expand All @@ -30,6 +32,18 @@ export const pointingService = {
return response.data;
},

updatePointing: async (id: number, update: PointingUpdateBody): Promise<{ message: string }> => {
const response = await client.put<{ message: string }>(`/api/v1/pointings/${id}`, update);
return response.data;
},

deletePointings: async (request: PointingDeleteRequest): Promise<{ message: string }> => {
const response = await client.delete<{ message: string }>('/api/v1/pointings', {
data: request
});
return response.data;
},

requestDOI: async (request: DOIRequest): Promise<DOIRequestResponse> => {
const response = await client.post<DOIRequestResponse>('/api/v1/request_doi', request);
return response.data;
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/lib/api/types/pointing.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,25 @@ export interface CancelAllRequest {
instrumentid: number;
}

export interface PointingDeleteRequest {
ids: number[];
}

export interface PointingUpdateBody {
status?: string | number;
time?: string;
depth?: number;
depth_err?: number;
depth_unit?: string | number;
band?: string | number;
pos_angle?: number;
central_wave?: number;
bandwidth?: number;
ra?: number;
dec?: number;
position?: string;
}

export interface PointingFilters {
graceid?: string;
graceids?: string;
Expand Down
6 changes: 1 addition & 5 deletions frontend/src/lib/components/search/PointingsTable.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,7 @@
];

function canSelectPointing(pointing: any): boolean {
// Only allow selection if:
// 1. Selection is allowed (allowSelection is true, which means myPointsOnly is true)
// 2. Status is 'completed'
// 3. No DOI URL already exists
return allowSelection && pointing.status === 'completed' && !pointing.doi_url;
return allowSelection;
}

function togglePointingSelection(pointingId: number) {
Expand Down
79 changes: 78 additions & 1 deletion frontend/src/routes/search/pointings/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,42 @@
function handleSelectionChange(event: CustomEvent) {
selectedPointings = event.detail.selectedPointings;
}

// DOI requests are only valid for completed pointings without an existing DOI.
// Compute this subset so DoiRequestPanel never receives ineligible IDs.
$: doiEligibleSelected = new Set(
searchResults
.filter((p) => selectedPointings.has(p.id) && p.status === 'completed' && !p.doi_url)
.map((p) => p.id)
);

// Handle delete selected pointings
let isDeleting = false;
let deleteError = '';

async function handleDeleteSelected() {
if (selectedPointings.size === 0) return;

const count = selectedPointings.size;
const confirmed = confirm(
`Delete ${count} pointing${count === 1 ? '' : 's'}? This cannot be undone.`
);
if (!confirmed) return;

isDeleting = true;
deleteError = '';

try {
await api.pointings.deletePointings({ ids: [...selectedPointings] });
searchResults = searchResults.filter((p) => !selectedPointings.has(p.id));
selectedPointings.clear();
selectedPointings = selectedPointings;
} catch (err) {
deleteError = err instanceof Error ? err.message : 'Delete failed. Please try again.';
} finally {
isDeleting = false;
}
}
</script>

<svelte:head>
Expand All @@ -137,13 +173,31 @@
<!-- DOI Request Panel -->
{#if hasSearched && lastSearchParams?.my_points_only}
<DoiRequestPanel
{selectedPointings}
selectedPointings={doiEligibleSelected}
graceid={lastSearchParams.graceid}
visible={true}
on:doi-request={handleDoiRequest}
/>
{/if}

<!-- Delete Selected -->
{#if hasSearched && lastSearchParams?.my_points_only && selectedPointings.size > 0}
<div class="flex items-center gap-4">
<button class="delete-btn" disabled={isDeleting} on:click={handleDeleteSelected}>
{#if isDeleting}
Deleting...
{:else}
Delete {selectedPointings.size} selected pointing{selectedPointings.size === 1
? ''
: 's'}
{/if}
</button>
{#if deleteError}
<span class="text-sm text-red-600">{deleteError}</span>
{/if}
</div>
{/if}

<!-- Loading State -->
{#if isSearching}
<div class="bg-white rounded-lg shadow p-8">
Expand Down Expand Up @@ -215,3 +269,26 @@
{/if}
</div>
</PageContainer>

<style>
.delete-btn {
padding: 0.5rem 1rem;
background-color: #dc2626;
color: white;
border: none;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease-in-out;
}

.delete-btn:hover:not(:disabled) {
background-color: #b91c1c;
}

.delete-btn:disabled {
background-color: #fca5a5;
cursor: not-allowed;
}
</style>
29 changes: 14 additions & 15 deletions server/auth/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,31 +109,30 @@ def get_current_user(
)


def is_admin_user(user: Users, db: Session) -> bool:
"""Return True if the user belongs to the admin group."""
return (
db.query(UserGroups)
.join(Groups, UserGroups.groupid == Groups.id)
.filter(
UserGroups.userid == user.id,
func.lower(Groups.name) == "admin",
)
.first()
) is not None


def verify_admin(
user: Users = Depends(get_current_user), db: Session = Depends(get_db)
) -> Users:
"""
Check if the user belongs to the admin group (case-insensitive).
"""
admin_group = db.query(Groups).filter(func.lower(Groups.name) == "admin").first()
if not admin_group:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin group does not exist",
)

user_group = (
db.query(UserGroups)
.filter(UserGroups.userid == user.id, UserGroups.groupid == admin_group.id)
.first()
)

if not user_group:
if not is_admin_user(user, db):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only admins can access this endpoint",
)

return user


Expand Down
5 changes: 2 additions & 3 deletions server/routes/event/delete_candidate_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@
from server.db.database import get_db
from server.db.models.candidate import GWCandidate
from server.utils.error_handling import not_found_exception, permission_exception
from server.auth.auth import get_current_user
from .utils import is_admin
from server.auth.auth import get_current_user, is_admin_user

router = APIRouter(tags=["Events"])

Expand All @@ -25,7 +24,7 @@ async def delete_candidate_event(
raise not_found_exception("Candidate not found")

# Check if user is the owner or an admin
if db_candidate.submitterid != current_user.id and not is_admin(current_user, db):
if db_candidate.submitterid != current_user.id and not is_admin_user(current_user, db):
raise permission_exception("Not authorized to delete this candidate")

db.delete(db_candidate)
Expand Down
5 changes: 2 additions & 3 deletions server/routes/event/update_candidate_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
from server.db.models.candidate import GWCandidate
from server.schemas.candidate import GWCandidateSchema
from server.utils.error_handling import not_found_exception, permission_exception
from server.auth.auth import get_current_user
from .utils import is_admin
from server.auth.auth import get_current_user, is_admin_user

router = APIRouter(tags=["Events"])

Expand All @@ -27,7 +26,7 @@ async def update_candidate_event(
raise not_found_exception("Candidate not found")

# Check if user is the owner or an admin
if db_candidate.submitterid != current_user.id and not is_admin(current_user, db):
if db_candidate.submitterid != current_user.id and not is_admin_user(current_user, db):
raise permission_exception("Not authorized to update this candidate")

# Update fields
Expand Down
19 changes: 0 additions & 19 deletions server/routes/event/utils.py

This file was deleted.

59 changes: 59 additions & 0 deletions server/routes/pointing/delete_pointings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Delete pointings endpoint."""

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session

from server.db.database import get_db
from server.db.models.pointing import Pointing
from server.db.models.pointing_event import PointingEvent
from server.schemas.pointing import PointingDeleteRequest
from server.auth.auth import get_current_user, is_admin_user
from server.utils.error_handling import not_found_exception, validation_exception

router = APIRouter(tags=["pointings"])


@router.delete("/pointings")
async def delete_pointings(
request: PointingDeleteRequest,
db: Session = Depends(get_db),
user=Depends(get_current_user),
):
"""
Delete pointings by ID.

Users may only delete their own pointings. Admins may delete any pointing.
Associated pointing_event rows are also removed.
"""
try:
admin = is_admin_user(user, db)

query = db.query(Pointing.id).filter(Pointing.id.in_(request.ids))
if not admin:
query = query.filter(Pointing.submitterid == user.id)

deleted_ids = sorted(row.id for row in query.all())
if not deleted_ids:
raise not_found_exception("No matching pointings found for the given IDs.")

db.query(PointingEvent).filter(
PointingEvent.pointingid.in_(deleted_ids)
).delete(synchronize_session=False)

db.query(Pointing).filter(
Pointing.id.in_(deleted_ids)
).delete(synchronize_session=False)

db.commit()

failed_ids = sorted(set(request.ids) - set(deleted_ids))
return {
"message": f"Deleted {len(deleted_ids)} of {len(request.ids)} pointing(s).",
"deleted_ids": deleted_ids,
"failed_ids": failed_ids,
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise validation_exception(message="Invalid request", errors=[str(e)])
Loading
Loading