Source code for cata_log_hub.api.v1.models

import enum
from typing import Annotated, Any
from zoneinfo import ZoneInfo

from apscheduler.triggers.base import BaseTrigger
from apscheduler.triggers.cron import CronTrigger
from pydantic import BaseModel, Field, field_validator
from pydantic.fields import FieldInfo
from pydantic.types import AwareDatetime, NonNegativeInt, PositiveInt, StringConstraints
from pydantic_core import PydanticCustomError
from pydantic_core.core_schema import ValidationInfo
from pydantic_extra_types.cron import CronStr
from sqlalchemy import sql

from cata_log_hub import constants
from cata_log_hub.api.mixins import AwareDatetimesMixin
from cata_log_hub.database import ModelBase
from cata_log_hub.exceptions import (
    ProviderInvalidConfigurationWarning,
    ProviderUnknownClassWarning,
)
from cata_log_hub.providers import Provider as ProviderType


[docs] class OrderChoices(enum.StrEnum): """Enum subclass for ordering with sql."""
[docs] def sql(self, model: type[ModelBase]) -> sql.ColumnExpressionArgument: """Translate into sql. Returns: SQL expression for proper ordering by the string. """ if self.value.startswith("-"): return getattr(model, self.value[1:]).desc() return getattr(model, self.value)
[docs] class AwareTimestampsMixin(AwareDatetimesMixin): """Mixin for api data with aware datetimes and database timestamps.""" created_at: AwareDatetime updated_at: AwareDatetime
[docs] class Catalog(AwareTimestampsMixin, BaseModel): """Catalog data model.""" id: int provider_id: int valid_since: AwareDatetime valid_until: AwareDatetime
[docs] class CatalogOrderChoices(OrderChoices): """Choices for ordering catalogs.""" ID = "id" VALID_SINCE = "valid_since" VALID_UNTIL = "valid_until" CREATED_AT = "created_at" UPDATED_AT = "updated_at" DESC_ID = "-id" DESC_VALID_SINCE = "-valid_since" DESC_VALID_UNTIL = "-valid_until" DESC_CREATED_AT = "-created_at" DESC_UPDATED_AT = "-updated_at"
[docs] class FullCatalog(Catalog): """Full catalog data model.""" pages: list[Page]
[docs] class PageFile(AwareTimestampsMixin, BaseModel): """Page file data model.""" id: int sha256: Annotated[ str, StringConstraints(strip_whitespace=True, pattern=r"^[a-f0-9]{64}$") ] size: PositiveInt width: PositiveInt height: PositiveInt name: str
[docs] class Page(AwareTimestampsMixin, BaseModel): """Page data model.""" id: int number: NonNegativeInt catalog_id: int file: PageFile
[docs] class PageOrderChoices(OrderChoices): """Choices for ordering pages.""" ID = "id" NUMBER = "number" CATALOG_ID = "catalog_id" CREATED_AT = "created_at" UPDATED_AT = "updated_at" DESC_ID = "-id" DESC_NUMBER = "-number" DESC_CATALOG_ID = "-catalog_id" DESC_CREATED_AT = "-created_at" DESC_UPDATED_AT = "-updated_at"
[docs] class Job(AwareDatetimesMixin, BaseModel): """Job data model.""" id: str next_run_time: AwareDatetime | None = None schedule: CronStr | None = Field(validation_alias="trigger") jitter: int = Field(validation_alias="trigger")
[docs] @field_validator("schedule", mode="before") @classmethod def get_schedule_from_trigger(cls, trigger: BaseTrigger) -> str | None: """Get the crontab schedule as a string.""" if isinstance(trigger, CronTrigger): return "{minute} {hour} {day} {month} {day_of_week}".format( **{field.name: str(field) for field in trigger.fields} ) return None
[docs] @field_validator("jitter", mode="before") @classmethod def get_jitter_from_trigger(cls, trigger: BaseTrigger) -> int: """Get the crontab schedule as a string.""" return (trigger.jitter if isinstance(trigger, CronTrigger) else 0) or 0
[docs] class Provider(AwareTimestampsMixin, BaseModel): """Provider data model.""" id: int class_uid: str note: str | None configuration: dict[str, Any] status: constants.StatusEnum job: Job | None
[docs] class ProviderOrderChoices(OrderChoices): """Choices for ordering providers.""" ID = "id" CLASS_UID = "class_uid" NOTE = "note" CONFIGURATION = "configuration" STATUS = "status" CREATED_AT = "created_at" UPDATED_AT = "updated_at" DESC_ID = "-id" DESC_CLASS_UID = "-class_uid" DESC_NOTE = "-note" DESC_CONFIGURATION = "-configuration" DESC_STATUS = "-status" DESC_CREATED_AT = "-created_at" DESC_UPDATED_AT = "-updated_at"
[docs] class FullProvider(Provider): """Full provider data model.""" catalogs: list[Catalog]
[docs] class ProviderUpdate(BaseModel): """Provider update data model.""" configuration: dict[str, Any] = {} note: str = ""
[docs] @field_validator("configuration") @classmethod def validate_configuration( cls, configuration: dict[str, Any], info: ValidationInfo ) -> dict[str, Any]: """Validate the provider specific configuration.""" class_uid = info.context.get("class_uid") if info.context else None if class_uid: try: provider_class = ProviderType.get_class(class_uid) except ProviderUnknownClassWarning as unknown_warning: raise PydanticCustomError( "unknown_provider", str(unknown_warning) ) from unknown_warning try: configuration = provider_class.validate_configuration( configuration ).model_dump() # this won't throw a ProviderUnknownWarning since class_uid is already validated except ProviderInvalidConfigurationWarning as invalid_warning: raise invalid_warning.__cause__ or PydanticCustomError( "invalid_configuration", str(invalid_warning) ) from invalid_warning return configuration
[docs] class NewProvider(BaseModel): """Provider creation data model.""" # keep this order for correct order of validation steps class_uid: str configuration: dict[str, Any] note: str | None = None
[docs] @field_validator("class_uid") @classmethod def validate_class_uid(cls, class_uid: str) -> str: """Validate the provider class uid.""" try: ProviderType.get_class(class_uid) except ProviderUnknownClassWarning as unknown_warning: raise PydanticCustomError( "unknown_provider", str(unknown_warning) ) from unknown_warning return class_uid
[docs] @field_validator("configuration") @classmethod def validate_configuration( cls, configuration: dict[str, Any], info: ValidationInfo ) -> dict[str, Any]: """Validate the provider specific configuration.""" class_uid = info.data.get("class_uid") if class_uid: try: configuration = ( ProviderType.get_class(class_uid) .validate_configuration(configuration) .model_dump() ) # this won't throw a ProviderUnknownWarning since class_uid is already validated except ProviderInvalidConfigurationWarning as invalid_warning: raise invalid_warning.__cause__ or PydanticCustomError( "invalid_configuration", str(invalid_warning) ) from invalid_warning return configuration
[docs] class RegionInfo(BaseModel): """Region info data model.""" local_name: str language_code: str timezone: str
[docs] @field_validator("timezone", mode="before") @classmethod def get_timezone_name(cls, timezone: ZoneInfo) -> str: """Get timezone's name.""" return timezone.key
[docs] class ConfigInfo(BaseModel): """Configuration info data model.""" description: str = "" default: str | None = None type: str = Field(validation_alias="annotation")
[docs] @field_validator("type", mode="before") @classmethod def get_type_name(cls, annotation: type) -> str: # type: ignore [valid-type] # mypy misinterprets type here """Get the type name of the config.""" return annotation.__name__
[docs] @field_validator("default", mode="before") @classmethod def convert_default(cls, default: str | None) -> str | None: """Convert default to string.""" return str(default) if default is not None else default
[docs] class ProviderInfo(BaseModel): """Provider info data model.""" name: str description: str url: str region: RegionInfo schedule: CronStr jitter: int class_uid: str = Field(validation_alias="uid") configuration: dict[str, ConfigInfo] = Field(validation_alias="Configuration")
[docs] @field_validator("configuration", mode="before") @classmethod def get_configuration_schema( cls, configuration_class: type[ProviderType.Configuration] ) -> dict[str, FieldInfo]: """Get the config field infos from the configuration model.""" return configuration_class.model_fields