Coverage for rfpy/model/project.py: 99%

277 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-24 10:52 +0000

1from datetime import datetime 

2from rfpy.model.exc import QuestionnaireStructureException 

3from typing import Iterable, Union, Optional, TYPE_CHECKING, Iterator 

4 

5from sqlalchemy import ( 

6 Column, 

7 Unicode, 

8 Boolean, 

9 text, 

10 func, 

11 ForeignKeyConstraint, 

12 Integer, 

13 ForeignKey, 

14 DateTime, 

15 Table, 

16 UniqueConstraint, 

17 Index, 

18) 

19from sqlalchemy.orm import ( 

20 Mapped, 

21 mapped_column, 

22 relationship, 

23 joinedload, 

24 deferred, 

25 DynamicMapped, 

26) 

27from sqlalchemy.dialects.mysql import ( 

28 TINYINT, 

29 VARCHAR, 

30 LONGTEXT, 

31 CHAR, 

32 INTEGER, 

33 MEDIUMTEXT, 

34) 

35from sqlalchemy.orm.session import object_session 

36from sqlalchemy.orm.exc import NoResultFound 

37import sqlalchemy.types as types 

38 

39from rfpy.auth import AuthorizationFailure 

40 

41 

42from rfpy.model.meta import Base, AttachmentMixin, Visitor 

43from rfpy.model.issue import Issue, IssueAttachment 

44from rfpy.model.notify import ProjectWatchList 

45 

46from rfpy.model.questionnaire import ( 

47 HierarchyWeightingsVisitor, 

48 SaveTotalWeightingsVisitor, 

49 LoadWeightSetVisitor, 

50 WeightingSet, 

51 Section, 

52 QuestionInstance, 

53 QuestionDefinition, 

54) 

55 

56from rfpy.model import ProjectPermission, Participant, User, Organisation, CustomRole 

57 

58if TYPE_CHECKING: 

59 from rfpy.model.misc import Category 

60 from rfpy.model.questionnaire import QElement, AnswerReport, TotalWeighting 

61 from rfpy.model.notes import ProjectNote 

62 from rfpy.model.audit.event import AuditEvent 

63 

64 

65status = {0: "Draft", 10: "Live", 20: "Closed"} 

66status_int = {v: k for k, v in status.items()} 

67 

68 

69class Status(types.TypeDecorator): 

70 impl = TINYINT() 

71 

72 cache_ok = True 

73 

74 def process_bind_param(self, value, dialect): 

75 return status_int[value] 

76 

77 def process_result_value(self, value, dialect): 

78 return status[value] 

79 

80 

81class LazyParticipants: 

82 """ 

83 Provides convenient set/collection syntax access for querying 

84 participants associated with the given project 

85 """ 

86 

87 def __init__(self, project): 

88 self.project = project 

89 

90 @property 

91 def roleq(self) -> Iterator[Participant]: 

92 return self.project.participants_query.options( 

93 joinedload(Participant.custom_role).load_only(CustomRole.name), 

94 joinedload(Participant.organisation).load_only( 

95 Organisation.name, Organisation.public 

96 ), 

97 ) 

98 

99 def __contains__(self, organisation: Union[str, Organisation]) -> bool: 

100 org_id = organisation if isinstance(organisation, str) else organisation.id 

101 q = self.project.participants_query.filter_by(org_id=org_id) 

102 session = object_session(self.project) 

103 if session is None: 

104 raise ValueError("Project not associated with a session") 

105 return session.query(q.exists()).scalar() 

106 

107 def __iter__(self) -> Iterator[Participant]: 

108 for p in self.roleq: 

109 yield p 

110 

111 def __len__(self) -> int: 

112 return self.project.participants_query.count() 

113 

114 def get(self, org_id: str) -> Participant: 

115 return self.project.participants_query.filter_by(org_id=org_id).one() 

116 

117 def add(self, participant: Participant) -> None: 

118 return self.project.participants_query.append(participant) 

119 

120 def clear(self) -> None: 

121 return self.project.participants_query.delete(synchronize_session="fetch") 

