Coverage for src/paperap/models/abstract/queryset.py: 73%

315 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-22 16:02 -0400

1""" 

2---------------------------------------------------------------------------- 

3 

4 METADATA: 

5 

6 File: queryset.py 

7 Project: paperap 

8 Created: 2025-03-04 

9 Version: 0.0.9 

10 Author: Jess Mann 

11 Email: jess@jmann.me 

12 Copyright (c) 2025 Jess Mann 

13 

14---------------------------------------------------------------------------- 

15 

16 LAST MODIFIED: 

17 

18 2025-03-04 By Jess Mann 

19 

20""" 

21 

22from __future__ import annotations 

23 

24import copy 

25import logging 

26from datetime import datetime 

27from string import Template 

28from typing import TYPE_CHECKING, Any, Final, Generic, Iterable, Iterator, Self, TypeAlias, Union, override 

29 

30from pydantic import HttpUrl 

31from typing_extensions import TypeVar 

32 

33from paperap.exceptions import FilterDisabledError, MultipleObjectsFoundError, ObjectNotFoundError 

34 

35if TYPE_CHECKING: 

36 from paperap.models.abstract.model import BaseModel, StandardModel 

37 from paperap.resources.base import BaseResource, StandardResource 

38 

39logger = logging.getLogger(__name__) 

40 

41# _BaseResource = TypeVar("_BaseResource", bound="BaseResource", default="BaseResource") 

42 

43type ClientResponse = dict[str, Any] | list[dict[str, Any]] | None 

44 

45 

46class BaseQuerySet[_Model: BaseModel](Iterable[_Model]): 

47 """ 

48 A lazy-loaded, chainable query interface for Paperless NGX resources. 

49 

50 BaseQuerySet provides pagination, filtering, and caching functionality similar to Django's QuerySet. 

51 It's designed to be lazy - only fetching data when it's actually needed. 

52 

53 Args: 

54 resource: The BaseResource instance. 

55 filters: Initial filter parameters. 

56 _cache: Optional internal result cache. 

57 _fetch_all: Whether all results have been fetched. 

58 _next_url: URL for the next page of results. 

59 _last_response: Optional last response from the API. 

60 _iter: Optional iterator for the results. 

61 

62 Returns: 

63 A new instance of BaseQuerySet. 

64 

65 Examples: 

66 # Create a QuerySet for documents 

67 >>> docs = client.documents() 

68 >>> for doc in docs: 

69 ... print(doc.id) 

70 1 

71 2 

72 3 

73 

74 """ 

75 

76 resource: "BaseResource[_Model, Self]" 

77 filters: dict[str, Any] 

78 _last_response: ClientResponse | None = None 

79 _result_cache: list[_Model] = [] 

80 _fetch_all: bool = False 

81 _next_url: str | None = None 

82 _urls_fetched: list[str] = [] 

83 _iter: Iterator[_Model] | None 

84 

85 def __init__( 

86 self, 

87 resource: "BaseResource[_Model, Self]", 

88 filters: dict[str, Any] | None = None, 

89 _cache: list[_Model] | None = None, 

90 _fetch_all: bool = False, 

91 _next_url: str | None = None, 

92 _last_response: ClientResponse = None, 

93 _iter: Iterator[_Model] | None = None, 

94 _urls_fetched: list[str] | None = None, 

95 ) -> None: 

96 self.resource = resource 

97 self.filters = filters or {} 

98 self._result_cache = _cache or [] 

99 self._fetch_all = _fetch_all 

100 self._next_url = _next_url 

101 self._urls_fetched = _urls_fetched or [] 

102 self._last_response = _last_response 

103 self._iter = _iter 

104 

105 super().__init__() 

106 

107 @property 

108 def _model(self) -> type[_Model]: 

109 """ 

110 Return the model class associated with the resource. 

111 

112 Returns: 

113 The model class 

114 

115 Examples: 

116 # Create a model instance 

117 >>> model = queryset._model(**params) 

118 

119 """ 

