from datetime import date, datetime from enum import Enum from typing import Annotated, Any, Generic, Optional, TypeVar from pydantic import BaseModel, BeforeValidator, ConfigDict, Field def _stringify(v: Any) -> Any: """ObjectId (and anything else non-null) -> str. ighub stores some reference fields inconsistently -- `state` is an ObjectId on 144 docs and a plain string on 23 -- so read models coerce rather than reject. """ return None if v is None else str(v) StrId = Annotated[Optional[str], BeforeValidator(_stringify)] def _loose_date(v: Any) -> Any: """Parse a date, but never fail the request over one bad value. Harvested rows arrive in batches; strict validation would throw away 25 good contacts because a single date was malformed. Junk becomes None instead. """ if v is None or v == "": return None if isinstance(v, (datetime, date)): return v try: return date.fromisoformat(str(v)[:10]) except (ValueError, TypeError): return None LooseDate = Annotated[Optional[date], BeforeValidator(_loose_date)] T = TypeVar("T") class Page(BaseModel, Generic[T]): total: int skip: int limit: int items: list[T] class UserBase(BaseModel): user_id: Optional[str] = None fname: Optional[str] = None lname: Optional[str] = None email: Optional[str] = None pen_name: Optional[str] = None pen_name_email: Optional[str] = None phone: Optional[str] = None role: Optional[int] = None status: Optional[int] = None emp_type: Optional[int] = None department: Optional[int] = None designation: Optional[str] = None team: Optional[str] = None team_id: StrId = None address1: Optional[str] = None address2: Optional[str] = None city: Optional[str] = None state: StrId = None country: StrId = None pin_code: Optional[str] = None date_of_birth: Optional[str] = None linkedin: Optional[str] = None msteams: Optional[str] = None user_profile_photo: Optional[str] = None assigned_menus: Optional[str] = None assigned_users: Optional[str] = None bmc_flag: Optional[int] = None bmc_key: Optional[str] = None aadhar_file: Optional[str] = None resume_file: Optional[str] = None pan_file: Optional[str] = None class UserCounters(BaseModel): """Score / tally fields every ighub user document carries.""" available_amount: Optional[int] = None queries_count: Optional[int] = None automatic_removed_count: Optional[int] = None bounced_removed_count: Optional[int] = None read_removed_count: Optional[int] = None unsub_removed_count: Optional[int] = None day_score: Optional[int] = None total_score: Optional[int] = None article_count: Optional[int] = None class UserOut(UserBase, UserCounters): # Publisher-type docs in this collection carry extra keys (logo, doi_link, # sending_limit, ...). Pass them through instead of dropping them. model_config = ConfigDict(populate_by_name=True, extra="allow") # validation_alias (not alias) so Mongo's `_id` is read on the way in while # responses expose the friendlier `id`. id: StrId = Field(default=None, validation_alias="_id") created_on: Optional[datetime] = None last_login: Optional[datetime] = None class UserCreate(UserBase, UserCounters): model_config = ConfigDict(extra="forbid") user_id: str fname: str password: str = Field(min_length=1) role: int = 0 status: int = 1 class UserUpdate(UserBase, UserCounters): """Every field optional; only keys actually sent are written.""" model_config = ConfigDict(extra="forbid") class PasswordChange(BaseModel): current_password: Optional[str] = None new_password: str = Field(min_length=1) class LoginRequest(BaseModel): username: str = Field(description="user_id or email") password: str class LoginResponse(BaseModel): authenticated: bool user: UserOut class SourceType(str, Enum): """Reach of a preprint source.""" GLOBAL = "Global" DOMAIN = "Domain" REGION = "Region" class Frequency(str, Enum): """How often a source publishes. Edit this list to change the dropdown.""" DAILY = "Daily" WEEKLY = "Weekly" FORTNIGHTLY = "Fortnightly" MONTHLY = "Monthly" QUARTERLY = "Quarterly" IRREGULAR = "Irregular" class Discipline(BaseModel): """A discipline on one preprint source, e.g. Mathematics / math. Held per source rather than as a global list, because every source carries its own set. """ model_config = ConfigDict(extra="forbid") title: str = Field(min_length=1, max_length=200) short_name: str = Field(min_length=1, max_length=120) class PreprintSourceBase(BaseModel): title: Optional[str] = None website: Optional[str] = None disciplines: Optional[list[Discipline]] = None # Derived from `disciplines` as the comma-joined titles, so anything already # reading this keeps working. Never written directly. discipline: Optional[str] = None type: Optional[SourceType] = None key_features: Optional[str] = None frequency: Optional[Frequency] = None note: Optional[str] = None status: Optional[int] = None class PreprintSourceCreate(PreprintSourceBase): model_config = ConfigDict(extra="forbid") title: str = Field(min_length=1, max_length=300) website: str = Field(min_length=1, max_length=500) type: SourceType frequency: Frequency status: int = 1 class PreprintSourceUpdate(PreprintSourceBase): """Every field optional; only keys actually sent are written.""" model_config = ConfigDict(extra="forbid") title: Optional[str] = Field(default=None, min_length=1, max_length=300) website: Optional[str] = Field(default=None, min_length=1, max_length=500) class PreprintSourceOut(PreprintSourceBase): model_config = ConfigDict(populate_by_name=True) id: StrId = Field(default=None, validation_alias="_id") created_on: Optional[datetime] = None updated_on: Optional[datetime] = None # Assignment lives on the source itself, so a source can only ever have one # owner -- the uniqueness rule is structural, not enforced by convention. assigned_to: StrId = None assigned_user_id: Optional[str] = None assigned_name: Optional[str] = None assigned_on: Optional[datetime] = None class AssignRequest(BaseModel): model_config = ConfigDict(extra="forbid") user_ref: str = Field(min_length=1, description="_id or user_id of an MRD user") class AssignmentCount(BaseModel): assigned_to: Optional[str] = None assigned_user_id: Optional[str] = None count: int class PreprintSourceOptions(BaseModel): """Drives the dropdowns in the UI so both sides cannot drift apart.""" types: list[str] frequencies: list[str] class CollectedPreprint(BaseModel): """One harvested author contact. `email` is required: a row without a contact address is not worth keeping, so those are rejected rather than stored. `name` and `country` stay optional because the PDF does not always give them up. """ model_config = ConfigDict(extra="forbid") title: str = Field(min_length=1, max_length=600) email: str = Field(min_length=3, max_length=320) name: Optional[str] = None country: Optional[str] = None # Subtopics the article is filed under, e.g. # ["Astrophysics, Cosmology and Nongalactic Astrophysics"]. relatedtopics: Optional[list[str]] = None arxiv_id: Optional[str] = None link: Optional[str] = None discipline: Optional[str] = None source: Optional[str] = "arxiv.org" # Accepts "2026-08-04"; stored as a BSON date, not text, so range queries # and sorting work. BSON has no date-only type, hence midnight UTC. published_on: LooseDate = None affiliation: Optional[str] = None collected_by: Optional[str] = None collected_by_user_id: Optional[str] = None class CollectedBatch(BaseModel): model_config = ConfigDict(extra="forbid") rows: list[CollectedPreprint] class CollectedResult(BaseModel): received: int inserted: int #`skipped` counts rows already held under the same title + email. skipped: int = 0 updated: int = 0 class CollectedPreprintOut(BaseModel): model_config = ConfigDict(populate_by_name=True, extra="allow") id: StrId = Field(default=None, validation_alias="_id") title: Optional[str] = None name: Optional[str] = None email: Optional[str] = None country: Optional[str] = None relatedtopics: Optional[list[str]] = None arxiv_id: Optional[str] = None link: Optional[str] = None discipline: Optional[str] = None source: Optional[str] = None published_on: Optional[datetime] = None collected_by_user_id: Optional[str] = None collected_on: Optional[datetime] = None class AutoRunStart(BaseModel): model_config = ConfigDict(extra="forbid") date: LooseDate = None disciplines: list[str] = Field(default_factory=list) user_id: Optional[str] = None class AutoRunFinish(BaseModel): model_config = ConfigDict(extra="forbid") date: LooseDate = None status: str = Field(pattern="^(completed|failed|stopped)$") error: Optional[str] = None class FilterCounts(BaseModel): """Per-reason removal counts, plus what survives. A row is attributed to exactly one reason (first match wins), so the reason counts never overlap and `remaining` = emails - their sum. """ unsub: int = 0 read: int = 0 bounced: int = 0 automatic: int = 0 abc: int = 0 collected: int = 0 extensions: int = 0 remaining: int = 0 class AutoRunOut(FilterCounts): """One day of the log report. Inherits the per-reason removal counts and `remaining` from FilterCounts.""" date: Optional[datetime] = None status: str = "not started" emails: int = 0 # True once the day's clean rows have been saved into collected_data_de. saved: bool = False # True once publisher-wise campaigns have been created for the day. campaigned: bool = False # True once every one of the day's campaigns has been sent. campaigns_completed: bool = False disciplines: list[str] = Field(default_factory=list) started_on: Optional[datetime] = None collected_on: Optional[datetime] = None user_id: Optional[str] = None error: Optional[str] = None class DisciplineCount(FilterCounts): discipline: str emails: int new: int = 0 # filtered emails not yet in any created campaign (display only) campaigned: int = 0 # this discipline's emails already placed in a campaign class FilterResult(BaseModel): """Outcome of running every filter over the collected preprint contacts.""" scanned: int # collected preprint rows examined filtered: int # rows removed by any list remaining: int # rows still usable (scanned - filtered) by_reason: dict[str, int] # rows removed, per reason class PendingDates(BaseModel): last_completed: Optional[datetime] = None yesterday: Optional[datetime] = None pending: list[datetime] = Field(default_factory=list) class ProcessRequest(BaseModel): model_config = ConfigDict(extra="forbid") date: LooseDate = None user_id: str = Field(min_length=1, description="logged-in user _id") class ProcessResult(BaseModel): scanned: int # clean preprint rows examined for the day inserted: int # new collected_data_de rows unmatched_topic: int # rows with no discipline/section_topics mapping (skipped) already_saved: bool = False # the day was saved before -- skipped class CampaignResult(BaseModel): topics: int # topics that had at least one publisher campaigns: int # new cd_campaigns created records: int # rows copied into cd_campaign_data detail: Optional[str] = None # human-readable outcome / why nothing was created class SendJob(BaseModel): """One rendered email + the SMTP to send it through. The desktop does the actual smtplib send; the API only prepares this.""" rec_id: str # cd_campaign_data _id de_id: Optional[str] = None # collected_data_de _id (for the mirror) to_email: Optional[str] = None subject: str = "" html: str = "" from_email: str = "" from_name: str = "" smtp_id: Optional[str] = None smtp_host: Optional[str] = None smtp_port: int = 587 smtp_user: Optional[str] = None smtp_password: Optional[str] = None smtp_domain: Optional[str] = None helo: Optional[str] = None cert_skip: bool = False # mail_type 2 (Postal) -> skip TLS cert check template_id: Optional[str] = None # human template code (temp_id) body_id: Optional[str] = None # templates _id, for used_count class PrepareSendResult(BaseModel): claimed: bool # False if already sent/sending or no SMTP campaign_id: str total: int = 0 jobs: list[SendJob] = [] detail: str = "" class SendReportItem(BaseModel): rec_id: str de_id: Optional[str] = None ok: bool smtp_id: Optional[str] = None smtp_host: Optional[str] = None smtp_domain: Optional[str] = None template_id: Optional[str] = None body_id: Optional[str] = None error: Optional[str] = None class SendReportRequest(BaseModel): results: list[SendReportItem] = [] final: bool = True # False for a mid-send batch; True finalizes status class SendReportResult(BaseModel): sent: int failed: int pending: int completed: bool detail: str = "" class CollectedLogOut(BaseModel): """One day's count of records saved into collected_data (IST calendar day).""" date: str # YYYY-MM-DD (IST) count: int # rows saved that day class CampaignOut(BaseModel): """One publisher-wise campaign in the day's list.""" model_config = ConfigDict(populate_by_name=True) id: StrId = Field(default=None, validation_alias="_id") publisher_id: StrId = None publisher_name: Optional[str] = None primary_topic_id: StrId = None primary_topic: Optional[str] = None total_emails: int = 0 published_on: Optional[datetime] = None created_on: Optional[datetime] = None status: Optional[int] = None # Set once the campaign has been sent (so it isn't sent again). send_started_on: Optional[datetime] = None sent_on: Optional[datetime] = None sent_count: Optional[int] = None failed_count: Optional[int] = None class CampaignSendResult(BaseModel): started: bool total: int detail: str = "" class CampaignDeleteResult(BaseModel): deleted: bool campaigns_deleted: int = 0 # campaigns removed (1 for a single delete) records_deleted: int # cd_campaign_data rows removed with them detail: str = "" class CampaignPreview(BaseModel): """One rendered campaign email (subject + HTML) for a single recipient.""" campaign_id: str publisher: str = "" smtp_host: str = "" web_domain: str = "" recipient: str = "" subject: str = "" html: str = "" warnings: list[str] = Field(default_factory=list) class CampaignDataOut(BaseModel): """One email row inside a campaign (from cd_campaign_data).""" model_config = ConfigDict(populate_by_name=True) id: StrId = Field(default=None, validation_alias="_id") name: Optional[str] = None email: Optional[str] = None title: Optional[str] = None country_name: Optional[str] = None primary_topic: Optional[str] = None published_on: Optional[datetime] = None # Filled once the campaign is sent. sent_on: Optional[datetime] = None smtp_host: Optional[str] = None smtp_domain: Optional[str] = None template_id: StrId = None mail_send_status: Optional[int] = None send_error: Optional[str] = None class SmtpOut(BaseModel): """One SMTP from smtp_manager, with the publisher name resolved.""" model_config = ConfigDict(populate_by_name=True) id: StrId = Field(default=None, validation_alias="_id") smtp_id: Optional[int] = None publisher_id: Optional[int] = None publisher: Optional[str] = None # users.fname for that publisher_id publisher_logo: Optional[str] = None # writable/uploads/publisher_logo/ used_count: int = 0 # day-used limit limit: int = 0 # SMTP total limit total_smtp_used_count: int = 0 # total used limit hourly_count: int = 0 # hourly limit sent_mails_hourly_count: int = 0 # hourly used (this hour) host: Optional[str] = None domain: Optional[str] = None password: Optional[str] = None status: Optional[int] = None # Last test-email result, saved by POST /smtp/{id}/test. last_test_ok: Optional[bool] = None last_test_status: Optional[str] = None last_tested_on: Optional[datetime] = None last_test_detail: Optional[str] = None class SmtpTestRequest(BaseModel): model_config = ConfigDict(extra="forbid") email: str = Field(min_length=3, description="recipient for the test email") class SmtpTestResult(BaseModel): ok: bool detail: str class StatsSummary(BaseModel): total: int active: int inactive: int by_role: dict[str, int] by_department: dict[str, int] by_emp_type: dict[str, int] total_score: int total_article_count: int total_queries_count: int