Coverage for backend/django/core/auxiliary/services/project_folders.py: 90%

266 statements  

« prev     ^ index     » next       coverage.py v7.10.7, created at 2026-07-22 05:22 +0000

1"""Relational project-folder selectors and transactional workflows.""" 

2 

3from __future__ import annotations 

4 

5import unicodedata 

6from collections import defaultdict 

7from collections.abc import Iterable, Mapping, Sequence 

8from dataclasses import dataclass 

9from datetime import datetime 

10 

11from django.db import IntegrityError, connection, transaction 

12from django.db.models import Value 

13from django.db.models.functions import Lower 

14from django.utils import timezone 

15from pydantic import BaseModel, ConfigDict 

16 

17from authentication.user.models import User 

18from core.auxiliary.enums.FlowsheetTemplateType import FlowsheetTemplateType 

19from core.auxiliary.models.Flowsheet import Flowsheet 

20from core.auxiliary.models.Project import Project 

21from core.auxiliary.models.ProjectFolder import ProjectFolder 

22 

23 

24class ProjectFolderError(ValueError): 

25 """Base error for rejected project-folder operations.""" 

26 

27 

28class ProjectFolderNotFoundError(ProjectFolderError): 

29 """Raised when an active, owner-scoped folder cannot be found.""" 

30 

31 

32class ProjectFolderConflictError(ProjectFolderError): 

33 """Raised when an active sibling already uses a requested name.""" 

34 

35 

36class ProjectFolderHierarchyError(ProjectFolderError): 

37 """Raised when persisted folder ownership or ancestry is inconsistent.""" 

38 

39 

40class ProjectUpdateError(ProjectFolderError): 

41 """Raised when an authoritative project update cannot be applied safely.""" 

42 

43 def __init__(self, *, field: str, message: str): 

44 super().__init__(message) 

45 self.field = field 

46 

47 

48PROJECT_FOLDER_REGULAR_PROJECT_ERROR = ( 

49 "Only projects with a regular active flowsheet can be organized into folders." 

50) 

51 

52 

53class BinProjectFolderResult(BaseModel): 

54 """Summary returned after binning one relational folder subtree.""" 

55 

56 model_config = ConfigDict(extra="forbid", frozen=True) 

57 

58 folder_id: int 

59 binned_project_count: int 

60 binned_folder_count: int 

61 

62 

63def _lock_project_folder_tree(owner_id: int) -> None: 

64 """Serialize hierarchy mutations for one owner for this transaction. 

65 

66 Negative advisory-lock keys are reserved here for project-folder trees. 

67 This stable mutex prevents child-insert phantoms without coordinating 

68 through mutable User fields or holding unrelated folder rows. 

69 """ 

70 

71 if not connection.in_atomic_block: 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true

72 raise RuntimeError("Project folder tree locks require an atomic transaction.") 

73 with connection.cursor() as cursor: 

74 cursor.execute("SELECT pg_advisory_xact_lock(%s)", [-owner_id]) 

75 

76 

77def normalize_project_folder_name(value: str) -> str: 

78 """Normalize a folder label without interpreting it as a path.""" 

79 

80 normalized = unicodedata.normalize("NFC", value).strip() 

81 if not normalized: 

82 raise ValueError("Folder names cannot be empty.") 

83 if any(unicodedata.category(character).startswith("C") for character in normalized): 

84 raise ValueError("Folder names cannot contain control characters.") 

85 if len(normalized) > ProjectFolder._meta.get_field("name").max_length: 

86 raise ValueError("Folder names cannot exceed 64 characters.") 

87 return normalized 

88 

89 

90def build_project_folder_adjacency( 

91 folders: Iterable[ProjectFolder], 

92) -> dict[int | None, list[int]]: 

93 """Build a deterministic parent-to-child map from owner-scoped rows.""" 

94 

95 adjacency: dict[int | None, list[int]] = defaultdict(list) 

96 for folder in folders: 

97 adjacency[folder.parent_id].append(folder.pk) 

98 for child_ids in adjacency.values(): 

99 child_ids.sort() 

100 return dict(adjacency) 

101 

102 

103def select_project_folder_subtree_ids( 

104 *, 

105 folders: Sequence[ProjectFolder], 

106 root_id: int, 

107) -> list[int]: 

108 """Return a folder subtree by stable IDs, including its root.""" 

109 