122 

123 

124class LazyRestrictedUsers: 

125 def __init__(self, project): 

126 self.project = project 

127 

128 def project_perms_query(self): 

129 session = object_session(self.project) 

130 q = session.query(ProjectPermission) 

131 q = ( 

132 q.join(Participant) 

133 .join(Project) 

134 .filter(Participant.project == self.project) 

135 ) 

136 return q 

137 

138 def __contains__(self, user): 

139 q = self.project_perms_query().filter( 

140 ProjectPermission.user == user, 

141 Participant.organisation == user.organisation, 

142 ) 

143 return object_session(self.project).query(q.exists()).scalar() 

144 

145 def __iter__(self): 

146 for pp in self.project_perms_query(): 

147 yield pp 

148 

149 

150proj_cat_rel = Table( 

151 "project_categories", 

152 Base.metadata, 

153 Column("id", Integer, primary_key=True), 

154 Column( 

155 "project_id", Integer, index=True, nullable=False, server_default=text("'0'") 

156 ), 

157 Column("category_id", Integer, nullable=False, server_default=text("'0'")), 

158 ForeignKeyConstraint( 

159 ["category_id"], 

160 ["categories.id"], 

161 name="project_categories_ibfk_1", 

162 ondelete="CASCADE", 

163 ), 

164 ForeignKeyConstraint( 

165 ["project_id"], ["projects.id"], name="project_categories_ibfk_2" 

166 ), 

167 UniqueConstraint("project_id", "category_id", name="project_category_unique"), 

168 Index("category_id", "category_id", "project_id"), 

169 mysql_engine="InnoDB", 

170 mysql_charset="utf8mb4", 

171) 

172 

173 

174class Project(Base): 

175 __tablename__ = "projects" 

176 

177 __mapper_args__ = {"polymorphic_on": "status", "polymorphic_identity": "Base"} 

178 

179 base_attrs = ( 

180 "title,id,status,author_id,description,allow_private_communication," 

181 + "deadline,date_published,deadline,multiscored,url,maximum_score" 

182 ).split(",") 

183 

184 section_id: Mapped[Optional[int]] = mapped_column( 

185 Integer, 

186 ForeignKey( 

187 "sections.id", use_alter=True, name="fk_prj_sec_id", ondelete="SET NULL" 

188 ), 

189 nullable=True, 

190 ) 

191 

192 status: Mapped[str] = mapped_column(Status, default="Draft", nullable=False) 

193 org_id: Mapped[Optional[str]] = mapped_column( 

194 VARCHAR(length=50), 

195 ForeignKey("organisations.id", onupdate="CASCADE", ondelete="SET NULL"), 

196 nullable=True, 

197 ) 

198 

199 author_id: Mapped[Optional[str]] = mapped_column( 

200 VARCHAR(length=50), ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

201 ) 

202 

203 allow_private_communication: Mapped[bool] = mapped_column( 

204 TINYINT(), nullable=False, server_default=text("'0'") 

205 ) 

206 date_created: Mapped[datetime] = mapped_column( 

207 DateTime, nullable=False, server_default=func.now() 

208 ) 

209 date_published: Mapped[Optional[datetime]] = mapped_column(DateTime) 

210 deadline: Mapped[Optional[datetime]] = mapped_column(DateTime) 

211 description: Mapped[Optional[str]] = deferred(mapped_column(LONGTEXT(), nullable=True)) 

212 email: Mapped[Optional[str]] = mapped_column(Unicode(255), nullable=True) 

213 

214 # Some organisations want to prevent restricted users 

215 # (domain experts) from accessing Respondent's attachment 

216 expose_response_attachments: Mapped[bool] = mapped_column( 

217 TINYINT(4), nullable=False, server_default=text("'0'") 

218 ) 

219 

220 # Whether Respondents should get to see weightings 

221 expose_weightings: Mapped[bool] = mapped_column( 

222 TINYINT(4), nullable=False, server_default=text("'0'") 

223 ) 

224 