120 return self.resource.model_class 

121 

122 @property 

123 def _meta(self) -> "BaseModel.Meta[Any]": 

124 """ 

125 Return the model's metadata. 

126 

127 Returns: 

128 The model's metadata 

129 

130 Examples: 

131 # Get the model's metadata 

132 >>> queryset._meta.read_only_fields 

133 {'id', 'added', 'modified'} 

134 

135 """ 

136 return self._model._meta # pyright: ignore[reportPrivateUsage] # pylint: disable=protected-access 

137 

138 def _reset(self) -> None: 

139 """ 

140 Reset the QuerySet to its initial state. 

141 

142 This clears the result cache and resets the fetch state. 

143 """ 

144 self._result_cache = [] 

145 self._fetch_all = False 

146 self._next_url = None 

147 self._urls_fetched = [] 

148 self._last_response = None 

149 self._iter = None 

150 

151 def _update_filters(self, values: dict[str, Any]) -> None: 

152 """ 

153 Update the current filters with new values. 

154 

155 This updates the current queryset instance. It does not return a new instance. For that reason, 

156 do not call this directly. Call filter() or exclude() instead. 

157 

158 Args: 

159 values: New filter values to add 

160 

161 Raises: 

162 FilterDisabledError: If a filter is not allowed by the resource 

163 

164 Examples: 

165 # Update filters with new values 

166 queryset._update_filters({"correspondent": 1}) 

167 

168 # Update filters with multiple values 

169 queryset._update_filters({"correspondent": 1, "document_type": 2}) 

170 

171 """ 

172 for key, _value in values.items(): 

173 if not self._meta.filter_allowed(key): 

174 raise FilterDisabledError(f"Filtering by {key} for {self.resource.name} does not appear to be supported by the API.") 

175 

176 if values: 

177 # Reset the cache if filters change 

178 self._reset() 

179 self.filters.update(**values) 

180 

181 def filter(self, **kwargs: Any) -> Self: 

182 """ 

183 Return a new QuerySet with the given filters applied. 

184 

185 Args: 

186 **kwargs: Filters to apply, where keys are field names and values are desired values. 

187 Supports Django-style lookups like field__contains, field__in, etc. 

188 

189 Returns: 

190 A new QuerySet with the additional filters applied 

191 

192 Examples: 

193 # Get documents with specific correspondent 

194 docs = client.documents.filter(correspondent=1) 

195 

196 # Get documents with specific correspondent and document type 

197 docs = client.documents.filter(correspondent=1, document_type=2) 

198 

199 # Get documents with title containing "invoice" 

200 docs = client.documents.filter(title__contains="invoice") 

201 

202 # Get documents with IDs in a list 

203 docs = client.documents.filter(id__in=[1, 2, 3]) 

204 

205 """ 

206 processed_filters = {} 

207 

208 for key, value in kwargs.items(): 

209 # Handle list values for __in lookups 

210 if isinstance(value, (list, set, tuple)): 

211 # Convert list to comma-separated string for the API 

212 processed_value = ",".join(str(item) for item in value) 

213 processed_filters[key] = processed_value 

214 # Handle boolean values 

215 elif isinstance(value, bool): 

216 processed_filters[key] = str(value).lower() 

217 # Handle normal values 

218 else: 

219 processed_filters[key] = value 

220 

221 return self._chain(filters={**self.filters, **processed_filters}) 

222 

223 def exclude(self, **kwargs: Any) -> Self: 

224 """ 

225 Return a new QuerySet excluding objects with the given filters. 

226 

227 Args: 

228 **kwargs: Filters to exclude, where keys are field names and values are excluded values 

229 

230 Returns: 

231 A new QuerySet excluding objects that match the filters 

232 

233 Examples: 

234 # Get documents with any correspondent except ID 1 

235 docs = client.documents.exclude(correspondent=1) 

236 

237 """ 

238 # Transform each key to its "not" equivalent 

