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
« 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
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
39from rfpy.auth import AuthorizationFailure
42from rfpy.model.meta import Base, AttachmentMixin, Visitor
43from rfpy.model.issue import Issue, IssueAttachment
44from rfpy.model.notify import ProjectWatchList
46from rfpy.model.questionnaire import (
47 HierarchyWeightingsVisitor,
48 SaveTotalWeightingsVisitor,
49 LoadWeightSetVisitor,
50 WeightingSet,
51 Section,
52 QuestionInstance,
53 QuestionDefinition,
54)
56from rfpy.model import ProjectPermission, Participant, User, Organisation, CustomRole
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
65status = {0: "Draft", 10: "Live", 20: "Closed"}
66status_int = {v: k for k, v in status.items()}
69class Status(types.TypeDecorator):
70 impl = TINYINT()
72 cache_ok = True
74 def process_bind_param(self, value, dialect):
75 return status_int[value]
77 def process_result_value(self, value, dialect):
78 return status[value]
81class LazyParticipants:
82 """
83 Provides convenient set/collection syntax access for querying
84 participants associated with the given project
85 """
87 def __init__(self, project):
88 self.project = project
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 )
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()
107 def __iter__(self) -> Iterator[Participant]:
108 for p in self.roleq:
109 yield p
111 def __len__(self) -> int:
112 return self.project.participants_query.count()
114 def get(self, org_id: str) -> Participant:
115 return self.project.participants_query.filter_by(org_id=org_id).one()
117 def add(self, participant: Participant) -> None:
118 return self.project.participants_query.append(participant)
120 def clear(self) -> None:
121 return self.project.participants_query.delete(synchronize_session="fetch")
124class LazyRestrictedUsers:
125 def __init__(self, project):
126 self.project = project
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
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()
145 def __iter__(self):
146 for pp in self.project_perms_query():
147 yield pp
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)
174class Project(Base):
175 __tablename__ = "projects"
177 __mapper_args__ = {"polymorphic_on": "status", "polymorphic_identity": "Base"}
179 base_attrs = (
180 "title,id,status,author_id,description,allow_private_communication,"
181 + "deadline,date_published,deadline,multiscored,url,maximum_score"
182 ).split(",")
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 )
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 )
199 author_id: Mapped[Optional[str]] = mapped_column(
200 VARCHAR(length=50), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
201 )
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)
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 )
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 )
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 )
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 )
271 owner_org: Mapped["Organisation"] = relationship(
272 "Organisation", lazy="joined", uselist=False, back_populates="projects"
273 )
275 author: Mapped["User"] = relationship("User")
277 _issues: DynamicMapped["Issue"] = relationship("Issue", lazy="dynamic")
279 participant_watchers: DynamicMapped["User"] = relationship(
280 "User", secondary="project_watch_list", lazy="dynamic", viewonly=True
281 )
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 )
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 )
301 sections: DynamicMapped["Section"] = relationship(
302 "Section",
303 lazy="dynamic",
304 primaryjoin="Project.id==Section.project_id",
305 cascade="all, delete-orphan",
306 )
308 _attachments: DynamicMapped["ProjectAttachment"] = relationship(
309 "ProjectAttachment", lazy="dynamic", order_by="ProjectAttachment.position"
310 )
312 categories: Mapped[list["Category"]] = relationship(
313 "Category", secondary=proj_cat_rel
314 )
316 answer_reports: Mapped[list["AnswerReport"]] = relationship(
317 "AnswerReport",
318 back_populates="project",
319 cascade="all,delete",
320 passive_deletes=True,
321 )
323 weighting_sets: DynamicMapped["WeightingSet"] = relationship(
324 "WeightingSet",
325 lazy="dynamic",
326 back_populates="project",
327 cascade="all,delete",
328 passive_deletes=True,
329 )
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 )
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 )
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 )
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 )
386 def __init__(self, title, *args, **kwargs):
387 super(Project, self).__init__(*args, **kwargs)
388 self.title = title
390 @property
391 def issues(self) -> list[Issue]:
392 return self._issues.all()
394 def issue_by_id(self, issue_id):
395 return self._issues.filter_by(id=issue_id).one()
397 def question_by_number(self, question_number):
398 return self.questions.filter_by(number=question_number).one()
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()
407 @property
408 def opportunity_issues(self) -> list[Issue]:
409 """Issues at status Opportunity"""
410 return self._issues.filter(Issue.status.in_(("Opportunity",))).all()
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))
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
427 @property
428 def deadline_passed(self):
429 if not self.deadline:
430 return False
431 return datetime.now() > self.deadline
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
439 return self._scoreable_issues_cache
441 def get_issue(self, issue_id) -> Issue:
442 """
443 Fetch the Issue by id.
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()
449 def __repr__(self):
450 return "<Project %s : %s>" % (self.id, self.title)
452 @property
453 def participants(self) -> LazyParticipants:
454 """A collection which lazily implements Set operations"""
455 return LazyParticipants(self)
457 @property
458 def restricted_users(self):
459 return LazyRestrictedUsers(self)
461 @property
462 def project_type(self):
463 if self._type == "gcpy":
464 return "REFERENCE"
465 else:
466 return "NORMAL"
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"
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()
484 def list_attachments(self, user):
485 self.check_attachments_visible(user)
486 return self._attachments.all()
488 def get_attachment(self, user, attachment_id):
489 self.check_attachments_visible(user)
490 return self._attachments.filter(ProjectAttachment.id == attachment_id).one()
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 )
502 return [att for _iss, att in rows]
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")
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)
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 )
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
540 q = self.sections.options(
541 joinedload(Section.subsections)
542 .subqueryload(Section.questions)
543 .lazyload(QuestionInstance.question_def)
544 .lazyload(QuestionDefinition.elements)
545 )
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)
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)
554 for visitor in visitors:
555 with benchmark("visitor %s" % visitor):
556 root.accept(visitor)
557 visitor.finalise()
559 return root
561 def print_questionnaire(self): # pragma: no cover
562 self.visit_questionnaire(PrintVisitor())
564 def calculate_normalised_weights(self):
565 self.visit_questionnaire(HierarchyWeightingsVisitor())
567 def save_total_weights(self, weighting_set_id=None):
568 sess = object_session(self)
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 )
583 self.visit_questionnaire(*visitors)
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()
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
593 def add_issue(self, issue):
594 self._issues.append(issue)
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
607 def contains_section_id(self, section_id):
608 return self.sections.filter_by(id=section_id).count() == 1
611class DraftProject(Project):
612 __mapper_args__ = {"polymorphic_identity": "Draft"}
615class LiveProject(Project):
616 __mapper_args__ = {"polymorphic_identity": "Live"}
618 def build_score_key(self, issue, question, scoreset_id):
619 return "%s:%s:%s" % (issue.id, question.id, scoreset_id)
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
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
650class ClosedProject(Project):
651 __mapper_args__ = {"polymorphic_identity": "Closed"}
654class ProjectAttachment(AttachmentMixin, Base):
655 __tablename__ = "project_attachments"
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)
679 project = relationship(Project, back_populates="_attachments")
680 organisation = relationship("Organisation")
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"]
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)
700 project = relationship("Project", back_populates="project_fields")
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 )
709class PrintVisitor(Visitor): # pragma: no cover
710 """Prints out the questionnaire structure for easier debugging"""
712 def __init__(self):
713 self.depth = 1
715 @property
716 def inset(self):
717 return " " * self.depth
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>")
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
742 def goodbye_section(self, _sec):
743 self.depth -= 1
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)