225 # Don't allow buyers/consultants to view answers in Submitted Issues 

226 # until the deadline of the Project has passed - some regulations demand 

227 # that all bidders are evaluated over the same period of time. 

228 hide_responses: Mapped[bool] = mapped_column( 

229 TINYINT(1), nullable=False, server_default=text("'0'") 

230 ) 

231 

232 library: Mapped[bool] = mapped_column( 

233 TINYINT(1), nullable=False, server_default=text("'0'") 

234 ) 

235 maximum_score: Mapped[int] = mapped_column( 

236 Integer, default=10, server_default=text("'10'"), nullable=False 

237 ) 

238 multiscored: Mapped[bool] = mapped_column( 

239 TINYINT(1), nullable=False, server_default=text("'0'") 

240 ) 

241 normalised_weights: Mapped[bool] = mapped_column( 

242 TINYINT(1), nullable=False, server_default=text("'0'") 

243 ) 

244 public: Mapped[bool] = mapped_column( 

245 TINYINT(4), nullable=False, server_default=text("'0'") 

246 ) 

247 questionnaire_locked: Mapped[bool] = mapped_column( 

248 TINYINT(1), nullable=False, server_default=text("'0'") 

249 ) 

250 reminder: Mapped[Optional[int]] = mapped_column(Integer) 

251 require_acceptance: Mapped[bool] = mapped_column( 

252 TINYINT(1), nullable=False, server_default=text("'1'") 

253 ) 

254 revision: Mapped[int] = mapped_column( 

255 Integer, nullable=False, server_default=text("'0'") 

256 ) 

257 template: Mapped[bool] = mapped_column( 

258 TINYINT(1), nullable=False, server_default=text("'0'") 

259 ) 

260 title: Mapped[str] = mapped_column( 

261 Unicode(200), nullable=False, server_default=text("''") 

262 ) 

263 _type: Mapped[str] = mapped_column( 

264 "type", CHAR(4), nullable=False, default="norm", server_default=text("'norm'") 

265 ) 

266 website: Mapped[Optional[str]] = mapped_column(VARCHAR(length=255), nullable=True) 

267 lock_issues: Mapped[bool] = mapped_column( 

268 TINYINT(4), nullable=False, server_default=text("'0'") 

269 ) 

270 

271 owner_org: Mapped["Organisation"] = relationship( 

272 "Organisation", lazy="joined", uselist=False, back_populates="projects" 

273 ) 

274 

275 author: Mapped["User"] = relationship("User") 

276 

277 _issues: DynamicMapped["Issue"] = relationship("Issue", lazy="dynamic") 

278 

279 participant_watchers: DynamicMapped["User"] = relationship( 

280 "User", secondary="project_watch_list", lazy="dynamic", viewonly=True 

281 ) 

282 

283 questions: DynamicMapped["QuestionInstance"] = relationship( 

284 "QuestionInstance", lazy="dynamic", viewonly=True 

285 ) 

286 participants_query: DynamicMapped["Participant"] = relationship( 

287 "Participant", 

288 lazy="dynamic", 

289 back_populates="project", 

290 cascade="all, delete", 

291 passive_deletes=True, 

292 ) 

293 

294 root_section: Mapped[Optional["Section"]] = relationship( 

295 "Section", 

296 uselist=False, 

297 post_update=True, 

298 primaryjoin="foreign(Project.section_id)==remote(Section.id)", 

299 ) 

300 

301 sections: DynamicMapped["Section"] = relationship( 

302 "Section", 

303 lazy="dynamic", 

304 primaryjoin="Project.id==Section.project_id", 

305 cascade="all, delete-orphan", 

306 ) 

307 

308 _attachments: DynamicMapped["ProjectAttachment"] = relationship( 

309 "ProjectAttachment", lazy="dynamic", order_by="ProjectAttachment.position" 

310 ) 

311 

312 categories: Mapped[list["Category"]] = relationship( 

313 "Category", secondary=proj_cat_rel 

314 ) 

315 