239 exclude_filters = {} 

240 for key, value in kwargs.items(): 

241 if "__" in key: 

242 field, lookup = key.split("__", 1) 

243 # If it already has a "not" prefix, remove it 

244 if lookup.startswith("not_"): 

245 exclude_filters[f"{field}__{lookup[4:]}"] = value 

246 else: 

247 exclude_filters[f"{field}__not_{lookup}"] = value 

248 else: 

249 exclude_filters[f"{key}__not"] = value 

250 

251 return self._chain(filters={**self.filters, **exclude_filters}) 

252 

253 def get(self, pk: Any) -> _Model: 

254 """ 

255 Retrieve a single object from the API. 

256 

257 Raises NotImplementedError. Subclasses may implement this. 

258 

259 Args: 

260 pk: The primary key (e.g. the id) of the object to retrieve 

261 

262 Returns: 

263 A single object matching the query 

264 

265 Raises: 

266 ObjectNotFoundError: If no object or multiple objects are found 

267 NotImplementedError: If the method is not implemented by the subclass 

268 

269 Examples: 

270 # Get document with ID 123 

271 doc = client.documents.get(123) 

272 

273 """ 

274 raise NotImplementedError("Getting a single resource is not defined by BaseModels without an id.") 

275 

276 def _get_last_count(self) -> int | None: 

277 if self._last_response is None: 

278 return None 

279 if isinstance(self._last_response, list): 

280 return len(self._last_response) 

281 return self._last_response.get("count") 

282 

283 def count(self) -> int: 

284 """ 

285 Return the total number of objects in the queryset. 

286 

287 Returns: 

288 The total count of objects matching the filters 

289 

290 Raises: 

291 NotImplementedError: If the response does not have a count attribute 

292 

293 """ 

294 # If we have a last response, we can use the "count" field 

295 if (count := self._get_last_count()) is not None: 

296 return count 

297 

298 # Get one page of results, to populate last response 

299 _iter = self._request_iter(params=self.filters) 

300 

301 # TODO Hack 

302 for _ in _iter: 

303 break 

304 

305 if not self._last_response: 

306 # I don't think this should ever occur, but just in case. 

307 raise NotImplementedError("Requested iter, but no last response") 

308 

309 if (count := self._get_last_count()) is not None: 

310 return count 

311 

312 # I don't think this should ever occur, but just in case. 

313 raise NotImplementedError(f"Unexpected Error: Could not determine count of objects. Last response: {self._last_response}") 

314 

315 def count_this_page(self) -> int: 

316 """ 

317 Return the number of objects on the current page. 

318 

319 Returns: 

320 The count of objects on the current page 

321 

322 Raises: 

323 NotImplementedError: If _last_response is not set 

324 

325 """ 

326 # If we have a last response, we can count it without a new request 

327 if self._last_response: 

328 if isinstance(self._last_response, list): 

329 return len(self._last_response) 

330 results = self._last_response.get("results", []) 

331 return len(results) 

332 

333 # Get one page of results, to populate last response 

334 _iter = self._request_iter(params=self.filters) 

335 

336 # TODO Hack 

337 for _ in _iter: 

338 break 

339 

340 if not self._last_response: 

341 # I don't think this should ever occur, but just in case. 

342 raise NotImplementedError("Requested iter, but no last response") 

343 

344 if isinstance(self._last_response, list): 

345 return len(self._last_response) 

346 results = self._last_response.get("results", []) 

347 return len(results) 

348 

349 def all(self) -> Self: 

350 """ 

351 Return a new QuerySet that copies the current one. 

352 

353 Returns: 

354 A copy of the current BaseQuerySet 

355 

356 """ 

357 return self._chain() 

358 

359 def order_by(self, *fields: str) -> Self: 