110 folder_ids = {folder.pk for folder in folders} 

111 if root_id not in folder_ids: 

112 raise ProjectFolderNotFoundError("Project folder not found.") 

113 

114 adjacency = build_project_folder_adjacency(folders) 

115 subtree_ids: list[int] = [] 

116 pending = [root_id] 

117 visited: set[int] = set() 

118 while pending: 

119 folder_id = pending.pop() 

120 if folder_id in visited: 120 ↛ 121line 120 didn't jump to line 121 because the condition on line 120 was never true

121 raise ProjectFolderHierarchyError("Project folder hierarchy contains a cycle.") 

122 visited.add(folder_id) 

123 subtree_ids.append(folder_id) 

124 pending.extend(reversed(adjacency.get(folder_id, []))) 

125 return subtree_ids 

126 

127 

128def select_project_folder_ancestor_ids( 

129 *, 

130 folders: Sequence[ProjectFolder], 

131 folder_id: int, 

132) -> list[int]: 

133 """Return root-to-leaf ancestor IDs for one owner-scoped folder.""" 

134 

135 folders_by_id = {folder.pk: folder for folder in folders} 

136 ancestor_ids: list[int] = [] 

137 visited: set[int] = set() 

138 current_id: int | None = folder_id 

139 while current_id is not None: 

140 if current_id in visited: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true

141 raise ProjectFolderHierarchyError("Project folder hierarchy contains a cycle.") 

142 visited.add(current_id) 

143 folder = folders_by_id.get(current_id) 

144 if folder is None: 144 ↛ 145line 144 didn't jump to line 145 because the condition on line 144 was never true

145 raise ProjectFolderHierarchyError( 

146 "Project folder hierarchy crosses an ownership boundary." 

147 ) 

148 ancestor_ids.append(folder.pk) 

149 current_id = folder.parent_id 

150 ancestor_ids.reverse() 

151 return ancestor_ids 

152 

153 

154def _matching_sibling( 

155 *, 

156 owner_id: int, 

157 parent_id: int | None, 

158 name: str, 

159) -> ProjectFolder | None: 

160 """Lock and return the case-insensitive sibling using a requested name.""" 

161 

162 return ( 

163 ProjectFolder.objects.select_for_update() 

164 .alias(normalized_name=Lower("name")) 

165 .filter( 

166 owner_id=owner_id, 

167 parent_id=parent_id, 

168 normalized_name=Lower(Value(name)), 

169 ) 

170 .order_by("id") 

171 .first() 

172 ) 

173 

174 

175def _reactivate_folder(folder: ProjectFolder) -> ProjectFolder: 

176 """Reactivate one locked folder without restoring projects or descendants.""" 

177 

178 folder.is_binned = False 

179 folder.binned_at = None 

180 folder.save(update_fields=["is_binned", "binned_at", "updated_at"]) 

181 return folder 

182 

183 

184@transaction.atomic 

185def create_project_folder( 

186 *, 

187 user: User, 

188 name: str, 

189 parent_id: int | None, 

190) -> ProjectFolder: 

191 """Create or reactivate a relational folder beneath an active parent.""" 

192 

193 normalized_name = normalize_project_folder_name(name) 

194 _lock_project_folder_tree(user.pk) 

195 if parent_id is not None: 

196 parent = ( 

197 ProjectFolder.objects.select_for_update() 

198 .filter(pk=parent_id, owner=user) 

199 .first() 

200 ) 

201 if parent is None or parent.is_binned: 

202 raise ProjectFolderNotFoundError("Parent project folder not found.") 

203 

204 sibling = _matching_sibling( 

205 owner_id=user.pk, 

206 parent_id=parent_id, 

207 name=normalized_name, 

208 ) 

209 if sibling is not None: 

210 if not sibling.is_binned: 

211 raise ProjectFolderConflictError( 

212 "A folder with this name already exists here." 

213 ) 

214 return _reactivate_folder(sibling) 

215 

216 try: 

217 # Isolate an integrity race in a savepoint so the outer transaction can 

218 # resolve the winning sibling into the domain result below. 

219 with transaction.atomic(): 

220 return ProjectFolder.objects.create( 

221 owner=user, 

222 parent_id=parent_id, 

223 name=normalized_name, 

224 ) 

225 except IntegrityError: 