316 answer_reports: Mapped[list["AnswerReport"]] = relationship( 

317 "AnswerReport", 

318 back_populates="project", 

319 cascade="all,delete", 

320 passive_deletes=True, 

321 ) 

322 

323 weighting_sets: DynamicMapped["WeightingSet"] = relationship( 

324 "WeightingSet", 

325 lazy="dynamic", 

326 back_populates="project", 

327 cascade="all,delete", 

328 passive_deletes=True, 

329 ) 

330 

331 project_fields: Mapped[list["ProjectField"]] = relationship( 

332 "ProjectField", 

333 back_populates="project", 

334 cascade="all,delete,delete-orphan", 

335 passive_deletes=True, 

336 order_by="ProjectField.position", 

337 ) 

338 

339 qelements: DynamicMapped["QElement"] = relationship( 

340 "QElement", 

341 primaryjoin="Project.id==QuestionInstance.project_id", 

342 secondaryjoin="QuestionDefinition.id==QElement.question_id", 

343 secondary="join(QuestionInstance, QuestionDefinition)", 

344 lazy="dynamic", 

345 viewonly=True, 

346 ) 

347 

348 all_respondent_watchers: DynamicMapped["User"] = relationship( 

349 User, 

350 primaryjoin="Project.id==Issue.project_id", 

351 secondaryjoin="IssueWatchList.user_id==User.id", 

352 secondary="join(Issue, IssueWatchList)", 

353 lazy="dynamic", 

354 viewonly=True, 

355 ) 

356 

357 watch_list: DynamicMapped["ProjectWatchList"] = relationship( 

358 "ProjectWatchList", 

359 lazy="dynamic", 

360 cascade="all, delete", 

361 passive_deletes=True, 

362 back_populates="project", 

363 ) 

364 notes_query: DynamicMapped["ProjectNote"] = relationship( 

365 "ProjectNote", 

366 back_populates="project", 

367 cascade="all,delete", 

368 passive_deletes=True, 

369 lazy="dynamic", 

370 ) 

371 total_weightings: DynamicMapped["TotalWeighting"] = relationship( 

372 "TotalWeighting", 

373 back_populates="project", 

374 cascade="all,delete", 

375 passive_deletes=True, 

376 lazy="dynamic", 

377 ) 

378 events: DynamicMapped["AuditEvent"] = relationship( 

379 "AuditEvent", 

380 back_populates="project", 

381 cascade_backrefs=False, 

382 lazy="dynamic", 

383 primaryjoin=("foreign(AuditEvent.project_id)" "==Project.id"), 

384 ) 

385 

386 def __init__(self, title, *args, **kwargs): 

387 super(Project, self).__init__(*args, **kwargs) 

388 self.title = title 

389 

390 @property 

391 def issues(self) -> list[Issue]: 

392 return self._issues.all() 

393 

394 def issue_by_id(self, issue_id): 

395 return self._issues.filter_by(id=issue_id).one() 

396 

397 def question_by_number(self, question_number): 

398 return self.questions.filter_by(number=question_number).one() 

399 

400 @property 

401 def published_issues(self) -> list[Issue]: 

402 """Issues visible to respondents""" 

403 return self._issues.filter( 

404 Issue.status.in_(("Accepted", "Submitted", "Opportunity", "Updateable")) 

405 ).all() 

406 

407 @property 

408 def opportunity_issues(self) -> list[Issue]: 

409 """Issues at status Opportunity""" 

410 return self._issues.filter(Issue.status.in_(("Opportunity",))).all() 

411 

412 @property 

413 def respondent_watchers(self) -> Iterable[User]: 

414 """ 

415 A SQLAlchemy query of issue watchers whose issues are visible to Respondents 

416 """ 

417 visible_statuses = ("Accepted", "Submitted", "Opportunity", "Updateable") 

418 return self.all_respondent_watchers.filter(Issue.status.in_(visible_statuses)) 

419 

420 def iter_all_watchers(self) -> Iterable[User]: 

421 """ 

422 Iterator returns all valid respondent or participant Users watching this project 

423 """ 

424 yield from self.respondent_watchers 