360 """ 

361 Return a new QuerySet ordered by the specified fields. 

362 

363 Args: 

364 *fields: Field names to order by. Prefix with '-' for descending order. 

365 

366 Returns: 

367 A new QuerySet with the ordering applied 

368 

369 Examples: 

370 # Order documents by title ascending 

371 docs = client.documents.order_by('title') 

372 

373 # Order documents by added date descending 

374 docs = client.documents.order_by('-added') 

375 

376 """ 

377 if not fields: 

378 return self 

379 

380 # Combine with existing ordering if any 

381 ordering = self.filters.get("ordering", []) 

382 if isinstance(ordering, str): 

383 ordering = [ordering] 

384 elif not isinstance(ordering, list): 

385 ordering = list(ordering) 

386 

387 # Add new ordering fields 

388 new_ordering = ordering + list(fields) 

389 

390 # Join with commas for API 

391 ordering_param = ",".join(new_ordering) 

392 

393 return self._chain(filters={**self.filters, "ordering": ordering_param}) 

394 

395 def first(self) -> _Model | None: 

396 """ 

397 Return the first object in the QuerySet, or None if empty. 

398 

399 Returns: 

400 The first object or None if no objects match 

401 

402 """ 

403 if self._result_cache and len(self._result_cache) > 0: 

404 return self._result_cache[0] 

405 

406 # If not cached, create a copy limited to 1 result 

407 results = list(self._chain(filters={**self.filters, "limit": 1})) 

408 return results[0] if results else None 

409 

410 def last(self) -> _Model | None: 

411 """ 

412 Return the last object in the QuerySet, or None if empty. 

413 

414 Note: This requires fetching all results to determine the last one. 

415 

416 Returns: 

417 The last object or None if no objects match 

418 

419 """ 

420 # If we have all results, we can just return the last one 

421 if self._fetch_all: 

422 if self._result_cache and len(self._result_cache) > 0: 

423 return self._result_cache[-1] 

424 return None 

425 

426 # We need all results to get the last one 

427 self._fetch_all_results() 

428 

429 if self._result_cache and len(self._result_cache) > 0: 

430 return self._result_cache[-1] 

431 return None 

432 

433 def exists(self) -> bool: 

434 """ 

435 Return True if the QuerySet contains any results. 

436 

437 Returns: 

438 True if there are any objects matching the filters 

439 

440 """ 

441 # Check the cache before potentially making a new request 

442 if self._fetch_all or self._result_cache: 

443 return len(self._result_cache) > 0 

444 

445 # Check if there's at least one result 

446 return self.first() is not None 

447 

448 def none(self) -> Self: 

449 """ 

450 Return an empty QuerySet. 

451 

452 Returns: 

453 An empty QuerySet 

454 

455 """ 

456 return self._chain(filters={"limit": 0}) 

457 

458 def filter_field_by_str(self, field: str, value: str, *, exact: bool = True, case_insensitive: bool = True) -> Self: 

459 """ 

460 Filter a queryset based on a given field. 

461 

462 This allows subclasses to easily implement custom filter methods. 

463 

464 Args: 

465 field: The field name to filter by. 

466 value: The value to filter against. 

467 exact: Whether to filter by an exact match. 

468 case_insensitive: Whether the filter should be case-insensitive. 

469 

470 Returns: 

471 A new QuerySet instance with the filter applied. 

472 

473 """ 

474 if exact: 

475 lookup = f"{field}__iexact" if case_insensitive else field 

476 else: 

477 lookup = f"{field}__icontains" if case_insensitive else f"{field}__contains" 

478 

479 return self.filter(**{lookup: value}) 

480 

481 def _fetch_all_results(self) -> None: 

482 """ 

483 Fetch all results from the API and populate the cache. 

484 

485 Returns: 

486 None 

487 

488 """ 

489 if self._fetch_all: 

490 return 

491 

492 # Clear existing cache if any 

493 self._result_cache = [] 

494 

495 # Initial fetch 

496 iterator = self._request_iter(params=self.filters) 

497 

498 # Collect results from initial page 

499 # TODO: Consider itertools chain for performance reasons (?) 