226 sibling = _matching_sibling( 

227 owner_id=user.pk, 

228 parent_id=parent_id, 

229 name=normalized_name, 

230 ) 

231 if sibling is None: 

232 raise 

233 if sibling.is_binned: 

234 return _reactivate_folder(sibling) 

235 raise ProjectFolderConflictError( 

236 "A folder with this name already exists here." 

237 ) 

238 

239 

240@transaction.atomic 

241def rename_project_folder( 

242 *, 

243 user: User, 

244 folder_id: int, 

245 name: str, 

246) -> ProjectFolder: 

247 """Rename one active owned folder while preserving sibling uniqueness.""" 

248 

249 normalized_name = normalize_project_folder_name(name) 

250 _lock_project_folder_tree(user.pk) 

251 folder = lock_active_project_folder(owner_id=user.pk, folder_id=folder_id) 

252 sibling = _matching_sibling( 

253 owner_id=user.pk, 

254 parent_id=folder.parent_id, 

255 name=normalized_name, 

256 ) 

257 if sibling is not None and sibling.pk != folder.pk: 

258 raise ProjectFolderConflictError( 

259 "A folder with this name already exists here." 

260 ) 

261 if folder.name == normalized_name: 261 ↛ 262line 261 didn't jump to line 262 because the condition on line 261 was never true

262 return folder 

263 

264 folder.name = normalized_name 

265 try: 

266 folder.save(update_fields=["name", "updated_at"]) 

267 except IntegrityError as exc: 

268 raise ProjectFolderConflictError( 

269 "A folder with this name already exists here." 

270 ) from exc 

271 return folder 

272 

273 

274def lock_active_project_folder(*, owner_id: int, folder_id: int) -> ProjectFolder: 

275 """Lock an active destination folder and enforce its ownership boundary.""" 

276 

277 try: 

278 return ProjectFolder.objects.select_for_update().get( 

279 pk=folder_id, 

280 owner_id=owner_id, 

281 is_binned=False, 

282 ) 

283 except ProjectFolder.DoesNotExist as exc: 

284 raise ProjectFolderNotFoundError("Project folder not found.") from exc 

285 

286 

287def _load_project_folder_ancestry( 

288 *, owner_id: int, folder_id: int 

289) -> list[ProjectFolder]: 

290 """Load only one folder's owner-scoped ancestry using a recursive CTE.""" 

291 

292 table = connection.ops.quote_name(ProjectFolder._meta.db_table) 

293 sql = f""" 

294 WITH RECURSIVE folder_ancestry AS ( 

295 SELECT id, parent_id, owner_id 

296 FROM {table} 

297 WHERE id = %s AND owner_id = %s 

298 UNION 

299 SELECT parent.id, parent.parent_id, parent.owner_id 

300 FROM {table} AS parent 

301 INNER JOIN folder_ancestry AS child 

302 ON parent.id = child.parent_id 

303 WHERE parent.owner_id = %s 

304 ) 

305 SELECT id, parent_id, owner_id 

306 FROM folder_ancestry 

307 """ 

308 return list(ProjectFolder.objects.raw(sql, [folder_id, owner_id, owner_id])) 

309 

310 

311def _load_project_folder_subtree( 

312 *, owner_id: int, folder_id: int 

313) -> list[ProjectFolder]: 

314 """Load only one owner-scoped folder subtree using a recursive CTE.""" 

315 

316 table = connection.ops.quote_name(ProjectFolder._meta.db_table) 

317 sql = f""" 

318 WITH RECURSIVE folder_subtree AS ( 

319 SELECT id, parent_id, owner_id 

320 FROM {table} 

321 WHERE id = %s AND owner_id = %s 

322 UNION 

323 SELECT child.id, child.parent_id, child.owner_id 

324 FROM {table} AS child 

325 INNER JOIN folder_subtree AS parent 

326 ON child.parent_id = parent.id 

327 WHERE child.owner_id = %s 

328 ) 

329 SELECT id, parent_id, owner_id 

330 FROM folder_subtree 

331 """ 

332 return list(ProjectFolder.objects.raw(sql, [folder_id, owner_id, owner_id])) 

333 

334 

335def lock_project_folder_ancestry( 

336 *, 

337 owner_id: int, 

338 folder_id: int, 

339) -> list[ProjectFolder]: 

340 """Lock and revalidate an owned folder ancestry before locking its project.""" 

341 

342 _lock_project_folder_tree(owner_id) 

