Coverage for src/paperap/models/document/model.py: 60%

275 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-03-12 23:40 -0400

1""" 

2 

3 

4 

5---------------------------------------------------------------------------- 

6 

7METADATA: 

8 

9File: model.py 

10 Project: paperap 

11Created: 2025-03-09 

12 Version: 0.0.7 

13Author: Jess Mann 

14Email: jess@jmann.me 

15 Copyright (c) 2025 Jess Mann 

16 

17---------------------------------------------------------------------------- 

18 

19LAST MODIFIED: 

20 

212025-03-09 By Jess Mann 

22 

23""" 

24from __future__ import annotations 

25 

26from datetime import datetime 

27from typing import TYPE_CHECKING, Annotated, Any, Iterable, Iterator, Optional, TypedDict, cast, override 

28 

29import pydantic 

30from pydantic import ConfigDict, Field, conlist, field_serializer, field_validator, model_serializer 

31from typing_extensions import TypeVar 

32from yarl import URL 

33 

34from paperap.models.abstract import FilteringStrategies, StandardModel 

35from paperap.models.document.queryset import DocumentQuerySet 

36 

37if TYPE_CHECKING: 

38 from paperap.models.correspondent import Correspondent 

39 from paperap.models.custom_field import CustomField, CustomFieldQuerySet 

40 from paperap.models.document_type import DocumentType 

41 from paperap.models.storage_path import StoragePath 

42 from paperap.models.tag import Tag, TagQuerySet 

43 from paperap.models.user import User 

44 

45 

46class CustomFieldTypedDict(TypedDict): 

47 field: int 

48 value: Any 

49 

50 

51class CustomFieldValues(pydantic.BaseModel): 

52 field: int 

53 value: Any 

54 

55 model_config = ConfigDict({ 

56 "extra": "forbid", 

57 "use_enum_values": True, 

58 }) 

59 

60 @override 

61 def __eq__(self, other: Any) -> bool: 

62 if isinstance(other, dict): 

63 if other.keys() != {"field", "value"}: 

64 return False 

65 return self.field == other.get("field") and self.value == other.get("value") 

66 

67 if isinstance(other, CustomFieldValues): 

68 return self.field == other.field and self.value == other.value 

69 

70 return super().__eq__(other) 

71 

72class DocumentNote(StandardModel): 

73 """ 

74 Represents a note on a Paperless-NgX document. 

75 """ 

76 

77 deleted_at: datetime | None = None 

78 restored_at: datetime | None = None 

79 transaction_id: int | None = None 

80 note: str 

81 created: datetime 

82 document: int 

83 user: int 

84 

85 class Meta(StandardModel.Meta): 

86 read_only_fields = {"deleted_at", "restored_at", "transaction_id", "created"} 

87 

88 @field_serializer("deleted_at", "restored_at", "created") 

89 def serialize_datetime(self, value: datetime | None): 

90 """ 

91 Serialize datetime fields to ISO format. 

92 

93 Args: 

94 value: The datetime value to serialize. 

95 

96 Returns: 

97 The serialized datetime value or None if the value is None. 

98 

99 """ 

100 return value.isoformat() if value else None 

101 

102 def get_document(self) -> "Document": 

103 """ 

104 Get the document associated with this note. 

105 

106 Returns: 

107 The document associated with this note. 

108 

109 """ 

110 return self._client.documents().get(self.document) 

111 

112 def get_user(self) -> "User": 

113 """ 

114 Get the user who created this note. 

115 

116 Returns: 

117 The user who created this note. 

118 

119 """ 

120 return self._client.users().get(self.user) 

121 

122 

123class Document(StandardModel): 

124 """ 

125 Represents a Paperless-NgX document. 

126 

127 Attributes: 

128 added: The timestamp when the document was added to the system. 

129 archive_serial_number: The serial number of the archive. 

130 archived_file_name: The name of the archived file. 

131 content: The content of the document. 

132 correspondent: The correspondent associated with the document. 

133 created: The timestamp when the document was created. 

134 created_date: The date when the document was created. 

135 updated: The timestamp when the document was last updated. 

136 custom_fields: Custom fields associated with the document. 

137 deleted_at: The timestamp when the document was deleted. 

138 document_type: The document type associated with the document. 

139 is_shared_by_requester: Whether the document is shared by the requester. 

140 notes: Notes associated with the document. 

141 original_file_name: The original file name of the document. 

142 owner: The owner of the document. 

143 page_count: The number of pages in the document. 

144 storage_path: The storage path of the document. 

145 tags: The tags associated with the document. 

146 title: The title of the document. 

147 user_can_change: Whether the user can change the document. 

148 

149 Examples: 

150 >>> document = client.documents().get(pk=1) 

151 >>> document.title = 'Example Document' 

152 >>> document.save() 

153 >>> document.title 

154 'Example Document' 

155 

156 """ 