500 self._result_cache.extend(list(iterator)) 

501 

502 # Fetch additional pages if available 

503 while self._last_response and self._next_url: 

504 iterator = self._request_iter(url=self._next_url) 

505 self._result_cache.extend(list(iterator)) 

506 

507 self._fetch_all = True 

508 

509 def _request_iter(self, url: str | HttpUrl | Template | None = None, params: dict[str, Any] | None = None) -> Iterator[_Model]: 

510 """ 

511 Get an iterator of resources. 

512 

513 Args: 

514 url: The URL to request, if different from the resource's default. 

515 params: Query parameters. 

516 

517 Returns: 

518 An iterator over the resources. 

519 

520 Raises: 

521 NotImplementedError: If the request cannot be completed. 

522 

523 Examples: 

524 # Iterate over documents 

525 for doc in queryset._request_iter(): 

526 print(doc) 

527 

528 """ 

529 if not (response := self.resource.request_raw(url=url, params=params)): 

530 logger.debug("No response from request.") 

531 return 

532 

533 self._last_response = response 

534 

535 yield from self.resource.handle_response(response) 

536 

537 def _get_next(self, response: ClientResponse | None = None) -> str | None: 

538 """ 

539 Get the next url, and adjust our references accordingly. 

540 """ 

541 # Allow passing a different response 

542 if response is None: 

543 response = self._last_response 

544 

545 if isinstance(response, list): 

546 return None 

547 

548 # Last response is not set 

549 if not response or not (next_url := response.get("next")): 

550 self._next_url = None 

551 return None 

552 

553 # For safety, check both instance attributes, even though the first check isn't strictly necessary 

554 # this hopefully future proofs any changes to the implementation 

555 if next_url == self._next_url or next_url in self._urls_fetched: 

556 logger.debug( 

557 "Next URL was previously fetched. Stopping iteration. URL: %s, Already Fetched: %s", 

558 next_url, 

559 self._urls_fetched, 

560 ) 

561 self._next_url = None 

562 return None 

563 

564 # Cache it 

565 self._next_url = next_url 

566 self._urls_fetched.append(next_url) 

567 return self._next_url 

568 

569 def _chain(self, **kwargs: Any) -> Self: 

570 """ 

571 Return a copy of the current BaseQuerySet with updated attributes. 

572 

573 Args: 

574 **kwargs: Attributes to update in the new BaseQuerySet 

575 

576 Returns: 

577 A new QuerySet with the updated attributes 

578 

579 """ 

580 # Create a new BaseQuerySet with copied attributes 

581 clone = self.__class__(self.resource) # type: ignore # pyright not handling Self correctly 

582 

583 # Copy attributes from self 

584 clone.filters = copy.deepcopy(self.filters) 

585 # Do not copy the cache, fetch_all, etc, since filters may change it 

586 

587 # Update with provided kwargs 

588 for key, value in kwargs.items(): 

589 if key == "filters" and value: 

590 clone._update_filters(value) # pylint: disable=protected-access 

591 else: 

592 setattr(clone, key, value) 

593 

594 return clone 

595 

596 @override 

597 def __iter__(self) -> Iterator[_Model]: 

598 """ 

599 Iterate over the objects in the QuerySet. 

600 

601 Returns: 

602 An iterator over the objects 

603 

604 """ 

605 # If we have a fully populated cache, use it 

606 if self._fetch_all: 

607 yield from self._result_cache 

608 return 

609 

610 if not self._iter: 

611 # Start a new iteration 

612 self._iter = self._request_iter(params=self.filters) 

613 

614 # Yield objects from the current page 

615 for obj in self._iter: 

616 self._result_cache.append(obj) 

617 yield obj 

618 

619 self._get_next() 

620 

621 # If there are more pages, keep going 

622 count = 0 

623 while self._next_url: 

624 count += 1 

625 self._iter = self._request_iter(url=self._next_url) 

626 