425 yield from self.participant_watchers 

426 

427 @property 

428 def deadline_passed(self): 

429 if not self.deadline: 

430 return False 

431 return datetime.now() > self.deadline 

432 

433 @property 

434 def scoreable_issues(self) -> list[Issue]: 

435 if not hasattr(self, "_scoreable_issues_cache"): 

436 issues = self._issues.filter(Issue.scoreable_filter(self)).all() 

437 self._scoreable_issues_cache = issues 

438 

439 return self._scoreable_issues_cache 

440 

441 def get_issue(self, issue_id) -> Issue: 

442 """ 

443 Fetch the Issue by id. 

444 

445 Raises NotFoundError if an Issue with the given ID doesn't belong to this project 

446 """ 

447 return self._issues.filter(Issue.id == issue_id).one() 

448 

449 def __repr__(self): 

450 return "<Project %s : %s>" % (self.id, self.title) 

451 

452 @property 

453 def participants(self) -> LazyParticipants: 

454 """A collection which lazily implements Set operations""" 

455 return LazyParticipants(self) 

456 

457 @property 

458 def restricted_users(self): 

459 return LazyRestrictedUsers(self) 

460 

461 @property 

462 def project_type(self): 

463 if self._type == "gcpy": 

464 return "REFERENCE" 

465 else: 

466 return "NORMAL" 

467 

468 @project_type.setter 

469 def project_type(self, ptype): 

470 if ptype not in ("REFERENCE", "NORMAL"): 

471 raise ValueError(f"{ptype} is not a recognised value for project_type") 

472 if ptype == "REFERENCE": 

473 self._type = "gcpy" 

474 else: 

475 self._type = "norm" 

476 

477 def participant_role_permissions(self, user): 

478 try: 

479 participant = self.participants.get(user.organisation.id) 

480 return participant.role_permissions 

481 except NoResultFound: 

482 return set() 

483 

484 def list_attachments(self, user): 

485 self.check_attachments_visible(user) 

486 return self._attachments.all() 

487 

488 def get_attachment(self, user, attachment_id): 

489 self.check_attachments_visible(user) 

490 return self._attachments.filter(ProjectAttachment.id == attachment_id).one() 

491 

492 def list_issue_attachments(self, user): 

493 self.check_attachments_visible(user) 

494 rows = ( 

495 self._issues.filter(Issue.scoreable_filter(self)) 

496 .add_entity(IssueAttachment) 

497 .join(IssueAttachment) 

498 .order_by(Issue.id) 

499 .all() 

500 ) 

501 

502 return [att for _iss, att in rows] 

503 

504 def check_attachments_visible(self, user): 

505 """ 

506 Check if user can view attachments for this project 

507 @raises AuthorizationFailure 

508 """ 

509 if user.is_restricted and not self.expose_response_attachments: 

510 raise AuthorizationFailure("Attachments not visible to restricted users") 

511 

512 def get_participant(self, organisation): 

513 """ 

514 Fetch the Participant associated with the given organisation 

515 for this project 

516 @raise NoResultFound if organisation is not a participant 

517 """ 

518 return self.participants.get(organisation.id) 

519 

520 def query_visible_questions(self, user): 

521 """ 

522 Returns a Question query object, filtered by section permission 

523 if the user is restricted 

524 """ 

525 if not user.is_restricted: 

526 return self.questions 

527 else: 

528 return ( 

529 self.questions.join(QuestionInstance.section) 

530 .join(Section.perms) 

531 .filter_by(user=user) 

532 ) 

533 

534 def visit_questionnaire(self, *visitors): 

535 """ 

536 Run the provided visitor(s) on the root section of this project. 

537 """ 

538 from rfpy.utils import benchmark 

539 

540 q = self.sections.options( 

541 joinedload(Section.subsections) 

542 .subqueryload(Section.questions) 

543 .lazyload(QuestionInstance.question_def) 

544 .lazyload(QuestionDefinition.elements) 

545 ) 

546 

547 # Should be the first item returned by genexp 