157 

158 added: datetime | None = None 

159 archive_serial_number: int | None = None 

160 archived_file_name: str | None = None 

161 content: str = "" 

162 is_shared_by_requester: bool = False 

163 notes: "list[DocumentNote]" = Field(default_factory=list) 

164 original_file_name: str | None = None 

165 owner: int | None = None 

166 page_count: int | None = None 

167 title: str = "" 

168 user_can_change: bool | None = None 

169 

170 created: datetime | None = Field(description="Creation timestamp", default=None) 

171 created_date: str | None = None 

172 # where did this come from? It's not in sample data? 

173 updated: datetime | None = Field(description="Last update timestamp", default=None) 

174 deleted_at: datetime | None = None 

175 

176 custom_field_dicts: Annotated[list[CustomFieldValues], Field(default_factory=list)] 

177 correspondent_id: int | None = None 

178 document_type_id: int | None = None 

179 storage_path_id: int | None = None 

180 tag_ids: Annotated[list[int], Field(default_factory=list)] 

181 

182 _correspondent: tuple[int, Correspondent] | None = None 

183 _document_type: tuple[int, DocumentType] | None = None 

184 _storage_path: tuple[int, StoragePath] | None = None 

185 __search_hit__: Optional[dict[str, Any]] = None 

186 

187 class Meta(StandardModel.Meta): 

188 # NOTE: Filtering appears to be disabled by paperless on page_count 

189 queryset = DocumentQuerySet 

190 read_only_fields = {"page_count", "deleted_at", "updated", "is_shared_by_requester"} 

191 filtering_disabled = {"page_count", "deleted_at", "updated", "is_shared_by_requester"} 

192 filtering_strategies = {FilteringStrategies.WHITELIST} 

193 field_map = { 

194 "tags": "tag_ids", 

195 "custom_fields": "custom_field_dicts", 

196 "document_type": "document_type_id", 

197 "correspondent": "correspondent_id", 

198 "storage_path": "storage_path_id", 

199 } 

200 supported_filtering_params = { 

201 "id__in", 

202 "id", 

203 "title__istartswith", 

204 "title__iendswith", 

205 "title__icontains", 

206 "title__iexact", 

207 "content__istartswith", 

208 "content__iendswith", 

209 "content__icontains", 

210 "content__iexact", 

211 "archive_serial_number", 

212 "archive_serial_number__gt", 

213 "archive_serial_number__gte", 

214 "archive_serial_number__lt", 

215 "archive_serial_number__lte", 

216 "archive_serial_number__isnull", 

217 "content__contains", # maybe? 

218 "correspondent__isnull", 

219 "correspondent__id__in", 

220 "correspondent__id", 

221 "correspondent__name__istartswith", 

222 "correspondent__name__iendswith", 

223 "correspondent__name__icontains", 

224 "correspondent__name__iexact", 

225 "correspondent__slug__iexact", # maybe? 

226 "created__year", 

227 "created__month", 

228 "created__day", 

229 "created__date__gt", 

230 "created__gt", 

231 "created__date__lt", 

232 "created__lt", 

233 "added__year", 

234 "added__month", 

235 "added__day", 

236 "added__date__gt", 

237 "added__gt", 

238 "added__date__lt", 

239 "added__lt", 

240 "modified__year", 

241 "modified__month", 

242 "modified__day", 

243 "modified__date__gt", 

244 "modified__gt", 

245 "modified__date__lt", 

246 "modified__lt", 

247 "original_filename__istartswith", 

248 "original_filename__iendswith", 

249 "original_filename__icontains", 

250 "original_filename__iexact", 

251 "checksum__istartswith", 

252 "checksum__iendswith", 

253 "checksum__icontains", 

254 "checksum__iexact", 

255 "tags__id__in", 

256 "tags__id", 

257 "tags__name__istartswith", 

258 "tags__name__iendswith", 

259 "tags__name__icontains", 

260 "tags__name__iexact", 

261 "document_type__isnull", 

262 "document_type__id__in", 

263 "document_type__id", 

264 "document_type__name__istartswith", 

265 "document_type__name__iendswith", 

266 "document_type__name__icontains", 

267 "document_type__name__iexact", 

268 "storage_path__isnull", 

269 "storage_path__id__in", 

270 "storage_path__id", 

271 "storage_path__name__istartswith", 

272 "storage_path__name__iendswith", 

273 "storage_path__name__icontains", 

274 "storage_path__name__iexact", 

275 "owner__isnull", 

276 "owner__id__in", 

277 "owner__id", 

278 "is_tagged", 

279 "tags__id__all", 

280 "tags__id__none", 

281 "correspondent__id__none", 

282 "document_type__id__none", 

283 "storage_path__id__none", 

284 "is_in_inbox", 

285 "title_content", 

286 "owner__id__none", 

287 "custom_fields__icontains", 

288 "custom_fields__id__all", 

289 "custom_fields__id__none", # ?? 

290 "custom_fields__id__in", 

291 "custom_field_query", # ?? 

292 "has_custom_fields", 

293 "shared_by__id", 

294 "shared_by__id__in", 

295 } 