627 # Yield objects from the current page 

628 for obj in self._iter: 

629 self._result_cache.append(obj) 

630 yield obj 

631 

632 self._get_next() 

633 

634 # We've fetched everything 

635 self._fetch_all = True 

636 self._iter = None 

637 

638 def __len__(self) -> int: 

639 """ 

640 Return the number of objects in the QuerySet. 

641 

642 Returns: 

643 The count of objects 

644 

645 """ 

646 return self.count() 

647 

648 def __bool__(self) -> bool: 

649 """ 

650 Return True if the QuerySet has any results. 

651 

652 Returns: 

653 True if there are any objects matching the filters 

654 

655 """ 

656 return self.exists() 

657 

658 def __getitem__(self, key: int | slice) -> _Model | list[_Model]: 

659 """ 

660 Retrieve an item or slice of items from the QuerySet. 

661 

662 Args: 

663 key: An integer index or slice 

664 

665 Returns: 

666 A single object or list of objects 

667 

668 Raises: 

669 IndexError: If the index is out of range 

670 

671 """ 

672 if isinstance(key, slice): 

673 # Handle slicing 

674 start = key.start if key.start is not None else 0 

675 stop = key.stop 

676 

677 if start < 0 or (stop is not None and stop < 0): 

678 # Negative indexing requires knowing the full size 

679 self._fetch_all_results() 

680 return self._result_cache[key] 

681 

682 # Optimize by using limit/offset if available 

683 if start == 0 and stop is not None: 

684 # Simple limit 

685 clone = self._chain(filters={**self.filters, "limit": stop}) 

686 results = list(clone) 

687 return results 

688 

689 if start > 0 and stop is not None: 

690 # Limit with offset 

691 clone = self._chain( 

692 filters={ 

693 **self.filters, 

694 "limit": stop - start, 

695 "offset": start, 

696 } 

697 ) 

698 results = list(clone) 

699 return results 

700 

701 if start > 0 and stop is None: 

702 # Just offset 

703 clone = self._chain(filters={**self.filters, "offset": start}) 

704 self._fetch_all_results() # We need all results after the offset 

705 return self._result_cache 

706 

707 # Default to fetching all and slicing 

708 self._fetch_all_results() 

709 return self._result_cache[key] 

710 

711 # Handle integer indexing 

712 if key < 0: 

713 # Negative indexing requires the full result set 

714 self._fetch_all_results() 

715 return self._result_cache[key] 

716 

717 # Positive indexing - we can optimize with limit/offset 

718 if len(self._result_cache) > key: 

719 # Already have this item cached 

720 return self._result_cache[key] 

721 

722 # Fetch specific item by position 

723 clone = self._chain(filters={**self.filters, "limit": 1, "offset": key}) 

724 results = list(clone) 

725 if not results: 

726 raise IndexError(f"BaseQuerySet index {key} out of range") 

727 return results[0] 

728 

729 def __contains__(self, item: Any) -> bool: 

730 """ 

731 Return True if the QuerySet contains the given object. 

732 

733 Args: 

734 item: The object to check for 

735 

736 Returns: 

737 True if the object is in the QuerySet 

738 

739 """ 

740 if not isinstance(item, self._model): 

741 return False 

742 

743 return any(obj == item for obj in self) 

744 

745 

746class StandardQuerySet[_Model: StandardModel](BaseQuerySet[_Model]): 

747 """ 

748 A queryset for StandardModel instances (i.e. BaseModels with standard fields, like id). 

749 

750 Returns: 

751 A new instance of StandardModel. 

752 

753 Raises: 

754 ValueError: If resource is not provided. 

755 

756 Examples: 

757 # Create a StandardModel instance 

758 model = StandardModel(id=1) 

759 

760 Args: 

761 resource: The BaseResource instance. 

762 filters: Initial filter parameters. 

763 

764 Returns: 

765 A new instance of StandardQuerySet. 

766 

767 Raises: 

768 ObjectNotFoundError: If no object or multiple objects are found. 

769 

770 Examples: 

771 # Create a StandardQuerySet for documents 

772 docs = StandardQuerySet(resource=client.documents) 

773 

774 """ 

