github_backup.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. """Pydantic schemas for GitHub backup configuration."""
  2. import re
  3. from datetime import datetime
  4. from pydantic import BaseModel, Field, model_validator
  5. from backend.app.core.compat import StrEnum
  6. from backend.app.schemas.not_null import IgnoresNullForRequiredColumns
  7. class ScheduleType(StrEnum):
  8. """Backup schedule types."""
  9. HOURLY = "hourly"
  10. DAILY = "daily"
  11. WEEKLY = "weekly"
  12. class ProviderType(StrEnum):
  13. """Git hosting provider types."""
  14. GITHUB = "github"
  15. GITLAB = "gitlab"
  16. GITEA = "gitea"
  17. FORGEJO = "forgejo"
  18. class GitHubBackupConfigCreate(BaseModel):
  19. """Schema for creating/updating GitHub backup config."""
  20. repository_url: str = Field(..., min_length=1, max_length=500, description="Git repository URL")
  21. access_token: str = Field(..., min_length=1, description="Personal Access Token")
  22. branch: str = Field(default="main", max_length=100, description="Branch to push to")
  23. provider: ProviderType = Field(default=ProviderType.GITHUB, description="Git hosting provider")
  24. schedule_enabled: bool = Field(default=False, description="Enable scheduled backups")
  25. schedule_type: ScheduleType = Field(default=ScheduleType.DAILY, description="Schedule frequency")
  26. backup_kprofiles: bool = Field(default=True, description="Backup K-profiles")
  27. backup_cloud_profiles: bool = Field(default=True, description="Backup Bambu Cloud profiles")
  28. backup_settings: bool = Field(default=False, description="Backup app settings")
  29. backup_spools: bool = Field(default=False, description="Backup spool inventory")
  30. backup_archives: bool = Field(default=False, description="Backup print archive history")
  31. allow_insecure_http: bool = Field(default=False, description="Allow HTTP (non-TLS) repository URLs")
  32. enabled: bool = Field(default=True, description="Enable backup feature")
  33. @model_validator(mode="after")
  34. def validate_repo_url(self) -> "GitHubBackupConfigCreate":
  35. url = self.repository_url.strip().rstrip("/")
  36. self.repository_url = url
  37. https_or_ssh = [
  38. r"^https://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$",
  39. r"^git@[\w.-]+:[\w.-]+(\/[\w.-]+)+(?:\.git)?$",
  40. ]
  41. http_pattern = r"^http://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$"
  42. if any(re.match(p, url) for p in https_or_ssh):
  43. return self
  44. if re.match(http_pattern, url):
  45. if not self.allow_insecure_http:
  46. raise ValueError(
  47. "This URL uses HTTP instead of HTTPS. "
  48. "Enable 'Allow insecure HTTP' if your instance does not use TLS."
  49. )
  50. return self
  51. raise ValueError(
  52. "Invalid Git repository URL. Expected: https://host/owner/repo, "
  53. "http://host/owner/repo (with 'Allow insecure HTTP' enabled), or git@host:owner/repo"
  54. )
  55. class GitHubBackupConfigUpdate(IgnoresNullForRequiredColumns):
  56. """Schema for updating GitHub backup config (all fields optional)."""
  57. __orm_table__ = "github_backup_config"
  58. repository_url: str | None = Field(default=None, max_length=500)
  59. access_token: str | None = Field(default=None)
  60. branch: str | None = Field(default=None, max_length=100)
  61. provider: ProviderType | None = None
  62. schedule_enabled: bool | None = None
  63. schedule_type: ScheduleType | None = None
  64. backup_kprofiles: bool | None = None
  65. backup_cloud_profiles: bool | None = None
  66. backup_settings: bool | None = None
  67. backup_spools: bool | None = None
  68. backup_archives: bool | None = None
  69. allow_insecure_http: bool | None = None
  70. enabled: bool | None = None
  71. @model_validator(mode="after")
  72. def validate_repo_url(self) -> "GitHubBackupConfigUpdate":
  73. if self.repository_url is None:
  74. return self
  75. url = self.repository_url.strip().rstrip("/")
  76. self.repository_url = url
  77. valid_patterns = [
  78. r"^https?://[\w.-]+(:\d+)?/[\w.-]+(\/[\w.-]+)+(?:\.git)?/?$",
  79. r"^git@[\w.-]+:[\w.-]+(\/[\w.-]+)+(?:\.git)?$",
  80. ]
  81. if not any(re.match(p, url) for p in valid_patterns):
  82. raise ValueError(
  83. "Invalid repository URL. Expected: https://host/owner/repo, "
  84. "http://host/owner/repo, or git@host:owner/repo"
  85. )
  86. return self
  87. class GitHubBackupConfigResponse(BaseModel):
  88. """Schema for GitHub backup config API response."""
  89. id: int
  90. repository_url: str
  91. has_token: bool = Field(description="Whether an access token is configured")
  92. branch: str
  93. provider: str
  94. allow_insecure_http: bool
  95. schedule_enabled: bool
  96. schedule_type: str
  97. backup_kprofiles: bool
  98. backup_cloud_profiles: bool
  99. backup_settings: bool
  100. backup_spools: bool
  101. backup_archives: bool
  102. enabled: bool
  103. last_backup_at: datetime | None
  104. last_backup_status: str | None
  105. last_backup_message: str | None
  106. last_backup_commit_sha: str | None
  107. next_scheduled_run: datetime | None
  108. created_at: datetime
  109. updated_at: datetime
  110. class Config:
  111. from_attributes = True
  112. class GitHubBackupLogResponse(BaseModel):
  113. """Schema for backup log API response."""
  114. id: int
  115. config_id: int
  116. started_at: datetime
  117. completed_at: datetime | None
  118. status: str
  119. trigger: str
  120. commit_sha: str | None
  121. files_changed: int
  122. error_message: str | None
  123. class Config:
  124. from_attributes = True
  125. class CloudAccountCounts(BaseModel):
  126. """How many connected cloud accounts a backup would collect presets from.
  127. Counts only, never identities: with auth enabled these are other users'
  128. accounts, and whoever administers the backup has no business learning who
  129. signed in to what. The number is enough to answer the only question the UI
  130. asks — is the Cloud Profiles category worth offering at all (#2717).
  131. """
  132. bambu: int = Field(default=0, description="Connected Bambu Cloud accounts")
  133. orca: int = Field(default=0, description="Connected Orca Cloud accounts")
  134. class GitHubBackupStatus(BaseModel):
  135. """Schema for current backup status."""
  136. configured: bool = Field(description="Whether backup is configured")
  137. enabled: bool = Field(description="Whether backup is enabled")
  138. is_running: bool = Field(description="Whether a backup is currently running")
  139. restore_running: bool = Field(default=False, description="Whether a restore is currently running")
  140. progress: str | None = Field(default=None, description="Current backup progress message")
  141. last_backup_at: datetime | None
  142. last_backup_status: str | None
  143. next_scheduled_run: datetime | None
  144. class GitHubTestConnectionResponse(BaseModel):
  145. """Schema for test connection response."""
  146. success: bool
  147. message: str
  148. repo_name: str | None = None
  149. permissions: dict | None = None
  150. # True = confirmed private. False = confirmed public (or non-private such
  151. # as GitLab "internal"). None = could not be determined (older self-hosted
  152. # API, non-2xx response). The backup config endpoints refuse anything that
  153. # isn't an explicit True.
  154. is_private: bool | None = None
  155. class GitHubBackupTriggerResponse(BaseModel):
  156. """Schema for manual backup trigger response."""
  157. success: bool
  158. message: str
  159. log_id: int | None = None
  160. commit_sha: str | None = None
  161. files_changed: int = 0
  162. # --- Restore (issue #2656) --------------------------------------------------
  163. # "HEAD" means "whatever the branch tip is right now"; the service resolves it
  164. # to a concrete SHA before reading anything so preview and apply can't straddle
  165. # two different commits. Anything else must look like a git object name.
  166. REF_PATTERN = r"^(?:HEAD|[0-9a-fA-F]{7,40})$"
  167. class RestoreCategory(StrEnum):
  168. """Backup categories that can be restored.
  169. Cloud profiles are deliberately absent: restoring a preset means writing to
  170. a Bambu or Orca Cloud account, which is a different operation from every
  171. other category here — those land in the local database, or on a printer the
  172. instance already owns. Tracked separately from #2656.
  173. """
  174. KPROFILES = "kprofiles"
  175. SETTINGS = "settings"
  176. SPOOLS = "spools"
  177. ARCHIVES = "archives"
  178. class GitHubCommitInfo(BaseModel):
  179. """One commit in the backup repository."""
  180. sha: str
  181. message: str
  182. author: str
  183. date: str
  184. class GitHubCommitListResponse(BaseModel):
  185. """Schema for the commit picker."""
  186. success: bool
  187. message: str
  188. branch: str
  189. commits: list[GitHubCommitInfo] = Field(default_factory=list)
  190. class GitHubRestorePreviewCategory(BaseModel):
  191. """What a single category looks like inside one backup commit."""
  192. category: RestoreCategory
  193. available: bool = Field(description="Whether this category is present in the commit")
  194. item_count: int = Field(default=0, description="Rows/profiles found, 0 when unavailable")
  195. detail: str | None = Field(default=None, description="Why unavailable, or extra context, in English")
  196. detail_code: str | None = Field(
  197. default=None, description="Key under backup.restoreFromGit.details, for the client to translate"
  198. )
  199. detail_params: dict[str, str | int] = Field(
  200. default_factory=dict, description="Interpolation values for detail_code"
  201. )
  202. class GitHubRestorePreview(BaseModel):
  203. """Schema for inspecting a commit before restoring from it."""
  204. success: bool
  205. message: str
  206. ref: str = Field(description="The concrete commit SHA that was inspected")
  207. commit: GitHubCommitInfo | None = None
  208. metadata_version: str | None = Field(default=None, description="version field from backup_metadata.json")
  209. categories: list[GitHubRestorePreviewCategory] = Field(default_factory=list)
  210. class GitHubRestoreRequest(BaseModel):
  211. """Schema for triggering a restore."""
  212. ref: str = Field(default="HEAD", pattern=REF_PATTERN, description="Commit SHA to restore from, or HEAD")
  213. categories: list[RestoreCategory] = Field(..., min_length=1, description="Categories to restore")
  214. overwrite_existing: bool = Field(
  215. default=False,
  216. description="Update rows that already exist locally. When false, only missing rows are inserted.",
  217. )
  218. @model_validator(mode="after")
  219. def deduplicate_categories(self) -> "GitHubRestoreRequest":
  220. # Same category twice would double-count the result totals.
  221. seen: list[RestoreCategory] = []
  222. for category in self.categories:
  223. if category not in seen:
  224. seen.append(category)
  225. self.categories = seen
  226. return self
  227. class GitHubRestoreNote(BaseModel):
  228. """One tally note, as a translation code plus the values it interpolates.
  229. Follows the ``backup.pathCheck`` contract already in use one card down in the
  230. same component: the server chooses the code and supplies typed params, and
  231. the client renders ``t(`...${code}`, { ...params, defaultValue: message })``.
  232. ``message`` is the English original, so a client that does not know a code
  233. yet still shows something sensible rather than the raw key.
  234. """
  235. code: str = Field(description="Key under backup.restoreFromGit.notes")
  236. params: dict[str, str | int] = Field(default_factory=dict, description="Interpolation values for code")
  237. message: str = Field(description="English rendering, used as the client's defaultValue")
  238. class GitHubRestoreCategoryResult(BaseModel):
  239. """Per-category outcome of a restore."""
  240. restored: int = 0
  241. skipped: int = 0
  242. failed: int = 0
  243. notes: list[GitHubRestoreNote] = Field(default_factory=list)
  244. class GitHubRestoreResponse(BaseModel):
  245. """Schema for the restore result."""
  246. success: bool
  247. message: str
  248. log_id: int | None = None
  249. ref: str | None = Field(default=None, description="The concrete commit SHA restored from")
  250. results: dict[str, GitHubRestoreCategoryResult] = Field(default_factory=dict)