343 ancestor_relations = _load_project_folder_ancestry( 

344 owner_id=owner_id, 

345 folder_id=folder_id, 

346 ) 

347 ancestor_ids = select_project_folder_ancestor_ids( 

348 folders=ancestor_relations, 

349 folder_id=folder_id, 

350 ) 

351 locked_folders = list( 

352 ProjectFolder.objects.select_for_update() 

353 .filter(owner_id=owner_id, pk__in=ancestor_ids) 

354 .order_by("id") 

355 ) 

356 if {folder.pk for folder in locked_folders} != set(ancestor_ids): 356 ↛ 357line 356 didn't jump to line 357 because the condition on line 356 was never true

357 raise ProjectFolderHierarchyError( 

358 "Project folder hierarchy changed; retry the update." 

359 ) 

360 locked_by_id = {folder.pk: folder for folder in locked_folders} 

361 return [locked_by_id[ancestor_id] for ancestor_id in ancestor_ids] 

362 

363 

364def reactivate_locked_project_folders(folders: Sequence[ProjectFolder]) -> None: 

365 """Reactivate a locked root-to-leaf folder ancestry in one write.""" 

366 

367 folders_to_update = [folder for folder in folders if folder.is_binned] 

368 if not folders_to_update: 

369 return 

370 updated_at = timezone.now() 

371 for folder in folders_to_update: 

372 folder.is_binned = False 

373 folder.binned_at = None 

374 folder.updated_at = updated_at 

375 ProjectFolder.objects.bulk_update( 

376 folders_to_update, 

377 ["is_binned", "binned_at", "updated_at"], 

378 ) 

379 

380 

381def project_has_regular_active_flowsheet(project: Project) -> bool: 

382 """Return whether a project currently points at a regular flowsheet.""" 

383 

384 if project.active_flowsheet_id is None: 384 ↛ 385line 384 didn't jump to line 385 because the condition on line 384 was never true

385 return False 

386 return Flowsheet.objects.filter( 

387 pk=project.active_flowsheet_id, 

388 flowsheet_template_type=FlowsheetTemplateType.NotTemplate, 

389 ).exists() 

390 

391 

392def _apply_project_bin_state( 

393 project: Project, 

394 *, 

395 is_binned: bool, 

396 transition_time: datetime, 

397) -> None: 

398 """Apply the canonical project bin state to an already locked instance. 

399 

400 Callers remain responsible for persisting the instance. Keeping this policy 

401 write-free lets both individual updates and recursive folder operations use 

402 identical lifecycle fields while retaining their respective bulk-write paths. 

403 """ 

404 

405 if is_binned: 

406 if not project.is_binned or project.binned_at is None: 

407 project.binned_at = transition_time 

408 project.is_binned = True 

409 else: 

410 project.is_binned = False 

411 project.binned_at = None 

412 project.updated_at = transition_time 

413 if is_binned: 

414 project.is_starred = False 

415 

416 

417@dataclass(frozen=True) 

418class _ProjectUpdateFolderLocks: 

419 """Folder state acquired before an individual project row is locked.""" 

420 

421 ancestors: tuple[ProjectFolder, ...] 

422 expected_restore_folder_id: int | None 

423 

424 

425def _lock_project_update_folders( 

426 *, 

427 stale_project: Project, 

428 validated_changes: dict[str, object], 

429 touches_folder: bool, 

430 requests_restore: bool, 

431) -> _ProjectUpdateFolderLocks: 

432 """Plan and acquire destination or restoration-ancestry folder locks. 

433 

434 The supplied change mapping is updated with the locked destination model so 

435 persistence cannot reuse the serializer's stale folder instance. 

436 """ 

437 

438 destination = validated_changes.get("folder") 

439 restores_current_folder = bool( 

440 requests_restore 

441 and destination is not None 

442 and destination.pk == stale_project.folder_id 

443 ) 

444 locked_ancestors: list[ProjectFolder] = [] 

445 locked_destination = None 

446 if touches_folder and destination is not None and restores_current_folder: 

447 locked_ancestors = lock_project_folder_ancestry( 

448 owner_id=stale_project.owner_id, 

449 folder_id=destination.pk, 

450 ) 

451 locked_destination = locked_ancestors[-1] 

452 validated_changes["folder"] = locked_destination 

453 elif touches_folder and destination is not None: 

