Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.alert import Alert
|
||||
from app.models.enums import SeverityLevel
|
||||
|
||||
|
||||
class AlertRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get(self, alert_id: uuid.UUID) -> Alert | None:
|
||||
return await self.db.get(Alert, alert_id)
|
||||
|
||||
async def get_for_user(self, alert_id: uuid.UUID, user_id: uuid.UUID) -> Alert | None:
|
||||
result = await self.db.execute(
|
||||
select(Alert).where(Alert.id == alert_id, Alert.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_for_user(
|
||||
self,
|
||||
user_id: uuid.UUID,
|
||||
*,
|
||||
company_id: uuid.UUID | None = None,
|
||||
severity: SeverityLevel | None = None,
|
||||
read: bool | None = None,
|
||||
resolved: bool | None = None,
|
||||
since: datetime | None = None,
|
||||
limit: int = 200,
|
||||
) -> list[Alert]:
|
||||
stmt = select(Alert).where(Alert.user_id == user_id)
|
||||
if company_id is not None:
|
||||
stmt = stmt.where(Alert.company_id == company_id)
|
||||
if severity is not None:
|
||||
stmt = stmt.where(Alert.severity == severity)
|
||||
if read is not None:
|
||||
stmt = stmt.where(Alert.read == read)
|
||||
if resolved is not None:
|
||||
stmt = stmt.where(Alert.resolved == resolved)
|
||||
if since is not None:
|
||||
stmt = stmt.where(Alert.created_at >= since)
|
||||
stmt = stmt.order_by(Alert.created_at.desc()).limit(limit)
|
||||
result = await self.db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, alert: Alert) -> Alert:
|
||||
self.db.add(alert)
|
||||
await self.db.flush()
|
||||
return alert
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.company_enrichment import CompanyEnrichment
|
||||
from app.models.enums import EnrichmentStatus
|
||||
|
||||
|
||||
class CompanyEnrichmentRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get_for_company(self, company_id: uuid.UUID) -> CompanyEnrichment | None:
|
||||
result = await self.db.execute(
|
||||
select(CompanyEnrichment).where(CompanyEnrichment.company_id == company_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def upsert(
|
||||
self,
|
||||
company_id: uuid.UUID,
|
||||
*,
|
||||
status: EnrichmentStatus,
|
||||
data: dict[str, Any],
|
||||
errors: dict[str, str],
|
||||
credits_spent: int | None,
|
||||
fetched_at: datetime,
|
||||
) -> CompanyEnrichment:
|
||||
"""One row per company (unique FK) - the second-ever call for a
|
||||
company would only happen via a manual re-run, never automatically
|
||||
(enrichment fires once, at onboarding - see enrichment_service)."""
|
||||
existing = await self.get_for_company(company_id)
|
||||
if existing is not None:
|
||||
existing.status = status
|
||||
existing.data = data
|
||||
existing.errors = errors
|
||||
existing.credits_spent = credits_spent
|
||||
existing.fetched_at = fetched_at
|
||||
await self.db.flush()
|
||||
return existing
|
||||
|
||||
enrichment = CompanyEnrichment(
|
||||
company_id=company_id,
|
||||
status=status,
|
||||
data=data,
|
||||
errors=errors,
|
||||
credits_spent=credits_spent,
|
||||
fetched_at=fetched_at,
|
||||
)
|
||||
self.db.add(enrichment)
|
||||
await self.db.flush()
|
||||
return enrichment
|
||||
@@ -0,0 +1,173 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.company import Company, CompanyAlias, Competitor
|
||||
from app.models.monitor_configuration import MonitorConfiguration
|
||||
|
||||
|
||||
def _with_relations(stmt):
|
||||
return stmt.options(
|
||||
selectinload(Company.aliases),
|
||||
selectinload(Company.competitors),
|
||||
selectinload(Company.monitor_configuration),
|
||||
selectinload(Company.enrichment),
|
||||
# Loaded eagerly so ORM-level cascade-delete (SQLite doesn't enforce
|
||||
# FK ON DELETE CASCADE without a pragma this app doesn't set) can
|
||||
# actually see the children to remove when a Company is deleted.
|
||||
selectinload(Company.notification_links),
|
||||
)
|
||||
|
||||
|
||||
class CompanyRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get_for_user(self, company_id: uuid.UUID, user_id: uuid.UUID) -> Company | None:
|
||||
stmt = _with_relations(
|
||||
select(Company).where(Company.id == company_id, Company.user_id == user_id)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_by_id(self, company_id: uuid.UUID) -> Company | None:
|
||||
"""Unscoped lookup for trusted internal callers (Celery tasks) that
|
||||
already have the company_id from a source they control - not for
|
||||
anything reachable from an HTTP request without an ownership check."""
|
||||
stmt = _with_relations(select(Company).where(Company.id == company_id))
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_for_user(self, user_id: uuid.UUID) -> list[Company]:
|
||||
stmt = _with_relations(
|
||||
select(Company).where(Company.user_id == user_id).order_by(Company.created_at.desc())
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def count_for_user(self, user_id: uuid.UUID) -> int:
|
||||
result = await self.db.execute(
|
||||
select(func.count()).select_from(Company).where(Company.user_id == user_id)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def slug_exists_for_user(self, user_id: uuid.UUID, slug: str) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(func.count())
|
||||
.select_from(Company)
|
||||
.where(Company.user_id == user_id, Company.slug == slug)
|
||||
)
|
||||
return int(result.scalar_one()) > 0
|
||||
|
||||
async def name_exists_for_user(self, user_id: uuid.UUID, name: str) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(func.count())
|
||||
.select_from(Company)
|
||||
.where(Company.user_id == user_id, func.lower(Company.name) == name.lower())
|
||||
)
|
||||
return int(result.scalar_one()) > 0
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
user_id: uuid.UUID,
|
||||
name: str,
|
||||
slug: str,
|
||||
official_website: str | None,
|
||||
description: str | None,
|
||||
monitoring_focus: str | None,
|
||||
industry: str | None,
|
||||
country: str | None,
|
||||
region: str | None,
|
||||
headquarters: str | None = None,
|
||||
public_identifiers: dict[str, str] | None = None,
|
||||
alias_names: list[str],
|
||||
competitor_names: list[str],
|
||||
) -> Company:
|
||||
company = Company(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
slug=slug,
|
||||
official_website=official_website,
|
||||
description=description,
|
||||
monitoring_focus=monitoring_focus,
|
||||
industry=industry,
|
||||
country=country,
|
||||
region=region,
|
||||
headquarters=headquarters,
|
||||
public_identifiers=public_identifiers or {},
|
||||
)
|
||||
company.aliases = [CompanyAlias(alias=a) for a in alias_names if a.strip()]
|
||||
company.competitors = [Competitor(name=c) for c in competitor_names if c.strip()]
|
||||
self.db.add(company)
|
||||
await self.db.flush()
|
||||
return company
|
||||
|
||||
async def delete(self, company: Company) -> None:
|
||||
await self.db.delete(company)
|
||||
await self.db.flush()
|
||||
|
||||
async def replace_aliases(self, company: Company, alias_names: list[str]) -> None:
|
||||
company.aliases = [CompanyAlias(alias=a) for a in alias_names if a.strip()]
|
||||
|
||||
async def replace_competitors(self, company: Company, competitor_names: list[str]) -> None:
|
||||
company.competitors = [Competitor(name=c) for c in competitor_names if c.strip()]
|
||||
|
||||
|
||||
class MonitorConfigurationRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def create_default(
|
||||
self,
|
||||
*,
|
||||
company_id: uuid.UUID,
|
||||
frequency_type,
|
||||
interval_minutes: int | None,
|
||||
cron_expression: str | None,
|
||||
timezone: str,
|
||||
severity_threshold,
|
||||
next_run,
|
||||
) -> MonitorConfiguration:
|
||||
config = MonitorConfiguration(
|
||||
company_id=company_id,
|
||||
frequency_type=frequency_type,
|
||||
interval_minutes=interval_minutes,
|
||||
cron_expression=cron_expression,
|
||||
timezone=timezone,
|
||||
severity_threshold=severity_threshold,
|
||||
next_run=next_run,
|
||||
)
|
||||
self.db.add(config)
|
||||
await self.db.flush()
|
||||
return config
|
||||
|
||||
async def list_due(self, now: datetime) -> list[MonitorConfiguration]:
|
||||
"""Enabled schedules whose next_run has arrived - what Celery Beat's
|
||||
sync_schedules task polls instead of requiring a static per-company
|
||||
beat_schedule entry (see ARCHITECTURE.md)."""
|
||||
result = await self.db.execute(
|
||||
select(MonitorConfiguration).where(
|
||||
MonitorConfiguration.enabled.is_(True),
|
||||
MonitorConfiguration.next_run.is_not(None),
|
||||
MonitorConfiguration.next_run <= now,
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_enabled(self) -> list[MonitorConfiguration]:
|
||||
"""Every enabled schedule regardless of next_run - since a company
|
||||
can now also become due purely via a per-source frequency override
|
||||
even when its own default next_run isn't due yet (see
|
||||
SourceRepository.list_due_for_company), the scheduler needs this
|
||||
broader set to check, not just the ones already due on the
|
||||
company-level clock."""
|
||||
result = await self.db.execute(
|
||||
select(MonitorConfiguration).where(MonitorConfiguration.enabled.is_(True))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.detected_change import DetectedChange
|
||||
from app.models.enums import ChangeType
|
||||
|
||||
|
||||
class DetectedChangeRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get_recent_for_source_and_type(
|
||||
self, source_id: uuid.UUID, change_type: ChangeType, since: datetime
|
||||
) -> DetectedChange | None:
|
||||
"""Most recent DetectedChange of this type for this source within
|
||||
the cooldown window - used both to flag repeats (lower significance)
|
||||
and to suppress exact duplicates (see change_detection_service.py)."""
|
||||
result = await self.db.execute(
|
||||
select(DetectedChange)
|
||||
.where(
|
||||
DetectedChange.source_id == source_id,
|
||||
DetectedChange.change_type == change_type,
|
||||
DetectedChange.created_at >= since,
|
||||
)
|
||||
.order_by(DetectedChange.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, **kwargs: Any) -> DetectedChange:
|
||||
change = DetectedChange(**kwargs)
|
||||
self.db.add(change)
|
||||
await self.db.flush()
|
||||
return change
|
||||
|
||||
async def list_for_company(
|
||||
self, company_id: uuid.UUID, limit: int = 100
|
||||
) -> list[DetectedChange]:
|
||||
result = await self.db.execute(
|
||||
select(DetectedChange)
|
||||
.where(DetectedChange.company_id == company_id)
|
||||
.order_by(DetectedChange.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.base import ensure_aware_utc
|
||||
from app.models.email_code import EmailCode
|
||||
from app.models.enums import EmailCodePurpose
|
||||
|
||||
|
||||
class EmailCodeRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def create(
|
||||
self, *, user_id: uuid.UUID, purpose: EmailCodePurpose, code_hash: str, expires_at: datetime
|
||||
) -> EmailCode:
|
||||
record = EmailCode(
|
||||
user_id=user_id, purpose=purpose, code_hash=code_hash, expires_at=expires_at
|
||||
)
|
||||
self.db.add(record)
|
||||
await self.db.flush()
|
||||
return record
|
||||
|
||||
async def get_latest_valid(
|
||||
self, user_id: uuid.UUID, purpose: EmailCodePurpose, code_hash: str
|
||||
) -> EmailCode | None:
|
||||
"""Most recent unused, unexpired code for this user/purpose whose
|
||||
hash matches. `invalidate_unused` is what actually guarantees only
|
||||
the most recently issued code can ever satisfy this - this method
|
||||
alone doesn't enforce that."""
|
||||
result = await self.db.execute(
|
||||
select(EmailCode)
|
||||
.where(
|
||||
EmailCode.user_id == user_id,
|
||||
EmailCode.purpose == purpose,
|
||||
EmailCode.code_hash == code_hash,
|
||||
EmailCode.used_at.is_(None),
|
||||
)
|
||||
.order_by(EmailCode.created_at.desc())
|
||||
)
|
||||
for record in result.scalars().all():
|
||||
if ensure_aware_utc(record.expires_at) > datetime.now(UTC):
|
||||
return record
|
||||
return None
|
||||
|
||||
async def mark_used(self, record: EmailCode) -> None:
|
||||
record.used_at = datetime.now(UTC)
|
||||
await self.db.flush()
|
||||
|
||||
async def invalidate_unused(self, user_id: uuid.UUID, purpose: EmailCodePurpose) -> None:
|
||||
"""Called right before issuing a fresh code - a resend must fully
|
||||
supersede every prior unused code for this purpose, not just make
|
||||
them harder to guess. Without this, an old code (e.g. still sitting
|
||||
in an old email) stays valid until it naturally expires, even after
|
||||
the user has explicitly asked for a new one."""
|
||||
await self.db.execute(
|
||||
update(EmailCode)
|
||||
.where(
|
||||
EmailCode.user_id == user_id,
|
||||
EmailCode.purpose == purpose,
|
||||
EmailCode.used_at.is_(None),
|
||||
)
|
||||
.values(used_at=datetime.now(UTC))
|
||||
)
|
||||
await self.db.flush()
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import ThrottleAction
|
||||
from app.models.ip_ban import IpBan
|
||||
from app.models.ip_throttle_state import IpThrottleState
|
||||
|
||||
|
||||
class IpThrottleRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get_state(self, ip_address: str, action: ThrottleAction) -> IpThrottleState | None:
|
||||
result = await self.db.execute(
|
||||
select(IpThrottleState).where(
|
||||
IpThrottleState.ip_address == ip_address, IpThrottleState.action == action
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_or_create_state(self, ip_address: str, action: ThrottleAction) -> IpThrottleState:
|
||||
state = await self.get_state(ip_address, action)
|
||||
if state is not None:
|
||||
return state
|
||||
state = IpThrottleState(ip_address=ip_address, action=action)
|
||||
self.db.add(state)
|
||||
await self.db.flush()
|
||||
return state
|
||||
|
||||
async def get_ban(self, ip_address: str) -> IpBan | None:
|
||||
result = await self.db.execute(select(IpBan).where(IpBan.ip_address == ip_address))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create_ban(self, ip_address: str, reason: str, banned_at: datetime) -> IpBan:
|
||||
ban = IpBan(ip_address=ip_address, reason=reason, banned_at=banned_at)
|
||||
self.db.add(ban)
|
||||
await self.db.flush()
|
||||
return ban
|
||||
|
||||
async def list_bans(self) -> list[IpBan]:
|
||||
result = await self.db.execute(select(IpBan).order_by(IpBan.banned_at.desc()))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def clear_ban_and_state(self, ip_address: str) -> bool:
|
||||
"""Full pardon for an admin-approved unban - removes the ban record
|
||||
and resets every throttle-state row for this IP to a clean slate
|
||||
(not just lifting the terminal ban while leaving them one offense
|
||||
from another)."""
|
||||
ban_result = await self.db.execute(delete(IpBan).where(IpBan.ip_address == ip_address))
|
||||
result = await self.db.execute(
|
||||
select(IpThrottleState).where(IpThrottleState.ip_address == ip_address)
|
||||
)
|
||||
states = result.scalars().all()
|
||||
for state in states:
|
||||
state.attempt_count = 0
|
||||
state.next_allowed_at = None
|
||||
state.timeout_until = None
|
||||
state.offense_count = 0
|
||||
await self.db.flush()
|
||||
return ban_result.rowcount > 0 or len(states) > 0
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import MonitoringRunStatus, MonitoringRunTrigger
|
||||
from app.models.monitoring_run import MonitoringRun
|
||||
|
||||
_ACTIVE_STATUSES = (MonitoringRunStatus.QUEUED, MonitoringRunStatus.RUNNING)
|
||||
|
||||
|
||||
class MonitoringRunRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get(self, run_id: uuid.UUID) -> MonitoringRun | None:
|
||||
return await self.db.get(MonitoringRun, run_id)
|
||||
|
||||
async def get_for_company(
|
||||
self, run_id: uuid.UUID, company_id: uuid.UUID
|
||||
) -> MonitoringRun | None:
|
||||
result = await self.db.execute(
|
||||
select(MonitoringRun).where(
|
||||
MonitoringRun.id == run_id, MonitoringRun.company_id == company_id
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_for_company(self, company_id: uuid.UUID, limit: int = 50) -> list[MonitoringRun]:
|
||||
result = await self.db.execute(
|
||||
select(MonitoringRun)
|
||||
.where(MonitoringRun.company_id == company_id)
|
||||
.order_by(MonitoringRun.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_active_for_company(self, company_id: uuid.UUID) -> MonitoringRun | None:
|
||||
"""The queued/running run for this company, if any - used both to
|
||||
make "run now" idempotent and to stop the scheduler from double
|
||||
enqueuing a company that's still mid-run."""
|
||||
result = await self.db.execute(
|
||||
select(MonitoringRun)
|
||||
.where(
|
||||
MonitoringRun.company_id == company_id,
|
||||
MonitoringRun.status.in_(_ACTIVE_STATUSES),
|
||||
)
|
||||
.order_by(MonitoringRun.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(
|
||||
self, *, company_id: uuid.UUID, trigger_type: MonitoringRunTrigger
|
||||
) -> MonitoringRun:
|
||||
run = MonitoringRun(
|
||||
company_id=company_id,
|
||||
trigger_type=trigger_type,
|
||||
status=MonitoringRunStatus.QUEUED,
|
||||
)
|
||||
self.db.add(run)
|
||||
await self.db.flush()
|
||||
return run
|
||||
|
||||
async def set_worker_task_id(self, run: MonitoringRun, task_id: str | None) -> None:
|
||||
run.worker_task_id = task_id
|
||||
await self.db.flush()
|
||||
|
||||
async def mark_running(self, run: MonitoringRun) -> None:
|
||||
run.status = MonitoringRunStatus.RUNNING
|
||||
run.started_at = datetime.now(UTC)
|
||||
await self.db.flush()
|
||||
|
||||
async def update_progress(
|
||||
self,
|
||||
run: MonitoringRun,
|
||||
*,
|
||||
sources_attempted: int,
|
||||
sources_successful: int,
|
||||
sources_failed: int,
|
||||
items_collected: int,
|
||||
changes_detected: int | None = None,
|
||||
) -> None:
|
||||
run.sources_attempted = sources_attempted
|
||||
run.sources_successful = sources_successful
|
||||
run.sources_failed = sources_failed
|
||||
run.items_collected = items_collected
|
||||
if changes_detected is not None:
|
||||
run.changes_detected = changes_detected
|
||||
await self.db.commit()
|
||||
|
||||
async def mark_finished(
|
||||
self, run: MonitoringRun, *, status: MonitoringRunStatus, error_summary: str | None
|
||||
) -> None:
|
||||
run.status = status
|
||||
run.error_summary = error_summary
|
||||
run.completed_at = datetime.now(UTC)
|
||||
await self.db.commit()
|
||||
|
||||
async def count_for_company(self, company_id: uuid.UUID) -> int:
|
||||
result = await self.db.execute(
|
||||
select(func.count())
|
||||
.select_from(MonitoringRun)
|
||||
.where(MonitoringRun.company_id == company_id)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def count_manual_since(self, company_id: uuid.UUID, since: datetime) -> int:
|
||||
"""Manual (user-triggered) runs for this company since `since` -
|
||||
what enforces MAX_MANUAL_RUNS_PER_DAY (scheduled runs don't count
|
||||
against it)."""
|
||||
result = await self.db.execute(
|
||||
select(func.count())
|
||||
.select_from(MonitoringRun)
|
||||
.where(
|
||||
MonitoringRun.company_id == company_id,
|
||||
MonitoringRun.trigger_type == MonitoringRunTrigger.MANUAL,
|
||||
MonitoringRun.created_at >= since,
|
||||
)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.notification_delivery import NotificationDelivery
|
||||
|
||||
|
||||
class NotificationDeliveryRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def create(self, delivery: NotificationDelivery) -> NotificationDelivery:
|
||||
self.db.add(delivery)
|
||||
await self.db.flush()
|
||||
return delivery
|
||||
|
||||
async def list_for_alert(self, alert_id: uuid.UUID) -> list[NotificationDelivery]:
|
||||
result = await self.db.execute(
|
||||
select(NotificationDelivery).where(NotificationDelivery.alert_id == alert_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,149 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.enums import NotificationType, SeverityLevel
|
||||
from app.models.notification_destination import (
|
||||
NotificationDestination,
|
||||
NotificationDestinationCompany,
|
||||
)
|
||||
|
||||
|
||||
def _with_companies(stmt):
|
||||
return stmt.options(
|
||||
selectinload(NotificationDestination.company_links).selectinload(
|
||||
NotificationDestinationCompany.company
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class NotificationDestinationRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def list_for_user(self, user_id: uuid.UUID) -> list[NotificationDestination]:
|
||||
result = await self.db.execute(
|
||||
_with_companies(
|
||||
select(NotificationDestination)
|
||||
.where(NotificationDestination.user_id == user_id)
|
||||
.order_by(NotificationDestination.created_at.desc())
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_for_company(self, company_id: uuid.UUID) -> list[NotificationDestination]:
|
||||
"""Only destinations actually linked to this company - what alert
|
||||
dispatch notifies, as opposed to list_for_user's "everything this
|
||||
user owns" (used by the Settings page)."""
|
||||
result = await self.db.execute(
|
||||
select(NotificationDestination)
|
||||
.join(
|
||||
NotificationDestinationCompany,
|
||||
NotificationDestinationCompany.destination_id == NotificationDestination.id,
|
||||
)
|
||||
.where(NotificationDestinationCompany.company_id == company_id)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_for_user(
|
||||
self, destination_id: uuid.UUID, user_id: uuid.UUID
|
||||
) -> NotificationDestination | None:
|
||||
result = await self.db.execute(
|
||||
_with_companies(
|
||||
select(NotificationDestination).where(
|
||||
NotificationDestination.id == destination_id,
|
||||
NotificationDestination.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def find_by_value(
|
||||
self, user_id: uuid.UUID, type: NotificationType, destination_value: str
|
||||
) -> NotificationDestination | None:
|
||||
"""Case-insensitive for email (RFC-technically case-sensitive local
|
||||
parts exist, but no real provider treats them that way and users
|
||||
retype casing inconsistently), exact for everything else."""
|
||||
normalized = destination_value.strip()
|
||||
stmt = select(NotificationDestination).where(
|
||||
NotificationDestination.user_id == user_id, NotificationDestination.type == type
|
||||
)
|
||||
if type == NotificationType.EMAIL:
|
||||
stmt = stmt.where(
|
||||
func.lower(NotificationDestination.destination_value) == normalized.lower()
|
||||
)
|
||||
else:
|
||||
stmt = stmt.where(NotificationDestination.destination_value == normalized)
|
||||
result = await self.db.execute(_with_companies(stmt))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
user_id: uuid.UUID,
|
||||
type: NotificationType,
|
||||
destination_value: str,
|
||||
minimum_severity: SeverityLevel,
|
||||
enabled: bool,
|
||||
) -> NotificationDestination:
|
||||
destination = NotificationDestination(
|
||||
user_id=user_id,
|
||||
type=type,
|
||||
destination_value=destination_value,
|
||||
minimum_severity=minimum_severity,
|
||||
enabled=enabled,
|
||||
)
|
||||
self.db.add(destination)
|
||||
await self.db.flush()
|
||||
return destination
|
||||
|
||||
async def link_company(self, destination_id: uuid.UUID, company_id: uuid.UUID) -> None:
|
||||
exists = await self.db.execute(
|
||||
select(func.count())
|
||||
.select_from(NotificationDestinationCompany)
|
||||
.where(
|
||||
NotificationDestinationCompany.destination_id == destination_id,
|
||||
NotificationDestinationCompany.company_id == company_id,
|
||||
)
|
||||
)
|
||||
if int(exists.scalar_one()) > 0:
|
||||
return
|
||||
self.db.add(
|
||||
NotificationDestinationCompany(destination_id=destination_id, company_id=company_id)
|
||||
)
|
||||
await self.db.flush()
|
||||
|
||||
async def unlink_company(self, destination_id: uuid.UUID, company_id: uuid.UUID) -> None:
|
||||
await self.db.execute(
|
||||
delete(NotificationDestinationCompany).where(
|
||||
NotificationDestinationCompany.destination_id == destination_id,
|
||||
NotificationDestinationCompany.company_id == company_id,
|
||||
)
|
||||
)
|
||||
await self.db.flush()
|
||||
|
||||
async def company_link_count(self, destination_id: uuid.UUID) -> int:
|
||||
result = await self.db.execute(
|
||||
select(func.count())
|
||||
.select_from(NotificationDestinationCompany)
|
||||
.where(NotificationDestinationCompany.destination_id == destination_id)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def delete_orphaned_for_user(self, user_id: uuid.UUID) -> None:
|
||||
"""Deletes any of this user's destinations that ended up linked to
|
||||
zero companies - called after a company delete, since that cascades
|
||||
the join rows for it but leaves the destination row itself behind
|
||||
even when it was the destination's only remaining link."""
|
||||
destinations = await self.list_for_user(user_id)
|
||||
for destination in destinations:
|
||||
if await self.company_link_count(destination.id) == 0:
|
||||
await self.delete(destination)
|
||||
|
||||
async def delete(self, destination: NotificationDestination) -> None:
|
||||
await self.db.delete(destination)
|
||||
await self.db.flush()
|
||||
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.password_history import PasswordHistoryEntry
|
||||
|
||||
|
||||
class PasswordHistoryRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def list_hashes_for_user(self, user_id: uuid.UUID) -> list[str]:
|
||||
result = await self.db.execute(
|
||||
select(PasswordHistoryEntry.password_hash).where(
|
||||
PasswordHistoryEntry.user_id == user_id
|
||||
)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def add(self, *, user_id: uuid.UUID, password_hash: str) -> None:
|
||||
self.db.add(PasswordHistoryEntry(user_id=user_id, password_hash=password_hash))
|
||||
await self.db.flush()
|
||||
@@ -0,0 +1,55 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.base import ensure_aware_utc
|
||||
from app.models.refresh_token import RefreshToken
|
||||
|
||||
|
||||
class RefreshTokenRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def create(
|
||||
self, *, user_id: uuid.UUID, token_hash: str, expires_at: datetime
|
||||
) -> RefreshToken:
|
||||
record = RefreshToken(user_id=user_id, token_hash=token_hash, expires_at=expires_at)
|
||||
self.db.add(record)
|
||||
await self.db.flush()
|
||||
return record
|
||||
|
||||
async def get_valid_by_hash(self, token_hash: str) -> RefreshToken | None:
|
||||
result = await self.db.execute(
|
||||
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
return None
|
||||
if record.revoked_at is not None:
|
||||
return None
|
||||
if ensure_aware_utc(record.expires_at) < datetime.now(UTC):
|
||||
return None
|
||||
return record
|
||||
|
||||
async def revoke(self, record: RefreshToken) -> None:
|
||||
record.revoked_at = datetime.now(UTC)
|
||||
await self.db.flush()
|
||||
|
||||
async def revoke_all_for_user(self, user_id: uuid.UUID) -> None:
|
||||
"""Invalidates every active session for this user - used after a
|
||||
password reset, since an attacker who had a valid refresh token
|
||||
shouldn't stay logged in past the password change that locked them
|
||||
out going forward."""
|
||||
result = await self.db.execute(
|
||||
select(RefreshToken).where(
|
||||
RefreshToken.user_id == user_id, RefreshToken.revoked_at.is_(None)
|
||||
)
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
for record in result.scalars().all():
|
||||
record.revoked_at = now
|
||||
await self.db.flush()
|
||||
@@ -0,0 +1,46 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.report import Report
|
||||
|
||||
|
||||
class ReportRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get(self, report_id: uuid.UUID) -> Report | None:
|
||||
return await self.db.get(Report, report_id)
|
||||
|
||||
async def get_for_company(self, report_id: uuid.UUID, company_id: uuid.UUID) -> Report | None:
|
||||
result = await self.db.execute(
|
||||
select(Report).where(Report.id == report_id, Report.company_id == company_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_for_company(self, company_id: uuid.UUID, limit: int = 50) -> list[Report]:
|
||||
result = await self.db.execute(
|
||||
select(Report)
|
||||
.where(Report.company_id == company_id)
|
||||
.order_by(Report.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def latest_for_company(self, company_id: uuid.UUID) -> Report | None:
|
||||
result = await self.db.execute(
|
||||
select(Report)
|
||||
.where(Report.company_id == company_id)
|
||||
.order_by(Report.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def count_for_company(self, company_id: uuid.UUID) -> int:
|
||||
result = await self.db.execute(
|
||||
select(func.count()).select_from(Report).where(Report.company_id == company_id)
|
||||
)
|
||||
return int(result.scalar_one())
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.base import ensure_aware_utc
|
||||
from app.models.company import Company
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
from app.models.snapshot import Snapshot
|
||||
from app.models.source import Source
|
||||
from app.models.source_document import SourceDocument
|
||||
|
||||
|
||||
def _is_due(value: datetime | None, now: datetime) -> bool:
|
||||
# SQLite silently drops tzinfo on read-back (Postgres does not) - any
|
||||
# value read from the DB must go through ensure_aware_utc before being
|
||||
# compared against an aware `now` in Python, or this comparison would
|
||||
# raise on SQLite while working fine on Postgres. See db/base.py.
|
||||
return value is not None and ensure_aware_utc(value) <= now
|
||||
|
||||
|
||||
def _is_source_due(source: Source, now: datetime, company_next_run: datetime | None) -> bool:
|
||||
"""A source with its own frequency override uses its own next_check
|
||||
(due immediately if never computed yet); one with no override rides
|
||||
the company's own next_run clock instead."""
|
||||
if source.frequency_type is not None:
|
||||
return source.next_check is None or _is_due(source.next_check, now)
|
||||
return _is_due(company_next_run, now)
|
||||
|
||||
|
||||
class SourceRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def list_for_company(self, company_id: uuid.UUID) -> list[Source]:
|
||||
result = await self.db.execute(
|
||||
select(Source).where(Source.company_id == company_id).order_by(Source.created_at)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def list_due_for_company(
|
||||
self, company_id: uuid.UUID, now: datetime, company_next_run: datetime | None
|
||||
) -> list[Source]:
|
||||
"""Active sources that are due for a check right now. A source with
|
||||
its own frequency override (frequency_type is not None) uses its
|
||||
own next_check, and is treated as due if it's never been computed
|
||||
yet (a brand-new override should get its first check immediately,
|
||||
same as any brand-new source always has). A source with no
|
||||
override rides the company's own next_run clock instead - this is
|
||||
what keeps "no override configured" behaviorally identical to how
|
||||
every source worked before per-source scheduling existed. Small
|
||||
per-company table (sources per company is always small for this
|
||||
app), so filtering in Python after one plain SELECT is simpler than
|
||||
expressing the OR-with-NULL-fallback logic as SQL."""
|
||||
sources = await self._list_active_for_company(company_id)
|
||||
return [s for s in sources if _is_source_due(s, now, company_next_run)]
|
||||
|
||||
async def company_has_due_work(
|
||||
self, company_id: uuid.UUID, now: datetime, company_next_run: datetime | None
|
||||
) -> bool:
|
||||
"""Whether the scheduler should enqueue a run for this company: a
|
||||
company with zero sources yet has nothing to check per-source - it
|
||||
stays gated purely by its own next_run (the company's first-ever
|
||||
run, which is what triggers source discovery), same as before
|
||||
per-source scheduling existed. A company with sources is due if any
|
||||
active one is (see list_due_for_company)."""
|
||||
sources = await self._list_active_for_company(company_id)
|
||||
if not sources:
|
||||
return _is_due(company_next_run, now)
|
||||
return any(_is_source_due(s, now, company_next_run) for s in sources)
|
||||
|
||||
async def _list_active_for_company(self, company_id: uuid.UUID) -> list[Source]:
|
||||
result = await self.db.execute(
|
||||
select(Source).where(Source.company_id == company_id, Source.active.is_(True))
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get_for_company(self, source_id: uuid.UUID, company_id: uuid.UUID) -> Source | None:
|
||||
result = await self.db.execute(
|
||||
select(Source).where(Source.id == source_id, Source.company_id == company_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def get_for_user(self, source_id: uuid.UUID, user_id: uuid.UUID) -> Source | None:
|
||||
"""Ownership-checked lookup that doesn't require the caller to
|
||||
already know the company_id (matches the spec's `/sources/{id}`
|
||||
routes, which aren't nested under `/companies/{company_id}`)."""
|
||||
result = await self.db.execute(
|
||||
select(Source)
|
||||
.join(Company, Company.id == Source.company_id)
|
||||
.where(Source.id == source_id, Company.user_id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
company_id: uuid.UUID,
|
||||
source_type: SourceType,
|
||||
name: str,
|
||||
base_url: str | None,
|
||||
configuration_metadata: dict[str, Any] | None = None,
|
||||
trust_score: float = 0.7,
|
||||
) -> Source:
|
||||
source = Source(
|
||||
company_id=company_id,
|
||||
source_type=source_type,
|
||||
name=name,
|
||||
base_url=base_url,
|
||||
configuration_metadata=configuration_metadata or {},
|
||||
trust_score=trust_score,
|
||||
)
|
||||
self.db.add(source)
|
||||
await self.db.flush()
|
||||
return source
|
||||
|
||||
async def delete(self, source: Source) -> None:
|
||||
await self.db.delete(source)
|
||||
await self.db.flush()
|
||||
|
||||
async def mark_checked(
|
||||
self,
|
||||
source: Source,
|
||||
*,
|
||||
status: SourceStatus,
|
||||
checked_at: datetime,
|
||||
success: bool,
|
||||
) -> None:
|
||||
source.status = status
|
||||
source.last_checked = checked_at
|
||||
if success:
|
||||
source.last_successful_check = checked_at
|
||||
source.failure_count = 0
|
||||
else:
|
||||
source.failure_count += 1
|
||||
await self.db.flush()
|
||||
|
||||
|
||||
class SourceDocumentRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def exists_with_hash(self, source_id: uuid.UUID, content_hash: str) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(SourceDocument.id).where(
|
||||
SourceDocument.source_id == source_id,
|
||||
SourceDocument.content_hash == content_hash,
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
async def create(self, **kwargs: Any) -> SourceDocument:
|
||||
document = SourceDocument(**kwargs)
|
||||
self.db.add(document)
|
||||
await self.db.flush()
|
||||
return document
|
||||
|
||||
async def latest_for_source(
|
||||
self, source_id: uuid.UUID, limit: int = 50
|
||||
) -> list[SourceDocument]:
|
||||
result = await self.db.execute(
|
||||
select(SourceDocument)
|
||||
.where(SourceDocument.source_id == source_id)
|
||||
.order_by(SourceDocument.retrieved_date.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def delete_older_than(self, cutoff: datetime) -> int:
|
||||
"""Data-retention purge target (DATA_RETENTION_DAYS). Only
|
||||
SourceDocument is in scope - nothing else has a foreign key onto it
|
||||
(see KNOWN_LIMITATIONS.md), so this can't cascade-delete a Snapshot,
|
||||
DetectedChange, Alert, or Report a user might still want to see."""
|
||||
result = await self.db.execute(
|
||||
delete(SourceDocument).where(SourceDocument.retrieved_date < cutoff)
|
||||
)
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
class SnapshotRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def list_for_company(self, company_id: uuid.UUID, limit: int = 50) -> list[Snapshot]:
|
||||
result = await self.db.execute(
|
||||
select(Snapshot)
|
||||
.where(Snapshot.company_id == company_id)
|
||||
.order_by(Snapshot.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def latest_for_source(self, source_id: uuid.UUID) -> Snapshot | None:
|
||||
result = await self.db.execute(
|
||||
select(Snapshot)
|
||||
.where(Snapshot.source_id == source_id)
|
||||
.order_by(Snapshot.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def create(self, **kwargs: Any) -> Snapshot:
|
||||
snapshot = Snapshot(**kwargs)
|
||||
self.db.add(snapshot)
|
||||
await self.db.flush()
|
||||
return snapshot
|
||||
|
||||
async def get_previous(self, source_id: uuid.UUID, before: Snapshot) -> Snapshot | None:
|
||||
"""The snapshot immediately preceding `before` for this source -
|
||||
what change detection diffs the new snapshot against."""
|
||||
result = await self.db.execute(
|
||||
select(Snapshot)
|
||||
.where(
|
||||
Snapshot.source_id == source_id,
|
||||
Snapshot.id != before.id,
|
||||
Snapshot.created_at <= before.created_at,
|
||||
)
|
||||
.order_by(Snapshot.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
@@ -0,0 +1,37 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import SystemSecretKey
|
||||
from app.models.system_secret import SystemSecret
|
||||
|
||||
|
||||
class SystemSecretRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def list_all(self) -> list[SystemSecret]:
|
||||
result = await self.db.execute(select(SystemSecret))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get(self, key: SystemSecretKey) -> SystemSecret | None:
|
||||
result = await self.db.execute(select(SystemSecret).where(SystemSecret.key == key))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def upsert(self, key: SystemSecretKey, encrypted_value: str) -> SystemSecret:
|
||||
existing = await self.get(key)
|
||||
if existing is not None:
|
||||
existing.encrypted_value = encrypted_value
|
||||
await self.db.flush()
|
||||
return existing
|
||||
record = SystemSecret(key=key, encrypted_value=encrypted_value)
|
||||
self.db.add(record)
|
||||
await self.db.flush()
|
||||
return record
|
||||
|
||||
async def delete(self, key: SystemSecretKey) -> None:
|
||||
existing = await self.get(key)
|
||||
if existing is not None:
|
||||
await self.db.delete(existing)
|
||||
await self.db.flush()
|
||||
@@ -0,0 +1,53 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.base import ensure_aware_utc
|
||||
from app.models.unban_request import UnbanRequest
|
||||
|
||||
|
||||
class UnbanRequestRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get(self, request_id: uuid.UUID) -> UnbanRequest | None:
|
||||
result = await self.db.execute(select(UnbanRequest).where(UnbanRequest.id == request_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def delete(self, request_id: uuid.UUID) -> None:
|
||||
request = await self.get(request_id)
|
||||
if request is not None:
|
||||
await self.db.delete(request)
|
||||
await self.db.flush()
|
||||
|
||||
async def most_recent_for_ip(self, ip_address: str) -> UnbanRequest | None:
|
||||
result = await self.db.execute(
|
||||
select(UnbanRequest)
|
||||
.where(UnbanRequest.ip_address == ip_address)
|
||||
.order_by(UnbanRequest.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def within_cooldown(self, ip_address: str, cooldown_hours: int = 24) -> bool:
|
||||
latest = await self.most_recent_for_ip(ip_address)
|
||||
if latest is None:
|
||||
return False
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=cooldown_hours)
|
||||
return ensure_aware_utc(latest.created_at) > cutoff
|
||||
|
||||
async def create(self, ip_address: str, message: str | None) -> UnbanRequest:
|
||||
record = UnbanRequest(ip_address=ip_address, message=message)
|
||||
self.db.add(record)
|
||||
await self.db.flush()
|
||||
return record
|
||||
|
||||
async def list_all(self, limit: int = 100) -> list[UnbanRequest]:
|
||||
result = await self.db.execute(
|
||||
select(UnbanRequest).order_by(UnbanRequest.created_at.desc()).limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import ApiKeyProvider
|
||||
from app.models.user_api_key import UserApiKey
|
||||
|
||||
|
||||
class UserApiKeyRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def list_for_user(self, user_id: uuid.UUID) -> list[UserApiKey]:
|
||||
result = await self.db.execute(select(UserApiKey).where(UserApiKey.user_id == user_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def get(self, user_id: uuid.UUID, provider: ApiKeyProvider) -> UserApiKey | None:
|
||||
result = await self.db.execute(
|
||||
select(UserApiKey).where(UserApiKey.user_id == user_id, UserApiKey.provider == provider)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def upsert(
|
||||
self, user_id: uuid.UUID, provider: ApiKeyProvider, encrypted_key: str
|
||||
) -> UserApiKey:
|
||||
existing = await self.get(user_id, provider)
|
||||
if existing is not None:
|
||||
existing.encrypted_key = encrypted_key
|
||||
await self.db.flush()
|
||||
return existing
|
||||
record = UserApiKey(user_id=user_id, provider=provider, encrypted_key=encrypted_key)
|
||||
self.db.add(record)
|
||||
await self.db.flush()
|
||||
return record
|
||||
|
||||
async def delete(self, user_id: uuid.UUID, provider: ApiKeyProvider) -> None:
|
||||
existing = await self.get(user_id, provider)
|
||||
if existing is not None:
|
||||
await self.db.delete(existing)
|
||||
await self.db.flush()
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user_known_ip import UserKnownIp
|
||||
|
||||
|
||||
class UserKnownIpRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get(self, user_id: uuid.UUID, ip_address: str) -> UserKnownIp | None:
|
||||
result = await self.db.execute(
|
||||
select(UserKnownIp).where(
|
||||
UserKnownIp.user_id == user_id, UserKnownIp.ip_address == ip_address
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_for_user(self, user_id: uuid.UUID) -> list[UserKnownIp]:
|
||||
result = await self.db.execute(
|
||||
select(UserKnownIp)
|
||||
.where(UserKnownIp.user_id == user_id)
|
||||
.order_by(UserKnownIp.last_seen_at.desc())
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def record_login(self, user_id: uuid.UUID, ip_address: str, now: datetime) -> bool:
|
||||
"""Touches last_seen_at for an already-known IP, or inserts a new
|
||||
row for a genuinely new one. Returns True iff this IP was new for
|
||||
this user - callers don't currently act on that, but it's the
|
||||
natural hook a future "new IP" alert would use."""
|
||||
existing = await self.get(user_id, ip_address)
|
||||
if existing is not None:
|
||||
existing.last_seen_at = now
|
||||
await self.db.flush()
|
||||
return False
|
||||
record = UserKnownIp(
|
||||
user_id=user_id, ip_address=ip_address, first_seen_at=now, last_seen_at=now
|
||||
)
|
||||
self.db.add(record)
|
||||
await self.db.flush()
|
||||
return True
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class UserRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get_by_id(self, user_id: uuid.UUID) -> User | None:
|
||||
return await self.db.get(User, user_id)
|
||||
|
||||
async def get_by_email(self, email: str) -> User | None:
|
||||
result = await self.db.execute(select(User).where(User.email == email.lower()))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_admin_emails(self) -> list[str]:
|
||||
result = await self.db.execute(select(User.email).where(User.is_admin.is_(True)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
password_hash: str | None,
|
||||
display_name: str,
|
||||
timezone: str,
|
||||
user_id: uuid.UUID | None = None,
|
||||
is_admin: bool = False,
|
||||
email_verified: bool = False,
|
||||
) -> User:
|
||||
user = User(
|
||||
id=user_id or uuid.uuid4(),
|
||||
email=email.lower(),
|
||||
password_hash=password_hash,
|
||||
display_name=display_name,
|
||||
timezone=timezone,
|
||||
is_admin=is_admin,
|
||||
email_verified=email_verified,
|
||||
)
|
||||
self.db.add(user)
|
||||
await self.db.flush()
|
||||
return user
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.enums import SecurityEventType
|
||||
from app.models.user_security_event import UserSecurityEvent
|
||||
|
||||
|
||||
class UserSecurityEventRepository:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def create(
|
||||
self, *, user_id: uuid.UUID, event_type: SecurityEventType, ip_address: str
|
||||
) -> UserSecurityEvent:
|
||||
record = UserSecurityEvent(user_id=user_id, event_type=event_type, ip_address=ip_address)
|
||||
self.db.add(record)
|
||||
await self.db.flush()
|
||||
return record
|
||||
|
||||
async def most_recent_of_type(
|
||||
self, user_id: uuid.UUID, event_type: SecurityEventType
|
||||
) -> UserSecurityEvent | None:
|
||||
result = await self.db.execute(
|
||||
select(UserSecurityEvent)
|
||||
.where(UserSecurityEvent.user_id == user_id, UserSecurityEvent.event_type == event_type)
|
||||
.order_by(UserSecurityEvent.created_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_for_user(self, user_id: uuid.UUID, limit: int = 100) -> list[UserSecurityEvent]:
|
||||
result = await self.db.execute(
|
||||
select(UserSecurityEvent)
|
||||
.where(UserSecurityEvent.user_id == user_id)
|
||||
.order_by(UserSecurityEvent.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
Reference in New Issue
Block a user