296 

297 @field_serializer("added", "created", "updated", "deleted_at") 

298 def serialize_datetime(self, value: datetime | None) -> str | None: 

299 """ 

300 Serialize datetime fields to ISO format. 

301 

302 Args: 

303 value: The datetime value to serialize. 

304 

305 Returns: 

306 The serialized datetime value. 

307 

308 """ 

309 return value.isoformat() if value else None 

310 

311 @field_serializer("notes") 

312 def serialize_notes(self, value: list[DocumentNote]): 

313 """ 

314 Serialize notes to a list of dictionaries. 

315 

316 Args: 

317 value: The list of DocumentNote objects to serialize. 

318 

319 Returns: 

320 A list of dictionaries representing the notes. 

321 

322 """ 

323 return [note.to_dict() for note in value] if value else [] 

324 

325 @field_validator("tag_ids", mode="before") 

326 @classmethod 

327 def validate_tags(cls, value: Any) -> list[int]: 

328 """ 

329 Validate and convert tag IDs to a list of integers. 

330 

331 Args: 

332 value: The list of tag IDs to validate. 

333 

334 Returns: 

335 A list of validated tag IDs. 

336 

337 """ 

338 if value is None: 

339 return [] 

340 

341 if isinstance(value, list): 

342 return [int(tag) for tag in value] 

343 

344 if isinstance(value, int): 

345 return [value] 

346 

347 raise TypeError(f"Invalid type for tags: {type(value)}") 

348 

349 @field_validator("custom_field_dicts", mode="before") 

350 @classmethod 

351 def validate_custom_fields(cls, value: Any) -> list[CustomFieldValues]: 

352 """ 

353 Validate and return custom field dictionaries. 

354 

355 Args: 

356 value: The list of custom field dictionaries to validate. 

357 

358 Returns: 

359 A list of validated custom field dictionaries. 

360 

361 """ 

362 if value is None: 

363 return [] 

364 

365 if isinstance(value, list): 

366 return value 

367 

368 raise TypeError(f"Invalid type for custom fields: {type(value)}") 

369 

370 @field_validator("content", "title", mode="before") 

371 @classmethod 

372 def validate_text(cls, value: Any) -> str: 

373 """ 

374 Validate and return a text field. 

375 

376 Args: 

377 value: The value of the text field to validate. 

378 

379 Returns: 

380 The validated text value. 

381 

382 """ 

383 if value is None: 

384 return "" 

385 

386 if isinstance(value, (str, int)): 

387 return str(value) 

388 

389 raise TypeError(f"Invalid type for text: {type(value)}") 

390 

391 @field_validator("notes", mode="before") 

392 @classmethod 

393 def validate_notes(cls, value: Any) -> list[Any]: 

394 """ 

395 Validate and return the list of notes. 

396 

397 Args: 

398 value: The list of notes to validate. 

399 

400 Returns: 

401 The validated list of notes. 

402 

403 """ 

404 if value is None: 

405 return [] 