454 locked_destination = lock_active_project_folder( 

455 owner_id=stale_project.owner_id, 

456 folder_id=destination.pk, 

457 ) 

458 validated_changes["folder"] = locked_destination 

459 

460 retained_folder_id = ( 

461 stale_project.folder_id 

462 if requests_restore 

463 and not touches_folder 

464 and stale_project.folder_id is not None 

465 else None 

466 ) 

467 if retained_folder_id is not None: 

468 locked_ancestors = lock_project_folder_ancestry( 

469 owner_id=stale_project.owner_id, 

470 folder_id=retained_folder_id, 

471 ) 

472 

473 expected_restore_folder_id = ( 

474 locked_destination.pk 

475 if restores_current_folder and locked_destination is not None 

476 else retained_folder_id 

477 ) 

478 return _ProjectUpdateFolderLocks( 

479 ancestors=tuple(locked_ancestors), 

480 expected_restore_folder_id=expected_restore_folder_id, 

481 ) 

482 

483 

484def _lock_fresh_project(stale_project: Project) -> Project: 

485 """Lock fresh project state and reject deletion or ownership drift.""" 

486 

487 try: 

488 locked_project = Project.objects.select_for_update().get(pk=stale_project.pk) 

489 except Project.DoesNotExist as exc: 

490 raise ProjectUpdateError( 

491 field="detail", 

492 message="Project changed; retry the update.", 

493 ) from exc 

494 if locked_project.owner_id != stale_project.owner_id: 494 ↛ 495line 494 didn't jump to line 495 because the condition on line 494 was never true

495 raise ProjectUpdateError( 

496 field="detail", 

497 message="Project ownership changed; retry the update.", 

498 ) 

499 return locked_project 

500 

501 

502def _validate_locked_project_update( 

503 project: Project, 

504 *, 

505 touches_folder: bool, 

506 requests_binning: bool, 

507 expected_restore_folder_id: int | None, 

508) -> None: 

509 """Revalidate active-flowsheet and stale-folder policy under row locks.""" 

510 

511 if (touches_folder or requests_binning) and not ( 

512 project_has_regular_active_flowsheet(project) 

513 ): 

514 raise ProjectUpdateError( 

515 field="folder" if touches_folder else "is_binned", 

516 message=PROJECT_FOLDER_REGULAR_PROJECT_ERROR, 

517 ) 

518 if ( 518 ↛ 522line 518 didn't jump to line 522 because the condition on line 518 was never true

519 expected_restore_folder_id is not None 

520 and project.folder_id != expected_restore_folder_id 

521 ): 

522 raise ProjectUpdateError( 

523 field="folder", 

524 message="Project folder changed; retry the update.", 

525 ) 

526 

527 

528def _persist_locked_project_update( 

529 project: Project, 

530 *, 

531 validated_changes: dict[str, object], 

532 requests_binning: bool, 

533 requests_restore: bool, 

534 locked_ancestors: Sequence[ProjectFolder], 

535) -> Project: 

536 """Apply ordinary fields plus canonical lifecycle state and persist once.""" 

537 

538 # Lifecycle timestamps are always server-owned, even if this service is 

539 # called from a path other than the serializer. 

540 validated_changes.pop("binned_at", None) 

541 for field, value in validated_changes.items(): 

542 setattr(project, field, value) 

543 

544 transition_time = timezone.now() 

545 if requests_binning: 

546 _apply_project_bin_state( 

547 project, 

548 is_binned=True, 

549 transition_time=transition_time, 

550 ) 

551 elif requests_restore: 

552 reactivate_locked_project_folders(locked_ancestors) 

553 _apply_project_bin_state( 

554 project, 

555 is_binned=False, 

556 transition_time=transition_time, 

557 ) 

558 

559 project.save() 

560 return project 

561 

562 

563@transaction.atomic 

564def apply_project_update( 

565 *, 

566 stale_project: Project, 

567 changes: Mapping[str, object], 

568) -> Project: 

569 """Apply serializer-validated fields under project-folder lifecycle locks. 

570 

571 Folder locks are acquired before the project lock so this path follows the 

572 same ordering as recursive folder binning. The fresh project row is then 

573 updated once, with server-owned bin timestamps and restoration of any 

574 retained folder ancestry. 

575 """ 

576 

577 validated_changes = dict(changes) 

578 touches_folder = "folder" in validated_changes 