548 root = next(s for s in q.order_by(Section.parent_id) if s.parent_id is None) 

549 

550 if root.parent_id is not None or root.id != self.section_id: 

551 m = f"Failed to find root section for project {self.id}" 

552 raise QuestionnaireStructureException(m) 

553 

554 for visitor in visitors: 

555 with benchmark("visitor %s" % visitor): 

556 root.accept(visitor) 

557 visitor.finalise() 

558 

559 return root 

560 

561 def print_questionnaire(self): # pragma: no cover 

562 self.visit_questionnaire(PrintVisitor()) 

563 

564 def calculate_normalised_weights(self): 

565 self.visit_questionnaire(HierarchyWeightingsVisitor()) 

566 

567 def save_total_weights(self, weighting_set_id=None): 

568 sess = object_session(self) 

569 

570 if weighting_set_id: 

571 ws = sess.get(WeightingSet, weighting_set_id) 

572 visitors = ( 

573 LoadWeightSetVisitor(ws), 

574 HierarchyWeightingsVisitor(), 

575 SaveTotalWeightingsVisitor(sess, self, weighting_set_id), 

576 ) 

577 else: 

578 visitors = ( 

579 HierarchyWeightingsVisitor(), 

580 SaveTotalWeightingsVisitor(sess, self, weighting_set_id), 

581 ) 

582 

583 self.visit_questionnaire(*visitors) 

584 

585 def delete_total_weights(self, weighting_set_id=None): 

586 """Delete TotalWeight records for this project for the given weighting set""" 

587 self.total_weightings.filter_by(weighting_set_id=weighting_set_id).delete() 

588 

589 def total_weights_exist_for(self, weighting_set_id=None): 

590 q = self.total_weightings.filter_by(weighting_set_id=weighting_set_id) 

591 return q.count() > 0 

592 

593 def add_issue(self, issue): 

594 self._issues.append(issue) 

595 

596 def add_watcher(self, user: User) -> bool: 

597 """ 

598 Adds the given user to this watch list. Returns True if successful, 

599 False if the user is already watching 

600 """ 

601 if self.watch_list.filter_by(user=user).count() == 0: 

602 self.watch_list.append(ProjectWatchList(user=user)) 

603 return True 

604 else: 

605 return False 

606 

607 def contains_section_id(self, section_id): 

608 return self.sections.filter_by(id=section_id).count() == 1 

609 

610 

611class DraftProject(Project): 

612 __mapper_args__ = {"polymorphic_identity": "Draft"} 

613 

614 

615class LiveProject(Project): 

616 __mapper_args__ = {"polymorphic_identity": "Live"} 

617 

618 def build_score_key(self, issue, question, scoreset_id): 

619 return "%s:%s:%s" % (issue.id, question.id, scoreset_id) 

620 

621 def generate_autoscores(self, session, user): 

622 autoscores_dict = {} 

623 for question in self.query_visible_questions(user): 

624 if not question.is_autoscored: 

625 continue 

626 autoscore_map = question.calculate_autoscores() 

627 for issue in self.scoreable_issues: 

628 if issue in autoscore_map: 

629 score_value = autoscore_map[issue] 

630 key = self.build_score_key(issue, question, user.id) 

631 autoscores_dict[key] = { 

632 "issue_id": issue.id, 

633 "respondent": issue.respondent.name, 

634 "question_id": question.id, 

635 "scoreset_id": user.id, 

636 "score": score_value, 

637 "question_number": question.number.dotted, 

638 } 

639 return autoscores_dict 

640 

641 def scores_dict(self): 

642 project_scores = {} 

643 for issue in self.scoreable_issues: 

644 for score in issue.scores.all(): 

645 key = self.build_score_key(issue, score.question, score.scoreset_id) 

646 project_scores[key] = score 

647 return project_scores 

648 

649 

650class ClosedProject(Project): 

651 __mapper_args__ = {"polymorphic_identity": "Closed"} 

652 

653 

654class ProjectAttachment(AttachmentMixin, Base): 

655 __tablename__ = "project_attachments" 

