library_store.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. """Store a virtual printer's upload in the File Manager.
  2. Every server mode keeps what the slicer sends in the File Manager: library
  3. mode right away, review mode once the upload is approved, queue mode for the
  4. queue items that print it. The archive only gets a file once it prints.
  5. """
  6. import asyncio
  7. import logging
  8. from pathlib import Path
  9. from sqlalchemy import exists, select
  10. from sqlalchemy.ext.asyncio import AsyncSession
  11. from backend.app.models.library import LibraryFile, LibraryFolder, LibraryTag
  12. from backend.app.models.print_queue import PrintQueueItem
  13. logger = logging.getLogger(__name__)
  14. SOURCE_TYPE = "virtual_printer"
  15. DEFAULT_FOLDER_NAME = "Virtual Printer"
  16. async def upload_folder(db: AsyncSession, folder_id: int | None) -> LibraryFolder:
  17. """The folder a virtual printer's uploads land in.
  18. Its configured folder, or the shared top-level "Virtual Printer" folder
  19. when it has none, the folder was deleted, or it is an external folder (an
  20. external mount refuses a second file of the same name, and slicer names
  21. like "Plate_1.gcode.3mf" repeat). Only a shared or ownerless folder of
  22. that name counts: a user's private one of the same name would take every
  23. such upload out of everyone else's sight (#3201). None yet: one is made.
  24. """
  25. if folder_id is not None:
  26. folder = await db.get(LibraryFolder, folder_id)
  27. if folder is not None and not folder.is_external:
  28. return folder
  29. logger.warning("Virtual printer folder %s is gone or external; using '%s'", folder_id, DEFAULT_FOLDER_NAME)
  30. shared = (
  31. await db.execute(
  32. select(LibraryFolder)
  33. .where(
  34. LibraryFolder.name == DEFAULT_FOLDER_NAME,
  35. LibraryFolder.parent_id.is_(None),
  36. LibraryFolder.is_external.is_(False),
  37. LibraryFolder.shared.is_(True) | LibraryFolder.created_by_id.is_(None),
  38. )
  39. .order_by(LibraryFolder.id)
  40. .limit(1)
  41. )
  42. ).scalar_one_or_none()
  43. if shared is not None:
  44. return shared
  45. folder = LibraryFolder(name=DEFAULT_FOLDER_NAME, parent_id=None, created_by_id=None, shared=True)
  46. db.add(folder)
  47. await db.flush()
  48. return folder
  49. async def _apply_tags(db: AsyncSession, library_file: LibraryFile, tags: str | None) -> None:
  50. """Attach the comma-separated ``tags`` (review mode's field), creating missing ones."""
  51. from backend.app.api.routes.library_tags import _name_key
  52. names = []
  53. for raw in (tags or "").split(","):
  54. name = raw.strip()[:64]
  55. if name and _name_key(name) not in {_name_key(n) for n in names}:
  56. names.append(name)
  57. if not names:
  58. return
  59. await db.refresh(library_file, ["tags"])
  60. have = {tag.name_key for tag in library_file.tags}
  61. for name in names:
  62. key = _name_key(name)
  63. if key in have:
  64. continue
  65. tag = (await db.execute(select(LibraryTag).where(LibraryTag.name_key == key))).scalar_one_or_none()
  66. if tag is None:
  67. tag = LibraryTag(name=name, name_key=key)
  68. db.add(tag)
  69. await db.flush()
  70. library_file.tags.append(tag)
  71. have.add(key)
  72. async def upload_settings(db: AsyncSession, vp_id: int) -> tuple[int | None, bool]:
  73. """``(library_folder_id, queue_remove_after_print)`` of a virtual printer.
  74. Read at each upload rather than held by the running instance, so changing
  75. them takes effect without restarting the virtual printer.
  76. """
  77. from backend.app.models.virtual_printer import VirtualPrinter
  78. row = (
  79. await db.execute(
  80. select(VirtualPrinter.library_folder_id, VirtualPrinter.queue_remove_after_print).where(
  81. VirtualPrinter.id == vp_id
  82. )
  83. )
  84. ).one_or_none()
  85. if row is None:
  86. return None, False
  87. return row[0], bool(row[1])
  88. async def store_upload(
  89. db: AsyncSession,
  90. *,
  91. source_path: Path,
  92. filename: str,
  93. folder_id: int | None,
  94. owner_id: int | None = None,
  95. notes: str | None = None,
  96. project_id: int | None = None,
  97. tags: str | None = None,
  98. reuse: bool = True,
  99. ) -> tuple[LibraryFile, bool]:
  100. """Put an upload into the File Manager; returns ``(file, reused)``.
  101. The same file sent again under the same name into the same folder is the
  102. file already there (``reused``), so sending a plate twice does not leave
  103. two copies; a queue gets a job for each send all the same. Only files a
  104. virtual printer stored count: a file someone put there by hand is never
  105. taken over, and neither is another sender's (``owner_id``, the user whose
  106. personal access code sent it, #1430). Notes, project and tags are applied
  107. to the file either way.
  108. A file a queue job removes once it prints ("Remove from File Manager once
  109. it prints") is never shared: such a job asks for a file of its own
  110. (``reuse=False``), and no other upload takes that one, or the job's print
  111. would remove a file someone else stored to keep. Commits.
  112. """
  113. from backend.app.api.routes.library import save_3mf_bytes_to_library
  114. from backend.app.services.archive import ArchiveService
  115. folder = await upload_folder(db, folder_id)
  116. file_hash = await asyncio.to_thread(ArchiveService.compute_file_hash, source_path)
  117. # The job that removes this file at dispatch: not a file to share.
  118. removed_on_print = exists().where(
  119. PrintQueueItem.library_file_id == LibraryFile.id,
  120. PrintQueueItem.cleanup_library_after_dispatch.is_(True),
  121. )
  122. existing = (
  123. None
  124. if not reuse
  125. else (
  126. await db.execute(
  127. LibraryFile.active()
  128. .where(
  129. LibraryFile.folder_id == folder.id,
  130. LibraryFile.filename == filename,
  131. LibraryFile.file_hash == file_hash,
  132. LibraryFile.source_type == SOURCE_TYPE,
  133. ~removed_on_print,
  134. # Only the same sender's: someone else's copy stays theirs.
  135. LibraryFile.created_by_id.is_(None) if owner_id is None else LibraryFile.created_by_id == owner_id,
  136. )
  137. .order_by(LibraryFile.id)
  138. .limit(1)
  139. )
  140. ).scalar_one_or_none()
  141. )
  142. if existing is not None:
  143. library_file, reused = existing, True
  144. else:
  145. library_file, reused = await save_3mf_bytes_to_library(
  146. db,
  147. source_path=source_path,
  148. filename=filename,
  149. folder_id=folder.id,
  150. source_type=SOURCE_TYPE,
  151. owner_id=owner_id,
  152. # No source URL: duplicates are found by content above.
  153. dedupe_within=None,
  154. )
  155. if notes:
  156. library_file.notes = notes
  157. if project_id is not None:
  158. library_file.project_id = project_id
  159. await _apply_tags(db, library_file, tags)
  160. await db.commit()
  161. return library_file, reused