579 requests_restore = validated_changes.get("is_binned") is False 

580 requests_binning = validated_changes.get("is_binned") is True 

581 

582 folder_locks = _lock_project_update_folders( 

583 stale_project=stale_project, 

584 validated_changes=validated_changes, 

585 touches_folder=touches_folder, 

586 requests_restore=requests_restore, 

587 ) 

588 locked_project = _lock_fresh_project(stale_project) 

589 _validate_locked_project_update( 

590 locked_project, 

591 touches_folder=touches_folder, 

592 requests_binning=requests_binning, 

593 expected_restore_folder_id=folder_locks.expected_restore_folder_id, 

594 ) 

595 return _persist_locked_project_update( 

596 locked_project, 

597 validated_changes=validated_changes, 

598 requests_binning=requests_binning, 

599 requests_restore=requests_restore, 

600 locked_ancestors=folder_locks.ancestors, 

601 ) 

602 

603 

604@transaction.atomic 

605def bin_project_folder(*, user: User, folder_id: int) -> BinProjectFolderResult: 

606 """Soft-bin an owned folder subtree and reconcile its project membership.""" 

607 

608 _lock_project_folder_tree(user.pk) 

609 subtree_relations = _load_project_folder_subtree( 

610 owner_id=user.pk, 

611 folder_id=folder_id, 

612 ) 

613 subtree_ids = select_project_folder_subtree_ids( 

614 folders=subtree_relations, 

615 root_id=folder_id, 

616 ) 

617 subtree_folders = list( 

618 ProjectFolder.objects.select_for_update() 

619 .filter(owner=user, pk__in=subtree_ids) 

620 .order_by("id") 

621 ) 

622 if {folder.pk for folder in subtree_folders} != set(subtree_ids): 622 ↛ 623line 622 didn't jump to line 623 because the condition on line 622 was never true

623 raise ProjectFolderNotFoundError("Project folder not found.") 

624 selected_folder = next( 

625 folder for folder in subtree_folders if folder.pk == folder_id 

626 ) 

627 if selected_folder.is_binned: 627 ↛ 628line 627 didn't jump to line 628 because the condition on line 627 was never true

628 raise ProjectFolderNotFoundError("Project folder not found.") 

629 

630 binned_at = timezone.now() 

631 for folder in subtree_folders: 

632 folder.is_binned = True 

633 folder.binned_at = binned_at 

634 folder.updated_at = binned_at 

635 ProjectFolder.objects.bulk_update( 

636 subtree_folders, 

637 ["is_binned", "binned_at", "updated_at"], 

638 ) 

639 

640 projects = list( 

641 Project.objects.select_for_update() 

642 .filter(owner=user, folder_id__in=subtree_ids) 

643 .order_by("id") 

644 ) 

645 regular_flowsheet_ids = set( 

646 Flowsheet.objects.filter( 

647 pk__in=[ 

648 project.active_flowsheet_id 

649 for project in projects 

650 if project.active_flowsheet_id is not None 

651 ], 

652 flowsheet_template_type=FlowsheetTemplateType.NotTemplate, 

653 ).values_list("id", flat=True) 

654 ) 

655 

656 binned_project_count = 0 

657 regular_projects_to_update: list[Project] = [] 

658 project_ids_to_clear_folder: list[int] = [] 

659 for project in projects: 

660 if project.active_flowsheet_id in regular_flowsheet_ids: 

661 was_binned = project.is_binned 

662 _apply_project_bin_state( 

663 project, 

664 is_binned=True, 

665 transition_time=binned_at, 

666 ) 

667 if not was_binned: 

668 binned_project_count += 1 

669 regular_projects_to_update.append(project) 

670 else: 

671 project_ids_to_clear_folder.append(project.pk) 

672 

673 if regular_projects_to_update: 

674 Project.objects.bulk_update( 

675 regular_projects_to_update, 

676 ["is_binned", "is_starred", "binned_at", "updated_at"], 

677 ) 

678 if project_ids_to_clear_folder: 

679 Project.objects.filter(pk__in=project_ids_to_clear_folder).update( 

680 folder=None, 

681 updated_at=binned_at, 

682 ) 

683 

684 return BinProjectFolderResult( 

685 folder_id=folder_id, 

686 binned_project_count=binned_project_count, 

687 binned_folder_count=len(subtree_ids), 

688 )