656 

657 public_attrs = ("id,description,size,filename,mimetype,private").split(",") 

658 description: Mapped[Optional[str]] = mapped_column(VARCHAR(length=255), nullable=True) 

659 project_id: Mapped[Optional[int]] = mapped_column( 

660 Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True 

661 ) 

662 date_uploaded: Mapped[datetime] = mapped_column( 

663 DateTime, server_default=text("CURRENT_TIMESTAMP"), nullable=True 

664 ) 

665 author_id: Mapped[Optional[str]] = mapped_column( 

666 VARCHAR(length=150), ForeignKey("users.id", ondelete="SET NULL"), nullable=True 

667 ) 

668 org_id: Mapped[Optional[str]] = mapped_column( 

669 VARCHAR(length=150), 

670 ForeignKey("organisations.id", onupdate="CASCADE", ondelete="SET NULL"), 

671 nullable=True, 

672 ) 

673 private: Mapped[bool] = mapped_column( 

674 Boolean, default=False, server_default=text("0"), nullable=True 

675 ) 

676 position: Mapped[int] = mapped_column("pos", Integer, server_default=text("0"), nullable=True) 

677 note_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) 

678 

679 project = relationship(Project, back_populates="_attachments") 

680 organisation = relationship("Organisation") 

681 

682 

683class ProjectField(Base): 

684 __tablename__ = "project_fields" 

685 __table_args__ = ( 

686 UniqueConstraint("project_id", "key", name="project_field_id_key"), 

687 UniqueConstraint("project_id", "position", name="project_field_id_position"), 

688 {"mysql_engine": "InnoDB", "mysql_charset": "utf8mb4"}, 

689 ) 

690 public_attrs = ["id", "key", "value", "private"] 

691 

692 project_id: Mapped[int] = mapped_column( 

693 INTEGER(11), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False 

694 ) 

695 private: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) 

696 key: Mapped[str] = mapped_column(VARCHAR(length=64), nullable=False) 

697 value: Mapped[Optional[str]] = mapped_column(MEDIUMTEXT(), nullable=True) 

698 position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) 

699 

700 project = relationship("Project", back_populates="project_fields") 

701 

702 def __repr__(self) -> str: 

703 return ( 

704 f"<ProjectField#{self.id}: key={self.key}, " 

705 f"position={self.position}, project_id={self.project_id}>" 

706 ) 

707 

708 

709class PrintVisitor(Visitor): # pragma: no cover 

710 """Prints out the questionnaire structure for easier debugging""" 

711 

712 def __init__(self): 

713 self.depth = 1 

714 

715 @property 

716 def inset(self): 

717 return " " * self.depth 

718 

719 def hello_section(self, sec): 

720 print("") 

721 fmt = ( 

722 "{inset} {title} - {num}/{id}/{pid}" 

723 + " W: ({weight}, Norm: {norm_weight:.3f}, Abs: {abs_weight:.3f})" 

724 ) 

725 normalised_weight = getattr(sec, "normalised_weight", 0) 

726 absolute_weight = getattr(sec, "absolute_weight", 0) 

727 sec_id = getattr(sec, "id", "<no id set>") 

728 

729 out = fmt.format( 

730 inset=self.inset, 

731 num=sec.safe_number, 

732 title=sec.title, 

733 weight=sec.weight, 

734 norm_weight=normalised_weight, 

735 abs_weight=absolute_weight, 

736 id=sec_id, 

737 pid=sec.parent_id, 

738 ) 

739 print(out) 

740 self.depth += 1 

741 

742 def goodbye_section(self, _sec): 

743 self.depth -= 1 

744 

745 def visit_question(self, q): 

746 normalised_weight = getattr(q, "normalised_weight", 0) 

747 absolute_weight = getattr(q, "absolute_weight", 0) 

748 m = ( 

749 f"{self.inset} Q: {q.safe_number} (W: {q.weight:.3f}," 

750 f" Norm: {normalised_weight:.3f} Abs: {absolute_weight:.4f})" 

751 ) 

752 print(m)