775 

776 resource: "StandardResource[_Model, Self]" # type: ignore # pyright is getting inheritance wrong 

777 

778 @override 

779 def get(self, pk: int) -> _Model: 

780 """ 

781 Retrieve a single object from the API. 

782 

783 Args: 

784 pk: The ID of the object to retrieve 

785 

786 Returns: 

787 A single object matching the query 

788 

789 Raises: 

790 ObjectNotFoundError: If no object or multiple objects are found 

791 

792 Examples: 

793 # Get document with ID 123 

794 doc = client.documents.get(123) 

795 

796 """ 

797 # Attempt to find it in the result cache 

798 if self._result_cache: 

799 for obj in self._result_cache: 

800 if obj.id == pk: 

801 return obj 

802 

803 # Direct lookup by ID - use the resource's get method 

804 return self.resource.get(pk) 

805 

806 def id(self, value: int | list[int]) -> Self: 

807 """ 

808 Filter models by ID. 

809 

810 Args: 

811 value: The ID or list of IDs to filter by 

812 

813 Returns: 

814 Filtered QuerySet 

815 

816 """ 

817 if isinstance(value, list): 

818 return self.filter(id__in=value) 

819 return self.filter(id=value) 

820 

821 @override 

822 def __contains__(self, item: Any) -> bool: 

823 """ 

824 Return True if the QuerySet contains the given object. 

825 

826 NOTE: This method only ensures a match by ID, not by full object equality. 

827 This is intentional, as the object may be outdated or not fully populated. 

828 

829 Args: 

830 item: The object or ID to check for 

831 

832 Returns: 

833 True if the object is in the QuerySet 

834 

835 """ 

836 # Handle integers directly 

837 if isinstance(item, int): 

838 return any(obj.id == item for obj in self) 

839 

840 # Handle model objects that have an id attribute 

841 try: 

842 if hasattr(item, "id"): 

843 return any(obj.id == item.id for obj in self) 

844 except (AttributeError, TypeError): 

845 pass 

846 

847 # For any other type, it's not in the queryset 

848 return False 

849 

850 def bulk_action(self, action: str, **kwargs: Any) -> ClientResponse: 

851 """ 

852 Perform a bulk action on all objects in the queryset. 

853 

854 This method fetches all IDs in the queryset and passes them to the resource's bulk_action method. 

855 

856 Args: 

857 action: The action to perform 

858 **kwargs: Additional parameters for the action 

859 

860 Returns: 

861 The API response 

862 

863 Raises: 

864 NotImplementedError: If the resource doesn't support bulk actions 

865 

866 """ 

867 if not (fn := getattr(self.resource, "bulk_action", None)): 

868 raise NotImplementedError(f"Resource {self.resource.name} does not support bulk actions") 

869 

870 # Fetch all IDs in the queryset 

871 # We only need IDs, so optimize by requesting just the ID field if possible 

872 ids = [obj.id for obj in self] 

873 

874 if not ids: 

875 return {"success": True, "count": 0} 

876 

877 return fn(action, ids, **kwargs) 

878 

879 def bulk_delete(self) -> ClientResponse: 

880 """ 

881 Delete all objects in the queryset. 

882 

883 Returns: 

884 The API response 

885 

886 """ 

887 return self.bulk_action("delete") 

888 

889 def bulk_update(self, **kwargs: Any) -> ClientResponse: 

890 """ 

891 Update all objects in the queryset with the given values. 

892 

893 Args: 

894 **kwargs: Fields to update 

895 

896 Returns: 

897 The API response 

898 

899 """ 

900 if not (fn := getattr(self.resource, "bulk_update", None)): 

901 raise NotImplementedError(f"Resource {self.resource.name} does not support bulk updates") 

902 

