| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312 |
- """Deleting users, archives and library files leaves no ids pointing at them on SQLite.
- SQLite runs with foreign keys off, so the ON DELETE SET NULL that PostgreSQL
- applies to print_log_entries.created_by_id, print_log_entries.archive_id and
- print_archives.library_file_id never fired: the print log kept a deleted user's
- id (so the entries were missing from "no user"), and archives kept the id of a
- deleted library file, which SQLite hands to the next file created.
- """
- from datetime import datetime, timezone
- import pytest
- from httpx import AsyncClient
- from sqlalchemy import select
- from backend.app.models.archive import PrintArchive
- from backend.app.models.library import LibraryFile
- from backend.app.models.print_log import PrintLogEntry
- from backend.app.models.user import User
- async def _user(db_session, name="leaver") -> User:
- user = User(username=name, password_hash="x", role="user")
- db_session.add(user)
- await db_session.commit()
- await db_session.refresh(user)
- return user
- async def _log_entry(db_session, **fields) -> PrintLogEntry:
- entry = PrintLogEntry(status="completed", print_name="Benchy", started_at=datetime.now(timezone.utc), **fields)
- db_session.add(entry)
- await db_session.commit()
- await db_session.refresh(entry)
- return entry
- async def _reload(db_session, model, row_id):
- statement = select(model).where(model.id == row_id).execution_options(populate_existing=True)
- return (await db_session.execute(statement)).scalar_one_or_none()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_deleting_a_user_clears_their_print_log_entries(async_client: AsyncClient, db_session):
- user = await _user(db_session)
- entry = await _log_entry(db_session, created_by_id=user.id)
- response = await async_client.delete(f"/api/v1/users/{user.id}")
- assert response.status_code == 204, response.text
- assert (await _reload(db_session, PrintLogEntry, entry.id)).created_by_id is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_deleting_a_user_with_their_items_clears_the_log_entries_of_their_archives(
- async_client: AsyncClient, db_session, printer_factory, archive_factory
- ):
- user = await _user(db_session)
- printer = await printer_factory()
- archive = await archive_factory(printer.id, created_by_id=user.id, with_run=False)
- entry = await _log_entry(db_session, archive_id=archive.id, created_by_id=user.id)
- response = await async_client.delete(f"/api/v1/users/{user.id}?delete_items=true")
- assert response.status_code == 204, response.text
- assert await _reload(db_session, PrintArchive, archive.id) is None
- reloaded = await _reload(db_session, PrintLogEntry, entry.id)
- assert reloaded.archive_id is None
- assert reloaded.created_by_id is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_deleting_a_user_with_their_items_clears_archives_of_their_library_files(
- async_client: AsyncClient, db_session, printer_factory, archive_factory
- ):
- user = await _user(db_session)
- library_file = LibraryFile(
- filename="part.3mf", file_path="library/files/part.3mf", file_type="3mf", file_size=1, created_by_id=user.id
- )
- db_session.add(library_file)
- await db_session.commit()
- printer = await printer_factory()
- archive = await archive_factory(printer.id, library_file_id=library_file.id, with_run=False)
- response = await async_client.delete(f"/api/v1/users/{user.id}?delete_items=true")
- assert response.status_code == 204, response.text
- assert (await _reload(db_session, PrintArchive, archive.id)).library_file_id is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_deleting_a_printer_with_its_archives_clears_their_log_entries(
- async_client: AsyncClient, db_session, printer_factory, archive_factory
- ):
- printer = await printer_factory()
- archive = await archive_factory(printer.id, with_run=False)
- entry = await _log_entry(db_session, archive_id=archive.id, thumbnail_path="archive/1/thumb.png")
- response = await async_client.delete(f"/api/v1/printers/{printer.id}?delete_archives=true")
- assert response.status_code == 200, response.text
- assert await _reload(db_session, PrintArchive, archive.id) is None
- reloaded = await _reload(db_session, PrintLogEntry, entry.id)
- assert reloaded.archive_id is None
- # The archive files stay on disk here (unlike a single archive delete), so
- # the print log keeps its picture.
- assert reloaded.thumbnail_path == "archive/1/thumb.png"
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_deleting_a_library_file_clears_the_archives_printed_from_it(
- async_client: AsyncClient, db_session, printer_factory, archive_factory
- ):
- library_file = LibraryFile(
- filename="ext.3mf", file_path="/mnt/share/ext.3mf", file_type="3mf", file_size=1, is_external=True
- )
- db_session.add(library_file)
- await db_session.commit()
- printer = await printer_factory()
- archive = await archive_factory(printer.id, library_file_id=library_file.id, with_run=False)
- response = await async_client.delete(f"/api/v1/library/files/{library_file.id}")
- assert response.status_code == 200, response.text
- assert await _reload(db_session, LibraryFile, library_file.id) is None
- assert (await _reload(db_session, PrintArchive, archive.id)).library_file_id is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_any_library_file_delete_clears_the_archives_printed_from_it(
- db_session, printer_factory, archive_factory
- ):
- """Not only the routes: any delete of the row applies the rule (core/sqlite_fk hooks)."""
- library_file = LibraryFile(filename="a.3mf", file_path="library/files/a.3mf", file_type="3mf", file_size=1)
- db_session.add(library_file)
- await db_session.commit()
- printer = await printer_factory()
- archive = await archive_factory(printer.id, library_file_id=library_file.id, with_run=False)
- await db_session.delete(library_file)
- await db_session.commit()
- assert (await _reload(db_session, PrintArchive, archive.id)).library_file_id is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_deleting_a_user_with_their_items_releases_other_users_queue_jobs(
- async_client: AsyncClient, db_session, printer_factory, archive_factory
- ):
- """Another user's job on the deleted user's library file or archive must not
- keep its id: SQLite gives the highest freed id to the next file or archive,
- and the job would print that one."""
- from backend.app.models.print_queue import PrintQueueItem
- owner = await _user(db_session, "owner")
- other = await _user(db_session, "other")
- library_file = LibraryFile(
- filename="part.3mf", file_path="library/files/part.3mf", file_type="3mf", file_size=1, created_by_id=owner.id
- )
- db_session.add(library_file)
- await db_session.commit()
- printer = await printer_factory()
- archive = await archive_factory(printer.id, created_by_id=owner.id, with_run=False)
- on_file = PrintQueueItem(printer_id=printer.id, library_file_id=library_file.id, position=1, created_by_id=other.id)
- on_archive = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, position=2, created_by_id=other.id)
- db_session.add_all([on_file, on_archive])
- await db_session.commit()
- on_file_id, on_archive_id = on_file.id, on_archive.id
- response = await async_client.delete(f"/api/v1/users/{owner.id}?delete_items=true")
- assert response.status_code == 204, response.text
- kept = await _reload(db_session, PrintQueueItem, on_file_id)
- assert kept.library_file_id is None
- assert kept.status == "cancelled"
- # ON DELETE CASCADE on print_queue.archive_id, as on PostgreSQL.
- assert await _reload(db_session, PrintQueueItem, on_archive_id) is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_a_rescan_that_drops_a_vanished_external_file_clears_its_archives(
- async_client: AsyncClient, db_session, printer_factory, archive_factory, tmp_path, monkeypatch
- ):
- from backend.app.core.config import settings as app_settings
- data_dir = tmp_path / "data"
- data_dir.mkdir()
- monkeypatch.setattr(app_settings, "base_dir", data_dir)
- monkeypatch.setattr(app_settings, "archive_dir", data_dir / "archive")
- share = tmp_path / "share"
- share.mkdir()
- monkeypatch.setenv("BAMBUDDY_EXTERNAL_ROOTS", str(share))
- (share / "bracket.stl").write_bytes(b"fakestl")
- folder = await async_client.post(
- "/api/v1/library/folders/external",
- json={"name": "Share", "external_path": str(share), "readonly": True, "show_hidden": False},
- )
- assert folder.status_code == 200, folder.text
- folder_id = folder.json()["id"]
- assert (await async_client.post(f"/api/v1/library/folders/{folder_id}/scan")).json()["added"] == 1
- file_id = (await async_client.get(f"/api/v1/library/files?folder_id={folder_id}")).json()[0]["id"]
- printer = await printer_factory()
- archive = await archive_factory(printer.id, library_file_id=file_id, with_run=False)
- (share / "bracket.stl").unlink()
- rescan = await async_client.post(f"/api/v1/library/folders/{folder_id}/scan")
- assert rescan.json()["removed"] == 1
- assert (await _reload(db_session, PrintArchive, archive.id)).library_file_id is None
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_deleting_a_user_with_their_items_applies_the_rules_to_their_jobs_and_batches(
- async_client: AsyncClient, db_session, printer_factory, archive_factory
- ):
- """Their queue jobs and batches go with a bulk delete; what points at those
- goes too (a job's model variants) or loses the link (another user's job in
- their batch), as on PostgreSQL."""
- from backend.app.models.print_batch import PrintBatch
- from backend.app.models.print_queue import PrintQueueItem, PrintQueueVariant
- owner = await _user(db_session, "owner")
- other = await _user(db_session, "other")
- library_file = LibraryFile(filename="v.3mf", file_path="library/files/v.3mf", file_type="3mf", file_size=1)
- batch = PrintBatch(name="Order", created_by_id=owner.id)
- db_session.add_all([library_file, batch])
- await db_session.commit()
- printer = await printer_factory()
- archive = await archive_factory(printer.id, with_run=False)
- owned = PrintQueueItem(printer_id=printer.id, archive_id=archive.id, position=1, created_by_id=owner.id)
- in_batch = PrintQueueItem(
- printer_id=printer.id, archive_id=archive.id, position=2, created_by_id=other.id, batch_id=batch.id
- )
- db_session.add_all([owned, in_batch])
- await db_session.commit()
- variant = PrintQueueVariant(queue_item_id=owned.id, library_file_id=library_file.id, target_model="X1C")
- db_session.add(variant)
- await db_session.commit()
- variant_id, in_batch_id = variant.id, in_batch.id
- response = await async_client.delete(f"/api/v1/users/{owner.id}?delete_items=true")
- assert response.status_code == 204, response.text
- assert await _reload(db_session, PrintQueueVariant, variant_id) is None
- assert (await _reload(db_session, PrintQueueItem, in_batch_id)).batch_id is None
- async def _printing_reprint(db_session, printer_factory, archive_factory):
- """An archive made on printer A, being reprinted right now on printer B."""
- from backend.app.models.print_queue import PrintQueueItem
- printer_a = await printer_factory(name="A", serial_number="SA")
- printer_b = await printer_factory(name="B", serial_number="SB")
- archive = await archive_factory(printer_a.id, with_run=False)
- item = PrintQueueItem(printer_id=printer_b.id, archive_id=archive.id, position=1, status="printing")
- db_session.add(item)
- await db_session.commit()
- return printer_a.id, archive, item.id
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_deleting_a_printer_with_its_archives_waits_for_a_running_reprint(
- async_client: AsyncClient, db_session, printer_factory, archive_factory
- ):
- """Like DELETE /archives/{id}: an archive whose queue item is printing is
- not deleted from under the dispatcher."""
- from backend.app.models.print_queue import PrintQueueItem
- printer_a_id, archive, item_id = await _printing_reprint(db_session, printer_factory, archive_factory)
- response = await async_client.delete(f"/api/v1/printers/{printer_a_id}?delete_archives=true")
- assert response.status_code == 409, response.text
- assert "printing" in response.json()["detail"]
- assert await _reload(db_session, PrintArchive, archive.id) is not None
- assert await _reload(db_session, PrintQueueItem, item_id) is not None
- @pytest.mark.asyncio
- @pytest.mark.integration
- @pytest.mark.parametrize("purge_stats", [False, True])
- async def test_the_archive_purge_skips_an_archive_that_is_being_printed(
- async_client: AsyncClient, db_session, printer_factory, archive_factory, purge_stats
- ):
- from datetime import datetime, timedelta, timezone
- from backend.app.models.print_queue import PrintQueueItem
- _, archive, item_id = await _printing_reprint(db_session, printer_factory, archive_factory)
- archive.created_at = datetime.now(timezone.utc) - timedelta(days=400)
- await db_session.commit()
- response = await async_client.post(
- "/api/v1/archives/purge", json={"older_than_days": 365, "purge_stats": purge_stats}
- )
- assert response.status_code == 200, response.text
- assert response.json()["deleted"] == 0
- reloaded = await _reload(db_session, PrintArchive, archive.id)
- assert reloaded is not None and reloaded.deleted_at is None
- assert await _reload(db_session, PrintQueueItem, item_id) is not None
|