mirror of
https://github.com/yogeshojha/rengine.git
synced 2026-10-21 07:41:28 +02:00
The search box cleared itself on every keystroke. The effect that syncs the input to the applied filter both read and wrote `draft`, so it was its own dependency: typing a character reset the field, and only the last character of each debounce window ever reached the query. The read is untracked now, and typing "adobe" keeps "adobe" and returns the 2 rows the API returns. Closing a deep-linked program reopened it. The guard was cleared synchronously while goto() dropped ?program= asynchronously, so the effect re-fired with the param still present. deepLinked is now cleared only once the param has actually left the URL. Verified: the sheet stays closed and the query string empties. sync_lock could mask the real exception and strand the advisory lock. Its finally ran a statement on a possibly-poisoned transaction, which raises PendingRollbackError over the original error and skips the unlock — and because the engine is pooled, a session-level lock outlives session.close() and would make every later sync answer "already_running" with nothing logged. It rolls back first now; a deliberately poisoned transaction preserves its DataError and the next run reacquires the lock. target_value is varchar(500) and nothing capped what was written to it, so a program listing a longer URL would abort a sync the way the profile picture and the event detail already did. A resolved value that could never be stored — or imported, since targets.target_value is also 500 — is no longer offered. The per-program refresh took no lock while doing exactly the work the lock was added for: on a feed platform it redownloads the file and rewrites every program's scope. It now takes the same lock, and the endpoint is superuser like its two siblings rather than letting any user drive that repeatedly. Also: the program sheet said HackerOne in five places and linked feed programs to a hackerone.com page that does not exist; formatPayout still announced a minimum as "up to" and _organization_for still rolled back instead of using a savepoint, both because an earlier unasserted string replace silently did not apply; scope_ids=[] was read as "every scope" rather than none; and the feed hardcoded "six_hours" and the "program_added" literal instead of their definitions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SZEi41ZDRWtwYDPFwoCVnu
721 lines
27 KiB
Python
721 lines
27 KiB
Python
from datetime import timedelta
|
|
from uuid import UUID
|
|
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy import Select, func, or_, select
|
|
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlmodel import col
|
|
|
|
from app.services.target import TargetService
|
|
from shared.definitions.bounty_feed import (
|
|
SOURCE_LICENSE,
|
|
SOURCE_NAME,
|
|
SOURCE_URL,
|
|
)
|
|
from shared.definitions.bounty_programs import (
|
|
DEFAULT_FEED_INTERVAL,
|
|
DEFAULT_SYNC_INTERVAL,
|
|
MAX_TAGS_PER_IMPORT,
|
|
NOTIFIABLE_EVENTS,
|
|
PLATFORMS,
|
|
PLATFORMS_BY_KEY,
|
|
SOURCE_LABELS,
|
|
SYNC_INTERVAL_HOURS,
|
|
ProgramSource,
|
|
ProgramState,
|
|
ScopeState,
|
|
SubmissionState,
|
|
SyncInterval,
|
|
asset_type_spec,
|
|
event_spec,
|
|
notify_enabled,
|
|
notify_events,
|
|
raw_state_label,
|
|
)
|
|
from shared.enums.api_key import APIProvider
|
|
from shared.models.api_key import APIKey
|
|
from shared.models.bounty_program import (
|
|
BountyEventRead,
|
|
BountyEventRow,
|
|
BountyImportRequest,
|
|
BountyImportResult,
|
|
BountyProgram,
|
|
BountyProgramDetail,
|
|
BountyProgramRead,
|
|
BountyScope,
|
|
BountyScopeRead,
|
|
BountySettingsRead,
|
|
BountySettingsUpdate,
|
|
BountyStatus,
|
|
PlatformCount,
|
|
)
|
|
from shared.models.instance_settings import InstanceSettings
|
|
from shared.models.organization import Organization, OrganizationSummary
|
|
from shared.models.tag import Tag, TagSummary, TargetTag
|
|
from shared.models.target import Target, TargetOrganization
|
|
from shared.utils.datetime import utc_now
|
|
from shared.utils.slug import add_with_unique_slug
|
|
from shared.utils.validation import clean_name
|
|
|
|
|
|
def _platform_label(platform: str) -> str:
|
|
spec = PLATFORMS_BY_KEY.get(platform)
|
|
return spec.label if spec else platform
|
|
|
|
|
|
MAX_ORG_NAME = 100
|
|
MAX_TAG_NAME = 50
|
|
DEFAULT_TAG_COLOR = "#6B7280"
|
|
MAX_ORG_DESCRIPTION = 500
|
|
|
|
EMPTY_COUNTS = {"in_scope_count": 0, "out_of_scope_count": 0, "importable_count": 0}
|
|
|
|
|
|
def _without_counts(program) -> dict:
|
|
"""paginate() may hand back an already-coerced read, so drop the tallies it defaulted."""
|
|
data = program.model_dump()
|
|
for key in (
|
|
*EMPTY_COUNTS,
|
|
"imported_count",
|
|
"scopes",
|
|
"unreachable",
|
|
"raw_state_label",
|
|
):
|
|
data.pop(key, None)
|
|
data["raw_state_label"] = raw_state_label(data.get("raw_state"))
|
|
spec = PLATFORMS_BY_KEY.get(data.get("platform") or "")
|
|
data["platform_label"] = spec.label if spec else (data.get("platform") or "")
|
|
data["source_label"] = SOURCE_LABELS.get(data.get("source") or "", "")
|
|
return data
|
|
|
|
|
|
SORTS = {
|
|
"name": (col(BountyProgram.name), "asc"),
|
|
"reports": (col(BountyProgram.reports_for_user), "desc"),
|
|
"age": (col(BountyProgram.started_accepting_at), "desc"),
|
|
"payout": (col(BountyProgram.max_payout), "desc"),
|
|
"assets": (col(BountyProgram.name), "asc"),
|
|
}
|
|
|
|
|
|
class BountyProgramService:
|
|
def __init__(self, session: AsyncSession):
|
|
self.session = session
|
|
|
|
async def _credentials_username(self) -> str | None:
|
|
row = await self.session.execute(
|
|
select(APIKey).where(APIKey.provider == APIProvider.HACKERONE)
|
|
)
|
|
key = row.scalar_one_or_none()
|
|
if not key or not key.is_enabled:
|
|
return None
|
|
return (key.key_meta or {}).get("username")
|
|
|
|
async def _settings(self) -> InstanceSettings | None:
|
|
rows = await self.session.execute(select(InstanceSettings).limit(1))
|
|
return rows.scalar_one_or_none()
|
|
|
|
async def events(
|
|
self, platform: str | None, *, kind: str | None, handle: str | None
|
|
) -> Select:
|
|
query = select(BountyEventRow)
|
|
if platform:
|
|
query = query.where(BountyEventRow.platform == platform)
|
|
if kind:
|
|
query = query.where(BountyEventRow.kind == kind)
|
|
if handle:
|
|
query = query.where(BountyEventRow.handle == handle)
|
|
return query.order_by(col(BountyEventRow.created_at).desc())
|
|
|
|
@staticmethod
|
|
def event_read(row: BountyEventRow) -> BountyEventRead:
|
|
spec = event_spec(row.kind)
|
|
return BountyEventRead(
|
|
id=row.id,
|
|
platform=row.platform,
|
|
handle=row.handle,
|
|
program_name=row.program_name,
|
|
kind=row.kind,
|
|
label=spec.label,
|
|
description=spec.description,
|
|
icon=spec.icon,
|
|
tone=spec.tone,
|
|
actionable=spec.actionable,
|
|
asset_type=row.asset_type,
|
|
asset_identifier=row.asset_identifier,
|
|
detail=row.detail,
|
|
created_at=row.created_at,
|
|
)
|
|
|
|
async def read_settings(self) -> BountySettingsRead:
|
|
settings = await self._settings()
|
|
interval = (
|
|
settings.bounty_sync_interval if settings else None
|
|
) or DEFAULT_SYNC_INTERVAL
|
|
stored = settings.bounty_settings if settings else {}
|
|
synced_at = settings.bounty_synced_at if settings else None
|
|
hours = SYNC_INTERVAL_HOURS.get(interval)
|
|
totals = await self.session.execute(
|
|
select(
|
|
select(func.count(BountyProgram.id)).scalar_subquery(),
|
|
select(func.count(BountyEventRow.id)).scalar_subquery(),
|
|
)
|
|
)
|
|
programs, events = totals.one()
|
|
feed_interval = (
|
|
settings.bounty_feed_interval if settings else None
|
|
) or DEFAULT_FEED_INTERVAL
|
|
feed_synced = settings.bounty_feed_synced_at if settings else None
|
|
feed_hours = SYNC_INTERVAL_HOURS.get(feed_interval)
|
|
feed_programs = await self.session.execute(
|
|
select(func.count(BountyProgram.id)).where(
|
|
BountyProgram.source == ProgramSource.FEED.value
|
|
)
|
|
)
|
|
return BountySettingsRead(
|
|
sync_interval=interval,
|
|
feed_interval=feed_interval,
|
|
feed_synced_at=feed_synced,
|
|
feed_next_sync_at=(
|
|
(feed_synced + timedelta(hours=feed_hours))
|
|
if (feed_hours and feed_synced)
|
|
else None
|
|
),
|
|
feed_programs=feed_programs.scalar_one() or 0,
|
|
feed_source=SOURCE_NAME,
|
|
feed_url=SOURCE_URL,
|
|
feed_license=SOURCE_LICENSE,
|
|
notify=notify_enabled(stored),
|
|
notify_events=sorted(notify_events(stored)),
|
|
notifiable_events=list(NOTIFIABLE_EVENTS),
|
|
last_synced_at=synced_at,
|
|
next_sync_at=(
|
|
(synced_at + timedelta(hours=hours)) if (hours and synced_at) else None
|
|
),
|
|
programs=programs or 0,
|
|
events_recorded=events or 0,
|
|
)
|
|
|
|
async def write_settings(self, data: BountySettingsUpdate) -> BountySettingsRead:
|
|
settings = await self._settings()
|
|
if settings is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail="Instance settings not found",
|
|
)
|
|
if data.sync_interval is not None:
|
|
if data.sync_interval not in {i.value for i in SyncInterval}:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Unknown sync interval: {data.sync_interval}",
|
|
)
|
|
settings.bounty_sync_interval = data.sync_interval
|
|
if data.feed_interval is not None:
|
|
if data.feed_interval not in {i.value for i in SyncInterval}:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Unknown sync interval: {data.feed_interval}",
|
|
)
|
|
settings.bounty_feed_interval = data.feed_interval
|
|
stored = dict(settings.bounty_settings or {})
|
|
if data.notify is not None:
|
|
stored["notify"] = data.notify
|
|
if data.notify_events is not None:
|
|
unknown = set(data.notify_events) - set(NOTIFIABLE_EVENTS)
|
|
if unknown:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Unknown change kinds: {', '.join(sorted(unknown))}",
|
|
)
|
|
stored["notify_events"] = sorted(set(data.notify_events))
|
|
settings.bounty_settings = stored
|
|
await self.session.commit()
|
|
return await self.read_settings()
|
|
|
|
async def mark_events_seen(self) -> None:
|
|
settings = await self._settings()
|
|
if settings:
|
|
settings.bounty_events_seen_at = utc_now()
|
|
await self.session.commit()
|
|
|
|
async def platform_counts(self) -> list[PlatformCount]:
|
|
rows = await self.session.execute(
|
|
select(BountyProgram.platform, func.count(BountyProgram.id)).group_by(
|
|
BountyProgram.platform
|
|
)
|
|
)
|
|
counts = dict(rows.all())
|
|
return [
|
|
PlatformCount(
|
|
platform=spec.key,
|
|
label=spec.label,
|
|
source=spec.source,
|
|
programs=counts.get(spec.key, 0),
|
|
)
|
|
for spec in PLATFORMS
|
|
]
|
|
|
|
async def status(self, platform: str) -> BountyStatus:
|
|
username = await self._credentials_username()
|
|
settings = await self._settings()
|
|
interval = (settings.bounty_sync_interval if settings else None) or (
|
|
DEFAULT_SYNC_INTERVAL
|
|
)
|
|
synced_at = settings.bounty_synced_at if settings else None
|
|
seen_at = settings.bounty_events_seen_at if settings else None
|
|
hours = SYNC_INTERVAL_HOURS.get(interval)
|
|
next_sync = (
|
|
(synced_at + timedelta(hours=hours)) if (hours and synced_at) else None
|
|
)
|
|
unseen = await self.session.execute(
|
|
select(func.count(BountyEventRow.id)).where(
|
|
*([BountyEventRow.created_at > seen_at] if seen_at else []),
|
|
)
|
|
)
|
|
platforms = await self.platform_counts()
|
|
totals = await self.session.execute(
|
|
select(
|
|
func.count(BountyProgram.id),
|
|
func.count(BountyProgram.id).filter(
|
|
BountyProgram.program_state == ProgramState.PRIVATE.value
|
|
),
|
|
func.max(BountyProgram.synced_at),
|
|
)
|
|
)
|
|
total, private, last = totals.one()
|
|
return BountyStatus(
|
|
configured=bool(username),
|
|
platform=platform,
|
|
username=username,
|
|
programs=total or 0,
|
|
private_programs=private or 0,
|
|
last_synced_at=synced_at or last,
|
|
sync_interval=interval,
|
|
next_sync_at=next_sync,
|
|
unseen_events=unseen.scalar_one() or 0,
|
|
platforms=platforms,
|
|
feed_interval=(settings.bounty_feed_interval if settings else None)
|
|
or DEFAULT_FEED_INTERVAL,
|
|
feed_synced_at=settings.bounty_feed_synced_at if settings else None,
|
|
)
|
|
|
|
def _filtered(
|
|
self,
|
|
platform: str | None,
|
|
*,
|
|
platforms: list[str] | None,
|
|
sources: list[str] | None,
|
|
q: str | None,
|
|
state: str | None,
|
|
submission: str | None,
|
|
bounty: bool | None,
|
|
bookmarked: bool | None,
|
|
joined: bool | None,
|
|
scope: str | None,
|
|
) -> Select:
|
|
query = select(BountyProgram)
|
|
if platform:
|
|
query = query.where(BountyProgram.platform == platform)
|
|
if platforms:
|
|
query = query.where(col(BountyProgram.platform).in_(platforms))
|
|
if sources:
|
|
query = query.where(col(BountyProgram.source).in_(sources))
|
|
if q:
|
|
term = f"%{q.strip().lower()}%"
|
|
query = query.where(
|
|
or_(
|
|
func.lower(BountyProgram.name).like(term),
|
|
func.lower(BountyProgram.handle).like(term),
|
|
)
|
|
)
|
|
if state in {s.value for s in ProgramState}:
|
|
query = query.where(BountyProgram.program_state == state)
|
|
if submission in {s.value for s in SubmissionState}:
|
|
query = query.where(BountyProgram.submission_state == submission)
|
|
if bounty is not None:
|
|
query = query.where(BountyProgram.offers_bounties == bounty)
|
|
if bookmarked is not None:
|
|
query = query.where(BountyProgram.bookmarked == bookmarked)
|
|
if joined is not None:
|
|
query = query.where(BountyProgram.joined == joined)
|
|
if scope == "importable":
|
|
query = query.where(
|
|
select(BountyScope.id)
|
|
.where(
|
|
BountyScope.program_id == BountyProgram.id,
|
|
BountyScope.scope_state == ScopeState.IN_SCOPE.value,
|
|
col(BountyScope.target_value).is_not(None),
|
|
)
|
|
.exists()
|
|
)
|
|
elif scope == "none":
|
|
query = query.where(
|
|
~select(BountyScope.id)
|
|
.where(BountyScope.program_id == BountyProgram.id)
|
|
.exists()
|
|
)
|
|
return query
|
|
|
|
def list_query(self, platform: str | None, *, sort: str, **filters) -> Select:
|
|
query = self._filtered(platform, **filters)
|
|
column, default_dir = SORTS.get(sort or "age", SORTS["age"])
|
|
ordering = column.desc() if default_dir == "desc" else column.asc()
|
|
return query.order_by(ordering.nullslast(), col(BountyProgram.name).asc())
|
|
|
|
async def imported_for(
|
|
self, program_ids: list[UUID], project_id: UUID | None
|
|
) -> dict[UUID, int]:
|
|
"""How many of each program's assets the project already tracks."""
|
|
if not program_ids or not project_id:
|
|
return {}
|
|
rows = await self.session.execute(
|
|
select(
|
|
BountyScope.program_id,
|
|
func.count(func.distinct(BountyScope.target_value)),
|
|
)
|
|
.join(
|
|
Target,
|
|
(Target.target_value == BountyScope.target_value)
|
|
& (Target.project_id == project_id),
|
|
)
|
|
.where(col(BountyScope.program_id).in_(program_ids))
|
|
.group_by(BountyScope.program_id)
|
|
)
|
|
return {program_id: count or 0 for program_id, count in rows.all()}
|
|
|
|
async def counts_for(self, program_ids: list[UUID]) -> dict[UUID, dict[str, int]]:
|
|
"""Scope tallies per program, so a card can state what it holds before it opens."""
|
|
if not program_ids:
|
|
return {}
|
|
rows = await self.session.execute(
|
|
select(
|
|
BountyScope.program_id,
|
|
func.count(BountyScope.id),
|
|
func.count(BountyScope.id).filter(
|
|
BountyScope.scope_state == ScopeState.IN_SCOPE.value
|
|
),
|
|
func.count(BountyScope.id).filter(
|
|
col(BountyScope.target_value).is_not(None),
|
|
BountyScope.scope_state == ScopeState.IN_SCOPE.value,
|
|
),
|
|
)
|
|
.where(col(BountyScope.program_id).in_(program_ids))
|
|
.group_by(BountyScope.program_id)
|
|
)
|
|
out: dict[UUID, dict[str, int]] = {}
|
|
for program_id, total, in_scope, importable in rows.all():
|
|
out[program_id] = {
|
|
"in_scope_count": in_scope or 0,
|
|
"out_of_scope_count": (total or 0) - (in_scope or 0),
|
|
"importable_count": importable or 0,
|
|
}
|
|
return out
|
|
|
|
async def to_read(
|
|
self, programs: list[BountyProgram], project_id: UUID | None = None
|
|
) -> list[BountyProgramRead]:
|
|
ids = [p.id for p in programs]
|
|
counts = await self.counts_for(ids)
|
|
imported = await self.imported_for(ids, project_id)
|
|
return [
|
|
BountyProgramRead(
|
|
**_without_counts(p),
|
|
**counts.get(p.id, EMPTY_COUNTS),
|
|
imported_count=imported.get(p.id, 0),
|
|
)
|
|
for p in programs
|
|
]
|
|
|
|
async def get_program(self, platform: str, handle: str) -> BountyProgram:
|
|
row = await self.session.execute(
|
|
select(BountyProgram).where(
|
|
BountyProgram.platform == platform, BountyProgram.handle == handle
|
|
)
|
|
)
|
|
program = row.scalar_one_or_none()
|
|
if not program:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND, detail="Program not found"
|
|
)
|
|
return program
|
|
|
|
async def detail(
|
|
self,
|
|
platform: str,
|
|
handle: str,
|
|
*,
|
|
project_id: UUID | None,
|
|
scope: str | None = None,
|
|
asset_type: str | None = None,
|
|
) -> BountyProgramDetail:
|
|
program = await self.get_program(platform, handle)
|
|
query = select(BountyScope).where(BountyScope.program_id == program.id)
|
|
if scope in {s.value for s in ScopeState}:
|
|
query = query.where(BountyScope.scope_state == scope)
|
|
if asset_type:
|
|
query = query.where(BountyScope.asset_type == asset_type.upper())
|
|
rows = (await self.session.execute(query)).scalars().all()
|
|
|
|
existing: set[str] = set()
|
|
if project_id:
|
|
values = [s.target_value for s in rows if s.target_value]
|
|
if values:
|
|
found = await self.session.execute(
|
|
select(Target.target_value).where(
|
|
Target.project_id == project_id,
|
|
col(Target.target_value).in_(values),
|
|
)
|
|
)
|
|
existing = set(found.scalars().all())
|
|
|
|
scopes = [self._scope_read(s, existing) for s in rows]
|
|
scopes.sort(
|
|
key=lambda s: (
|
|
s.scope_state != ScopeState.IN_SCOPE.value,
|
|
not s.importable,
|
|
s.asset_identifier,
|
|
)
|
|
)
|
|
counts = await self.counts_for([program.id])
|
|
unreachable: dict[str, int] = {}
|
|
for s in rows:
|
|
if s.target_value:
|
|
continue
|
|
label = asset_type_spec(s.asset_type).label
|
|
unreachable[label] = unreachable.get(label, 0) + 1
|
|
return BountyProgramDetail(
|
|
**_without_counts(program),
|
|
**counts.get(program.id, EMPTY_COUNTS),
|
|
imported_count=len(existing),
|
|
scopes=scopes,
|
|
unreachable=unreachable,
|
|
)
|
|
|
|
@staticmethod
|
|
def _scope_read(scope: BountyScope, existing: set[str]) -> BountyScopeRead:
|
|
spec = asset_type_spec(scope.asset_type)
|
|
return BountyScopeRead(
|
|
id=scope.id,
|
|
asset_type=scope.asset_type,
|
|
asset_type_label=spec.label,
|
|
asset_group=spec.group.value,
|
|
icon=spec.icon,
|
|
asset_identifier=scope.asset_identifier,
|
|
scope_state=scope.scope_state,
|
|
eligible_for_bounty=scope.eligible_for_bounty,
|
|
max_severity=scope.max_severity,
|
|
instruction=scope.instruction,
|
|
target_value=scope.target_value,
|
|
target_type=scope.target_type.value if scope.target_type else None,
|
|
importable=bool(scope.target_value),
|
|
already_target=bool(scope.target_value and scope.target_value in existing),
|
|
)
|
|
|
|
async def import_scopes(
|
|
self, platform: str, handle: str, request: BountyImportRequest, user_id
|
|
) -> BountyImportResult:
|
|
"""Add a program's scannable assets to a project as targets."""
|
|
program = await self.get_program(platform, handle)
|
|
query = select(BountyScope).where(
|
|
BountyScope.program_id == program.id,
|
|
col(BountyScope.target_value).is_not(None),
|
|
)
|
|
if request.scope_ids is not None:
|
|
query = query.where(col(BountyScope.id).in_(request.scope_ids))
|
|
if not request.include_out_of_scope:
|
|
query = query.where(BountyScope.scope_state == ScopeState.IN_SCOPE.value)
|
|
rows = (await self.session.execute(query)).scalars().all()
|
|
|
|
# what the user actually selected but reNgine cannot reach
|
|
unreachable = select(BountyScope).where(
|
|
BountyScope.program_id == program.id,
|
|
col(BountyScope.target_value).is_(None),
|
|
)
|
|
if request.scope_ids is not None:
|
|
unreachable = unreachable.where(col(BountyScope.id).in_(request.scope_ids))
|
|
if not request.include_out_of_scope:
|
|
unreachable = unreachable.where(
|
|
BountyScope.scope_state == ScopeState.IN_SCOPE.value
|
|
)
|
|
skipped = sorted(
|
|
{
|
|
s.asset_identifier
|
|
for s in (await self.session.execute(unreachable)).scalars()
|
|
}
|
|
)
|
|
values = sorted({s.target_value for s in rows if s.target_value})
|
|
if not values:
|
|
return BountyImportResult(created=[], existing=[], skipped=skipped)
|
|
|
|
before = await self.session.execute(
|
|
select(Target.target_value).where(
|
|
Target.project_id == request.project_id,
|
|
col(Target.target_value).in_(values),
|
|
)
|
|
)
|
|
already = set(before.scalars().all())
|
|
targets = TargetService(self.session)
|
|
rows = await targets.ensure_targets(values, request.project_id, user_id)
|
|
|
|
organization = None
|
|
if request.group_by_program:
|
|
organization = await self._organization_for(
|
|
program, request.project_id, user_id, request.organization_name
|
|
)
|
|
await self._attach(rows, organization)
|
|
tags = await self._tags_for(request, program, user_id)
|
|
await self._attach_tags(rows, tags)
|
|
await self.session.commit()
|
|
return BountyImportResult(
|
|
created=[v for v in values if v not in already],
|
|
existing=sorted(already),
|
|
skipped=skipped,
|
|
organization=(
|
|
OrganizationSummary(
|
|
id=organization.id, name=organization.name, slug=organization.slug
|
|
)
|
|
if organization
|
|
else None
|
|
),
|
|
tags=[
|
|
TagSummary(id=t.id, name=t.name, slug=t.slug, color=t.color)
|
|
for t in tags
|
|
],
|
|
)
|
|
|
|
async def _tags_for(
|
|
self, request: BountyImportRequest, program: BountyProgram, user_id
|
|
) -> list[Tag]:
|
|
"""The project's tags for these names, created on first use."""
|
|
spec = PLATFORMS_BY_KEY.get(program.platform)
|
|
wanted: dict[str, str] = {}
|
|
for raw in request.tags[:MAX_TAGS_PER_IMPORT]:
|
|
candidate = (raw or "").strip()[:MAX_TAG_NAME]
|
|
if not candidate:
|
|
continue
|
|
wanted.setdefault(candidate.lower(), DEFAULT_TAG_COLOR)
|
|
if spec and spec.tag in wanted:
|
|
wanted[spec.tag] = spec.tag_color
|
|
if not wanted:
|
|
return []
|
|
|
|
found = await self.session.execute(
|
|
select(Tag).where(
|
|
Tag.project_id == request.project_id, col(Tag.name).in_(list(wanted))
|
|
)
|
|
)
|
|
tags = {t.name: t for t in found.scalars().all()}
|
|
for name, color in wanted.items():
|
|
if name in tags:
|
|
continue
|
|
tag = Tag(
|
|
name=name,
|
|
color=color,
|
|
project_id=request.project_id,
|
|
created_by=user_id,
|
|
)
|
|
try:
|
|
async with self.session.begin_nested():
|
|
await add_with_unique_slug(
|
|
self.session, tag, name, project_id=request.project_id
|
|
)
|
|
except IntegrityError:
|
|
again = await self.session.execute(
|
|
select(Tag).where(
|
|
Tag.project_id == request.project_id, Tag.name == name
|
|
)
|
|
)
|
|
tags[name] = again.scalar_one()
|
|
continue
|
|
tags[name] = tag
|
|
return [tags[name] for name in wanted if name in tags]
|
|
|
|
async def _attach_tags(self, targets: list[Target], tags: list[Tag]) -> None:
|
|
"""A tag already on a target must not fail the import."""
|
|
if not targets or not tags:
|
|
return
|
|
await self.session.execute(
|
|
pg_insert(TargetTag)
|
|
.values(
|
|
[{"target_id": t.id, "tag_id": tag.id} for t in targets for tag in tags]
|
|
)
|
|
.on_conflict_do_nothing()
|
|
)
|
|
|
|
async def _organization_for(
|
|
self,
|
|
program: BountyProgram,
|
|
project_id: UUID,
|
|
user_id,
|
|
override: str | None = None,
|
|
) -> Organization:
|
|
"""The project's organization for this program, created on first import."""
|
|
chosen = ((override or "").strip() or program.name)[:MAX_ORG_NAME]
|
|
try:
|
|
name = clean_name(chosen, max_len=MAX_ORG_NAME).lower()
|
|
except ValueError as exc:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)
|
|
) from exc
|
|
found = await self.session.execute(
|
|
select(Organization).where(
|
|
Organization.project_id == project_id, Organization.name == name
|
|
)
|
|
)
|
|
existing = found.scalar_one_or_none()
|
|
if existing:
|
|
return existing
|
|
organization = Organization(
|
|
name=name,
|
|
description=f"{_platform_label(program.platform)} program @{program.handle}"[
|
|
:MAX_ORG_DESCRIPTION
|
|
],
|
|
project_id=project_id,
|
|
created_by=user_id,
|
|
)
|
|
try:
|
|
async with self.session.begin_nested():
|
|
await add_with_unique_slug(
|
|
self.session, organization, name, project_id=project_id
|
|
)
|
|
except IntegrityError:
|
|
again = await self.session.execute(
|
|
select(Organization).where(
|
|
Organization.project_id == project_id, Organization.name == name
|
|
)
|
|
)
|
|
return again.scalar_one()
|
|
return organization
|
|
|
|
async def _attach(self, targets: list[Target], organization: Organization) -> None:
|
|
"""A target already under the organization must not fail the import."""
|
|
if not targets:
|
|
return
|
|
await self.session.execute(
|
|
pg_insert(TargetOrganization)
|
|
.values(
|
|
[
|
|
{"target_id": t.id, "organization_id": organization.id}
|
|
for t in targets
|
|
]
|
|
)
|
|
.on_conflict_do_nothing()
|
|
)
|
|
|
|
async def _all_scopes(self, program_id: UUID) -> list[BountyScope]:
|
|
rows = await self.session.execute(
|
|
select(BountyScope).where(BountyScope.program_id == program_id)
|
|
)
|
|
return list(rows.scalars().all())
|
|
|
|
@staticmethod
|
|
def require_platform(platform: str) -> str:
|
|
if platform not in PLATFORMS_BY_KEY:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Unknown platform: {platform}",
|
|
)
|
|
return platform
|