406 

407 if isinstance(value, list): 

408 return value 

409 

410 if isinstance(value, DocumentNote): 

411 return [value] 

412 

413 raise TypeError(f"Invalid type for notes: {type(value)}") 

414 

415 @field_validator("is_shared_by_requester", mode="before") 

416 @classmethod 

417 def validate_is_shared_by_requester(cls, value: Any) -> bool: 

418 """ 

419 Validate and return the is_shared_by_requester flag. 

420 

421 Args: 

422 value: The flag to validate. 

423 

424 Returns: 

425 The validated flag. 

426 

427 """ 

428 if value is None: 

429 return False 

430 

431 if isinstance(value, bool): 

432 return value 

433 

434 raise TypeError(f"Invalid type for is_shared_by_requester: {type(value)}") 

435 

436 @property 

437 def custom_field_ids(self) -> list[int]: 

438 """ 

439 Get the IDs of the custom fields for this document. 

440 """ 

441 return [element.field for element in self.custom_field_dicts] 

442 

443 @property 

444 def custom_field_values(self) -> list[Any]: 

445 """ 

446 Get the values of the custom fields for this document. 

447 """ 

448 return [element.value for element in self.custom_field_dicts] 

449 

450 @property 

451 def tag_names(self) -> list[str]: 

452 """ 

453 Get the names of the tags for this document. 

454 """ 

455 return [tag.name for tag in self.tags if tag.name] 

456 

457 @property 

458 def tags(self) -> TagQuerySet: 

459 """ 

460 Get the tags for this document. 

461 

462 Returns: 

463 List of tags associated with this document. 

464 

465 Examples: 

466 >>> document = client.documents().get(pk=1) 

467 >>> for tag in document.tags: 

468 ... print(f'{tag.name} # {tag.id}') 

469 'Tag 1 # 1' 

470 'Tag 2 # 2' 

471 'Tag 3 # 3' 

472 

473 >>> if 5 in document.tags: 

474 ... print('Tag ID #5 is associated with this document') 

475 

476 >>> tag = client.tags().get(pk=1) 

477 >>> if tag in document.tags: 

478 ... print('Tag ID #1 is associated with this document') 

479 

480 >>> filtered_tags = document.tags.filter(name__icontains='example') 

481 >>> for tag in filtered_tags: 

482 ... print(f'{tag.name} # {tag.id}') 

483 

484 """ 

485 if not self.tag_ids: 

486 return self._client.tags().none() 

487 

488 # Use the API's filtering capability to get only the tags with specific IDs 

489 # The paperless-ngx API supports id__in filter for retrieving multiple objects by ID 

490 return self._client.tags().id(self.tag_ids) 

491 

492 @tags.setter 

493 def tags(self, value: "Iterable[Tag | int] | None") -> None: 

494 """ 

495 Set the tags for this document. 

496 

497 Args: 

498 value: The tags to set. 

499 

500 """ 

501 if value is None: 

502 self.tag_ids = [] 

503 return 

504 

505 if isinstance(value, Iterable): 

506 for tag in value: 

507 if isinstance(tag, int): 

508 self.tag_ids.append(tag) 

509 continue 

510 

511 # Check against StandardModel to avoid circular imports 

512 # If it is another type of standard model, pydantic validators will complain 

513 if isinstance(tag, StandardModel): 

514 self.tag_ids.append(tag.id) 

515 continue 

516 

517 raise TypeError(f"Invalid type for tags: {type(tag)}") 

518 return 

519 

520 raise TypeError(f"Invalid type for tags: {type(value)}") 

521 

522 @property 

523 def correspondent(self) -> "Correspondent | None": 

524 """ 

525 Get the correspondent for this document. 

526 

527 Returns: 

528 The correspondent or None if not set. 

529 

530 Examples: 

531 >>> document = client.documents().get(pk=1) 

532 >>> document.correspondent.name 

533 'Example Correspondent' 

534 

535 """ 

536 # Return cache 

537 if self._correspondent is not None: 

538 pk, value = self._correspondent 

539 if pk == self.correspondent_id: 

540 return value 

541 

542 # None set to retrieve 

543 if not self.correspondent_id: 

544 return None 

545 

546 # Retrieve it 

547 correspondent = self._client.correspondents().get(self.correspondent_id) 