903 # Fetch all IDs in the queryset 

904 ids = [obj.id for obj in self] 

905 

906 if not ids: 

907 return {"success": True, "count": 0} 

908 

909 return fn(ids, **kwargs) 

910 

911 def bulk_assign_tags(self, tag_ids: list[int], remove_existing: bool = False) -> ClientResponse: 

912 """ 

913 Assign tags to all objects in the queryset. 

914 

915 Args: 

916 tag_ids: List of tag IDs to assign 

917 remove_existing: If True, remove existing tags before assigning new ones 

918 

919 Returns: 

920 The API response 

921 

922 """ 

923 if not (fn := getattr(self.resource, "bulk_assign_tags", None)): 

924 raise NotImplementedError(f"Resource {self.resource.name} does not support bulk tag assignment") 

925 

926 # Fetch all IDs in the queryset 

927 ids = [obj.id for obj in self] 

928 

929 if not ids: 

930 return {"success": True, "count": 0} 

931 

932 return fn(ids, tag_ids, remove_existing) 

933 

934 def bulk_assign_correspondent(self, correspondent_id: int) -> ClientResponse: 

935 """ 

936 Assign a correspondent to all objects in the queryset. 

937 

938 Args: 

939 correspondent_id: Correspondent ID to assign 

940 

941 Returns: 

942 The API response 

943 

944 """ 

945 if not (fn := getattr(self.resource, "bulk_assign_correspondent", None)): 

946 raise NotImplementedError(f"Resource {self.resource.name} does not support bulk correspondent assignment") 

947 

948 # Fetch all IDs in the queryset 

949 ids = [obj.id for obj in self] 

950 

951 if not ids: 

952 return {"success": True, "count": 0} 

953 

954 return fn(ids, correspondent_id) 

955 

956 def bulk_assign_document_type(self, document_type_id: int) -> ClientResponse: 

957 """ 

958 Assign a document type to all objects in the queryset. 

959 

960 Args: 

961 document_type_id: Document type ID to assign 

962 

963 Returns: 

964 The API response 

965 

966 """ 

967 if not (fn := getattr(self.resource, "bulk_assign_document_type", None)): 

968 raise NotImplementedError(f"Resource {self.resource.name} does not support bulk document type assignment") 

969 

970 # Fetch all IDs in the queryset 

971 ids = [obj.id for obj in self] 

972 

973 if not ids: 

974 return {"success": True, "count": 0} 

975 

976 return fn(ids, document_type_id) 

977 

978 def bulk_assign_storage_path(self, storage_path_id: int) -> ClientResponse: 

979 """ 

980 Assign a storage path to all objects in the queryset. 

981 

982 Args: 

983 storage_path_id: Storage path ID to assign 

984 

985 Returns: 

986 The API response 

987 

988 """ 

989 if not (fn := getattr(self.resource, "bulk_assign_storage_path", None)): 

990 raise NotImplementedError(f"Resource {self.resource.name} does not support bulk storage path assignment") 

991 

992 # Fetch all IDs in the queryset 

993 ids = [obj.id for obj in self] 

994 

995 if not ids: 

996 return {"success": True, "count": 0} 

997 

998 return fn(ids, storage_path_id) 

999 

1000 def bulk_assign_owner(self, owner_id: int) -> ClientResponse: 

1001 """ 

1002 Assign an owner to all objects in the queryset. 

1003 

1004 Args: 

1005 owner_id: Owner ID to assign 

1006 

1007 Returns: 

1008 The API response 

1009 

1010 """ 

1011 if not (fn := getattr(self.resource, "bulk_assign_owner", None)): 

1012 raise NotImplementedError(f"Resource {self.resource.name} does not support bulk owner assignment") 

1013 

1014 # Fetch all IDs in the queryset 

1015 ids = [obj.id for obj in self] 

1016 

1017 if not ids: 

1018 return {"success": True, "count": 0} 

1019 

1020 return fn(ids, owner_id)