548 self._correspondent = (self.correspondent_id, correspondent) 

549 return correspondent 

550 

551 @correspondent.setter 

552 def correspondent(self, value: "Correspondent | int | None") -> None: 

553 """ 

554 Set the correspondent for this document. 

555 

556 Args: 

557 value: The correspondent to set. 

558 

559 """ 

560 if value is None: 

561 # Leave cache in place in case it changes again 

562 self.correspondent_id = None 

563 return 

564 

565 if isinstance(value, int): 

566 # Leave cache in place in case id is the same, or id changes again 

567 self.correspondent_id = value 

568 return 

569 

570 # Check against StandardModel to avoid circular imports 

571 # If it is another type of standard model, pydantic validators will complain 

572 if isinstance(value, StandardModel): 

573 self.correspondent_id = value.id 

574 # Pre-populate the cache 

575 self._correspondent = (value.id, value) 

576 return 

577 

578 raise TypeError(f"Invalid type for correspondent: {type(value)}") 

579 

580 @property 

581 def document_type(self) -> "DocumentType | None": 

582 """ 

583 Get the document type for this document. 

584 

585 Returns: 

586 The document type or None if not set. 

587 

588 Examples: 

589 >>> document = client.documents().get(pk=1) 

590 >>> document.document_type.name 

591 'Example Document Type 

592 

593 """ 

594 # Return cache 

595 if self._document_type is not None: 

596 pk, value = self._document_type 

597 if pk == self.document_type_id: 

598 return value 

599 

600 # None set to retrieve 

601 if not self.document_type_id: 

602 return None 

603 

604 # Retrieve it 

605 document_type = self._client.document_types().get(self.document_type_id) 

606 self._document_type = (self.document_type_id, document_type) 

607 return document_type 

608 

609 @document_type.setter 

610 def document_type(self, value: "DocumentType | int | None") -> None: 

611 """ 

612 Set the document type for this document. 

613 

614 Args: 

615 value: The document type to set. 

616 

617 """ 

618 if value is None: 

619 # Leave cache in place in case it changes again 

620 self.document_type_id = None 

621 return 

622 

623 if isinstance(value, int): 

624 # Leave cache in place in case id is the same, or id changes again 

625 self.document_type_id = value 

626 return 

627 

628 # Check against StandardModel to avoid circular imports 

629 # If it is another type of standard model, pydantic validators will complain 

630 if isinstance(value, StandardModel): 

631 self.document_type_id = value.id 

632 # Pre-populate the cache 

633 self._document_type = (value.id, value) 

634 return 

635 

636 raise TypeError(f"Invalid type for document_type: {type(value)}") 

637 

638 @property 

639 def storage_path(self) -> "StoragePath | None": 

640 """ 

641 Get the storage path for this document. 

642 

643 Returns: 

644 The storage path or None if not set. 

645 

646 Examples: 

647 >>> document = client.documents().get(pk=1) 

648 >>> document.storage_path.name 

649 'Example Storage Path' 

650 

651 """ 

652 # Return cache 

653 if self._storage_path is not None: 

654 pk, value = self._storage_path 

655 if pk == self.storage_path_id: 

656 return value 

657 

658 # None set to retrieve 

659 if not self.storage_path_id: 

660 return None 

661 

662 # Retrieve it 

663 storage_path = self._client.storage_paths().get(self.storage_path_id) 

664 self._storage_path = (self.storage_path_id, storage_path) 

665 return storage_path 

666 

667 @storage_path.setter 

668 def storage_path(self, value: "StoragePath | int | None") -> None: 

669 """ 

670 Set the storage path for this document. 

671 

672 Args: 

673 value: The storage path to set. 

674 

675 """ 

676 if value is None: 

677 # Leave cache in place in case it changes again 

678 self.storage_path_id = None 

679 return 

680 

681 if isinstance(value, int): 

682 # Leave cache in place in case id is the same, or id changes again 

683 self.storage_path_id = value 

684 return 

685 

686 # Check against StandardModel to avoid circular imports 

687 # If it is another type of standard model, pydantic validators will complain 

688 if isinstance(value, StandardModel): 

689 self.storage_path_id = value.id 

690 # Pre-populate the cache 

691 self._storage_path = (value.id, value) 

692 return 

693 

694 raise TypeError(f"Invalid type for storage_path: {type(value)}") 

695 

696 @property 

697 def custom_fields(self) -> "CustomFieldQuerySet": 

698 """ 

699 Get the custom fields for this document. 

700 

701 Returns: 

702 List of custom fields associated with this document. 

703 

704 """ 

705 if not self.custom_field_dicts: 

706 return self._client.custom_fields().none() 

707 

708 # Use the API's filtering capability to get only the custom fields with specific IDs 

709 # The paperless-ngx API supports id__in filter for retrieving multiple objects by ID 

710 return self._client.custom_fields().id(self.custom_field_ids) 

711 

712 @custom_fields.setter 

713 def custom_fields(self, value: "Iterable[CustomField | CustomFieldValues | CustomFieldTypedDict] | None") -> None: 

714 """ 

715 Set the custom fields for this document. 

716 

717 Args: 

718 value: The custom fields to set. 

719 

720 """ 

721 if value is None: 

722 self.custom_field_dicts = [] 

723 return 

724 

725 if isinstance(value, Iterable): 

726 new_list: list[CustomFieldValues] = [] 

727 for field in value: 

728 if isinstance(field, CustomFieldValues): 

729 new_list.append(field) 

730 continue 

731 

732 # isinstance(field, CustomField) 

733 # Check against StandardModel (instead of CustomField) to avoid circular imports 

734 # If it is the wrong type of standard model (e.g. a User), pydantic validators will complain 

735 if isinstance(field, StandardModel): 

736 new_list.append(CustomFieldValues(field=field.id, value=getattr(field, "value"))) 

737 continue 

738 

739 if isinstance(field, dict): 

740 new_list.append(CustomFieldValues(**field)) 

741 continue 

742 

743 raise TypeError(f"Invalid type for custom fields: {type(field)}") 

744 

745 self.custom_field_dicts = new_list 

746 return 

747 

748 raise TypeError(f"Invalid type for custom fields: {type(value)}") 

749 

750 @property 

751 def has_search_hit(self) -> bool: 

752 return self.__search_hit__ is not None 

753 

754 @property 

755 def search_hit(self) -> Optional[dict[str, Any]]: 

756 return self.__search_hit__ 

757 

758 def custom_field_value(self, field_id: int, default: Any = None, *, raise_errors: bool = False) -> Any: 

759 """ 

760 Get the value of a custom field by ID. 

761 

762 Args: 

763 field_id: The ID of the custom field. 

764 default: The value to return if the field is not found. 

765 raise_errors: Whether to raise an error if the field is not found. 

766 

767 Returns: 

768 The value of the custom field or the default value if not found. 

769 

770 """ 

771 for field in self.custom_field_dicts: 

772 if field.field == field_id: 

773 return field.value 

774 

775 if raise_errors: 

776 raise ValueError(f"Custom field {field_id} not found") 

777 return default 

778 

779 """ 

780 def __getattr__(self, name: str) -> Any: 

781 # Allow easy access to custom fields 

782 for custom_field in self.custom_fields: 

783 if custom_field['field'] == name: 

784 return custom_field['value'] 

785 

786 raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{name}'") 

787 """ 

788 

789 @override 

790 def update_locally(self, from_db: bool | None = None, **kwargs: Any): 

791 """ 

792 Update the document locally with the provided data. 

793 

794 Args: 

795 from_db: Whether to update from the database. 

796 **kwargs: Additional data to update the document with. 

797 

798 Raises: 

799 NotImplementedError: If attempting to set notes or tags to None when they are not already None. 

800 

801 """ 

802 # Paperless does not support setting notes or tags to None if not already None 

803 if self._meta.original_data["notes"]: 

804 if "notes" in kwargs and not kwargs.get("notes"): 

805 # TODO: Gracefully delete the notes instead of raising an error. 

806 raise NotImplementedError( 

807 f"Cannot set notes to None. Notes currently: {self._meta.original_data['notes']}" 

808 ) 

809 

810 if self._meta.original_data["tag_ids"]: 

811 if "tag_ids" in kwargs and not kwargs.get("tag_ids"): 

812 # TODO: Gracefully delete the tags instead of raising an error. 

813 raise NotImplementedError( 

814 f"Cannot set tag_ids to None. Tags currently: {self._meta.original_data['tag_ids']}" 

815 ) 

816 

817 return super().update_locally(from_db, **kwargs)