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

282 statements  

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

1from datetime import datetime 

2from typing import List, Tuple, Optional, TYPE_CHECKING 

3 

4from sqlalchemy import ( 

5 Integer, 

6 ForeignKey, 

7 DateTime, 

8 Boolean, 

9 and_, 

10 event, 

11 text, 

12 Index, 

13) 

14from sqlalchemy.dialects import mysql 

15from sqlalchemy.orm.session import object_session 

16from sqlalchemy.orm import Mapped, mapped_column, relationship, backref, DynamicMapped 

17from sqlalchemy.ext.hybrid import hybrid_method 

18import sqlalchemy.types as types 

19 

20from rfpy.auth import perms as p 

21from rfpy.model.exc import IllegalStatusAction, DeadlineHasPassed, BusinessRuleViolation 

22from rfpy.model.helpers import ensure 

23from .audit import AuditEvent, evt_types 

24from .notify import IssueWatchList 

25from .meta import Base, AttachmentMixin 

26from .humans import Organisation, User 

27 

28if TYPE_CHECKING: 

29 from .questionnaire import QuestionResponseState, Answer 

30 from .project import Project 

31 

32 

33class IssueStatusType(types.TypeDecorator): 

34 impl = mysql.TINYINT() 

35 

36 cache_ok = True 

37 

38 def process_bind_param(self, value, dialect): 

39 try: 

40 return Issue.issue_statuses_int[value] 

41 except KeyError: 

42 vals = ",".join(Issue.issue_statuses.values()) 

43 raise KeyError(f"Status '{value}' not found in {vals}") 

44 

45 def process_result_value(self, value, dialect): 

46 return Issue.issue_statuses[value] 

47 

48 

49class Issue(Base): 

50 """@DynamicAttrs""" 

51 

52 __tablename__ = "issues" 

53 

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

55 

56 class Status: 

57 NOT_SENT = 0 

58 OPPORTUNITY = 10 

59 ACCEPTED = 20 

60 UPDATEABLE = 25 

61 DECLINED = 30 

62 SUBMITTED = 40 

63 RETRACTED = 50 

64 

65 issue_statuses = { 

66 Status.NOT_SENT: "Not Sent", 

67 Status.OPPORTUNITY: "Opportunity", 

68 Status.ACCEPTED: "Accepted", 

69 Status.UPDATEABLE: "Updateable", 

70 Status.DECLINED: "Declined", 

71 Status.SUBMITTED: "Submitted", 

72 Status.RETRACTED: "Retracted", 

73 } 

74 

75 issue_statuses_int = {v: k for k, v in issue_statuses.items()} 

76 status_names = set(issue_statuses.values()) 

77 

78 scoreable_statuses = (Status.SUBMITTED, Status.UPDATEABLE) 

79 

80 project_id: Mapped[int] = mapped_column( 

81 Integer, 

82 ForeignKey("projects.id"), 

83 nullable=False, 

84 index=True, 

85 server_default=text("'0'"), 

86 ) 

87 respondent_id: Mapped[Optional[str]] = mapped_column( 

88 mysql.VARCHAR(length=150), 

89 ForeignKey("organisations.id", onupdate="CASCADE"), 

90 nullable=True, 

91 ) 

92 respondent_email: Mapped[Optional[str]] = mapped_column(mysql.VARCHAR(length=80)) 

93 issue_date: Mapped[Optional[datetime]] = mapped_column(DateTime) 

94 accepted_date: Mapped[Optional[datetime]] = mapped_column(DateTime) 

95 submitted_date: Mapped[Optional[datetime]] = mapped_column(DateTime) 

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

97 use_workflow: Mapped[bool] = mapped_column( 

98 Boolean, nullable=False, default=False, server_default=text("'0'") 

99 ) 

100 status: Mapped[str] = mapped_column( 

101 IssueStatusType, nullable=False, default="Not Sent" 

102 ) 

103 feedback: Mapped[Optional[str]] = mapped_column(mysql.LONGTEXT) 

104 internal_comments: Mapped[Optional[str]] = mapped_column(mysql.LONGTEXT) 

105 award_status: Mapped[int] = mapped_column( 

106 mysql.TINYINT(), 

107 nullable=False, 

108 default=0, 

109 server_default=text("'0'"), 

110 ) 

111 label: Mapped[Optional[str]] = mapped_column(mysql.VARCHAR(length=255)) 

112 selected: Mapped[bool] = mapped_column( 

113 mysql.TINYINT(), 

114 default=False, 

115 server_default=text("'0'"), 

116 nullable=False, 

117 ) 

118 reminder_sent: Mapped[Optional[datetime]] = mapped_column(DateTime) 

119 winloss_exposed: Mapped[bool] = mapped_column( 

120 Boolean, nullable=False, default=0, server_default=text("'0'") 

121 ) 

122 winloss_expiry: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) 

123 winloss_weightset_id: Mapped[Optional[int]] = mapped_column( 

124 Integer, ForeignKey("weighting_sets.id", ondelete="SET NULL") 

125 ) 

126 

127 answers: DynamicMapped["Answer"] = relationship( 

128 "Answer", 

129 lazy="dynamic", 

130 passive_deletes=True, 

131 cascade_backrefs=False, 

132 cascade="all, delete", 

133 back_populates="issue", 

134 ) 

135 

136 response_states: DynamicMapped["QuestionResponseState"] = relationship( 

137 "QuestionResponseState", 

138 back_populates="issue", 

139 lazy="dynamic", 

140 passive_deletes=True, 

141 ) 

142 

143 project: Mapped["Project"] = relationship( 

144 "Project", uselist=False, back_populates="_issues" 

145 ) 

146 respondent: Mapped["Organisation"] = relationship( 

147 "Organisation", 

148 lazy="joined", 

149 uselist=False, 

150 backref=backref("issues", passive_deletes=True), 

151 ) 

152 

153 winloss_weightset = relationship("WeightingSet") 

154 

155 watchers: DynamicMapped[User] = relationship( 

156 "User", secondary="issue_watch_list", lazy="dynamic", viewonly=True 

157 ) 

158 scores: DynamicMapped["Score"] = relationship( 

159 "Score", back_populates="issue", lazy="dynamic" 

160 ) 

161 

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

163 "AuditEvent", 

164 back_populates="issue", 

165 lazy="dynamic", 

166 cascade_backrefs=False, 

167 primaryjoin="foreign(AuditEvent.issue_id)==Issue.id", 

168 ) 

169 

170 watch_list: DynamicMapped["IssueWatchList"] = relationship( 

171 "IssueWatchList", 

172 lazy="dynamic", 

173 back_populates="issue", 

174 cascade="all,delete", 

175 passive_deletes=True, 

176 ) 

177 

178 attachments: DynamicMapped["IssueAttachment"] = relationship( 

179 "IssueAttachment", 

180 lazy="dynamic", 

181 back_populates="issue", 

182 ) 

183 

184 def get_attachment(self, attachment_id): 

185 return self.attachments.filter(IssueAttachment.id == attachment_id).one() 

186 

187 @hybrid_method 

188 def can_view_winloss(self, user) -> bool: 

189 return ( 

190 self.winloss_exposed 

191 and (self.winloss_expiry is not None) 

192 and (self.winloss_expiry > datetime.now()) 

193 and (user.org_id == self.respondent_id) 

194 ) 

195 

196 @can_view_winloss.expression # type: ignore 

197 def can_view_sql(self, user) -> bool: 

198 return ( 

199 self.winloss_exposed 

200 & (self.winloss_expiry is not None) 

201 & (self.winloss_expiry > datetime.now()) # type: ignore 

202 & (user.org_id == self.respondent_id) 

203 ) 

204 

205 def __repr__(self) -> str: 

206 if self.respondent_id is not None: 

207 return "< Issue[%s] %s (%s) >" % (self.id, self.respondent_id, self.status) 

208 else: 

209 return "< Issue id %s (%s) >" % (self.id, self.status) 

210 

211 @property 

212 def deadline_passed(self) -> bool: 

213 if not self.deadline: 

214 return False 

215 return datetime.now() > self.deadline 

216 

217 @classmethod 

218 def scoreable_filter(cls, project): 

219 """ 

220 Expression to use in query(...).filter() to limit Issues to Submitted or Updateable 

221 Issues after the deadline if applicable for `project`. 

222 """ 

223 expr = Issue.status.in_(("Submitted", "Updateable")) 

224 if project.hide_responses: 

225 expr = and_(expr, Issue.deadline < datetime.now()) 

226 

227 return expr 

228 

229 def _change_status(self, new_status): 

230 """ 

231 Change the status value directly in the DB and remove 

232 the current self (Issue) object from the Session. 

233 

234 This is a nasty workaround because sqlalchemy polymorphism 

235 doesn't permit objects to change their type within a Session 

236 

237 NB - this method should be the last db function called before returning 

238 since it messes up the session 

239 """ 

240 sess = object_session(self) 

241 # Begin by flushing any pending changes since this function 

242 # will remove this Issue instance from the Session 

243 sess.flush() 

244 

245 issue_table = self.__table__ 

246 

247 exp = ( 

248 issue_table.update() 

249 .where(issue_table.c.id == self.id) 

250 .values(status=new_status) 

251 ) 

252 

253 sess.execute(exp) 

254 sess.expunge(self) 

255 

256 def change_status(self, user, new_status): 

257 """ 

258 Delegates to concrete subclass methods named to_[new_status] e.g. to_accepted() 

259 

260 Creates necessary AuditEvents and sets the issue_date to now() 

261 """ 

262 if not self.project.status == "Live": 

263 msg = "Project status must be Live to change status of an Issue" 

264 raise IllegalStatusAction(msg) 

265 

266 if new_status not in Issue.issue_statuses_int: 

267 raise ValueError('"%s" is not a valid Issue Status' % new_status) 

268 

269 try: 

270 meth_name = "to_%s" % new_status.lower() 

271 transition_method = getattr(self, meth_name) 

272 

273 except AttributeError: 

274 tmpl = "Cannot change status from %s to %s" 

275 msg = tmpl % (self.status, new_status) 

276 raise IllegalStatusAction(msg) 

277 

278 return transition_method(user) 

279 

280 def log_event(self, event_name, user, change_list: List[Tuple[str, str, str]]): 

281 session = object_session(self) 

282 assert session is not None 

283 evt = AuditEvent.create( 

284 session, 

285 event_name, 

286 object_id=self.id, 

287 user=user, 

288 issue=self, 

289 project=self.project, 

290 change_list=change_list, 

291 ) 

292 sess = object_session(self) 

293 assert sess is not None 

294 sess.add(evt) 

295 

296 def response_state_for_q(self, question_id): 

297 return self.response_states.filter_by(question_instance_id=question_id).one() 

298 

299 def add_watcher(self, watching_user: User) -> bool: 

300 """ 

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

302 False if the user is already watching 

303 """ 

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

305 self.watch_list.append(IssueWatchList(user=watching_user)) 

306 return True 

307 else: 

308 return False 

309 

310 

311class NotSentIssue(Issue): 

312 __mapper_args__ = {"polymorphic_identity": "Not Sent"} 

313 

314 def _publish(self, new_status, user): 

315 self.issue_date = datetime.now() 

316 

317 # Log event before changing status - self (this Issue) is detached from 

318 # SQLA session afterwards 

319 changes = [ 

320 ("status", self.status, new_status), 

321 ("issue_date", None, self.issue_date), 

322 ] 

323 self.log_event("ISSUE_RELEASED", user, changes) 

324 self._change_status(new_status) 

325 

326 @ensure(p.ISSUE_PUBLISH, is_participant=True) 

327 def to_opportunity(self, user): 

328 self._publish("Opportunity", user) 

329 

330 @ensure(p.ISSUE_PUBLISH, is_participant=True) 

331 def to_updateable(self, user): 

332 self._publish("Updateable", user) 

333 

334 @ensure(p.ISSUE_PUBLISH, is_participant=True) 

335 def to_accepted(self, user): 

336 self._publish("Accepted", user) 

337 

338 

339class OpportunityIssue(Issue): 

340 __mapper_args__ = {"polymorphic_identity": "Opportunity"} 

341 

342 @ensure(p.ISSUE_ACCEPT, is_vendor=True) 

343 def to_accepted(self, user): 

344 changes = [("status", self.status, "Accepted")] 

345 self.log_event("ISSUE_ACCEPTED", user, changes) 

346 self._change_status("Accepted") 

347 

348 @ensure(p.ISSUE_DECLINE, is_vendor=True) 

349 def to_declined(self, user): 

350 changes = [("status", self.status, "Declined")] 

351 self.log_event("ISSUE_DECLINED", user, changes) 

352 self._change_status("Declined") 

353 

354 @ensure(p.ISSUE_RETRACT, is_participant=True) 

355 def to_retracted(self, user): 

356 changes = [("status", self.status, "Retracted")] 

357 self.log_event("ISSUE_RETRACTED", user, changes) 

358 self._change_status("Retracted") 

359 

360 

361class AcceptedIssue(Issue): 

362 __mapper_args__ = {"polymorphic_identity": "Accepted"} 

363 

364 @ensure(p.ISSUE_DECLINE, is_vendor=True) 

365 def to_declined(self, user): 

366 changes = [("status", self.status, "Declined")] 

367 self.log_event("ISSUE_DECLINED", user, changes) 

368 self._change_status("Declined") 

369 

370 @ensure(p.ISSUE_SUBMIT, is_vendor=True) 

371 def to_submitted(self, user): 

372 from rfpy.api import fetch 

373 

374 if self.deadline and self.deadline < datetime.now(): 

375 msg = "This Response cannot be submitted - Deadline has passed" 

376 raise DeadlineHasPassed(msg) 

377 

378 unanswered = fetch.unanswered_mandatory(self).all() 

379 uc = len(unanswered) 

380 if uc > 0: 

381 nums = ", ".join(num.dotted for (_, num) in unanswered) 

382 v = ("is", "") if uc == 1 else ("are", "s") 

383 m = f"Cannot submit issue #{self.id} ({self.respondent_id}): there {v[0]} {uc} unanswered mandatory question{v[1]} remaining: {nums}" 

384 raise IllegalStatusAction(m) 

385 

386 previous_submitted_date = self.submitted_date 

387 self.submitted_date = datetime.now() 

388 # Log event before changing status - self (this Issue) is detached from 

389 # SQLA session afterwards 

390 changes = [ 

391 ("status", self.status, "Submitted"), 

392 ("submitted_date", previous_submitted_date, self.submitted_date), 

393 ] 

394 self.log_event("ISSUE_SUBMITTED", user, changes) 

395 

396 self._change_status("Submitted") 

397 

398 @ensure(p.ISSUE_RETRACT, is_participant=True) 

399 def to_retracted(self, user): 

400 changes = [ 

401 ("status", self.status, "Retracted"), 

402 ] 

403 self.log_event("ISSUE_RETRACTED", user, changes) 

404 self._change_status("Retracted") 

405 

406 

407class UpdateableIssue(Issue): 

408 __mapper_args__ = {"polymorphic_identity": "Updateable"} 

409 

410 def to_retracted(self, user): 

411 changes = [ 

412 ("status", self.status, "Retracted"), 

413 ] 

414 self.log_event("ISSUE_RETRACTED", user, changes) 

415 self._change_status("Retracted") 

416 

417 

418class DeclinedIssue(Issue): 

419 __mapper_args__ = {"polymorphic_identity": "Declined"} 

420 

421 

422class SubmittedIssue(Issue): 

423 __mapper_args__ = {"polymorphic_identity": "Submitted"} 

424 

425 @ensure(p.ISSUE_PUBLISH, is_participant=True) 

426 def to_accepted(self, user): 

427 changes = [("status", self.status, "Accepted")] 

428 self.log_event(evt_types.ISSUE_REVERTED_TO_ACCEPTED, user, changes) 

429 self._change_status("Accepted") 

430 

431 

432class RetractedIssue(Issue): 

433 __mapper_args__ = {"polymorphic_identity": "Retracted"} 

434 

435 @ensure(p.ISSUE_PUBLISH, is_participant=True) 

436 def to_accepted(self, user, is_participant=True): 

437 changes = [ 

438 ("status", self.status, "Accepted"), 

439 ] 

440 self.log_event("ISSUE_RELEASED", user, changes) 

441 self._change_status("Accepted") 

442 

443 @ensure(p.ISSUE_PUBLISH, is_participant=True) 

444 def to_opportunity(self, user): 

445 changes = [ 

446 ("status", self.status, "Opportunity"), 

447 ] 

448 self.log_event("ISSUE_RELEASED", user, changes) 

449 self._change_status("Opportunity") 

450 

451 

452@event.listens_for(Issue, "before_insert", propagate=True) 

453def check_org_or_email(mapper, connection, issue): 

454 if issue.respondent_id is None and issue.respondent_email is None: 

455 m = "Issue has neither Organisation or an email address set" 

456 raise BusinessRuleViolation(m) 

457 

458 

459class Score(Base): 

460 __tablename__ = "scores" 

461 

462 public_attrs = "id,question_id,issue_id,score,scoreset_id".split(",") 

463 

464 question_instance_id: Mapped[int] = mapped_column( 

465 mysql.INTEGER(10), ForeignKey("question_instances.id"), nullable=False 

466 ) 

467 issue_id: Mapped[int] = mapped_column( 

468 mysql.INTEGER(10), ForeignKey("issues.id"), nullable=False 

469 ) 

470 score: Mapped[Optional[float]] = mapped_column( 

471 mysql.DOUBLE(asdecimal=True), nullable=True, default=None 

472 ) 

473 scoreset_id: Mapped[Optional[str]] = mapped_column( 

474 mysql.VARCHAR(length=150), default="", nullable=True 

475 ) 

476 

477 issue = relationship("Issue", uselist=False, back_populates="scores") 

478 

479 question = relationship("QuestionInstance", uselist=False) 

480 

481 comments = relationship( 

482 "ScoreComment", order_by="ScoreComment.comment_time", back_populates="score" 

483 ) 

484 project = relationship( 

485 "Project", 

486 viewonly=True, 

487 secondary="issues", 

488 secondaryjoin="issues.c.project_id==projects.c.id", 

489 backref=backref("scores_q", lazy="dynamic", viewonly=True), 

490 ) 

491 

492 def __repr__(self): 

493 return "Score: %s" % self.score 

494 

495 @property 

496 def question_id(self): 

497 return self.question_instance_id 

498 

499 @classmethod 

500 def check_view_scores(cls, user, scoreset_id): 

501 perm = Score.get_permission_for_scoreset(user, scoreset_id) 

502 user.check_permission(perm) 

503 

504 @classmethod 

505 def get_permission_for_scoreset(cls, user, scoreset_id, to_save=False): 

506 if scoreset_id in (None, "") or scoreset_id != user.id: 

507 return p.ISSUE_SAVE_AGREED_SCORES if to_save else p.ISSUE_VIEW_AGREED_SCORES 

508 else: 

509 return p.ISSUE_SAVE_SCORES if to_save else p.ISSUE_VIEW_SCORES 

510 

511 @classmethod 

512 def validate_scoreset(cls, scoreset_id, project): 

513 if not project.multiscored and scoreset_id not in (None, ""): 

514 raise ValueError("Cannot assign scoreset project that is not multiscored") 

515 

516 @classmethod 

517 def check_score_value(cls, score_value, project): 

518 # None is valid as it can be used to remove a score 

519 max_score = project.maximum_score 

520 if score_value is not None and not 0 <= score_value <= max_score: 

521 raise ValueError("Score must be between zero and %s" % max_score) 

522 

523 

524class ScoreComment(Base): 

525 __tablename__ = "score_comments" 

526 __table_args__ = ( 

527 Index("score_comment_fulltext", "comment_text", mysql_prefix="FULLTEXT"), 

528 ) + Base.__table_args__ 

529 

530 public_attrs = "comment_time,user_name,comment_text".split(",") 

531 

532 score_id: Mapped[int] = mapped_column( 

533 mysql.INTEGER(11), ForeignKey("scores.id"), nullable=False 

534 ) 

535 comment_time: Mapped[datetime] = mapped_column( 

536 mysql.DATETIME(), server_default=text("CURRENT_TIMESTAMP"), nullable=False 

537 ) 

538 user_id: Mapped[Optional[str]] = mapped_column( 

539 mysql.VARCHAR(length=150), ForeignKey("users.id"), nullable=True 

540 ) 

541 comment_text: Mapped[str] = mapped_column(mysql.LONGTEXT(), nullable=False) 

542 type: Mapped[int] = mapped_column( 

543 mysql.INTEGER(1), nullable=False, server_default=text("'0'") 

544 ) 

545 

546 score = relationship("Score", back_populates="comments") 

547 user = relationship("User") 

548 

549 @property 

550 def user_name(self): 

551 return self.user.fullname 

552 

553 

554class IssueAttachment(AttachmentMixin, Base): 

555 __tablename__ = "issue_attachments" 

556 

557 public_attrs = ( 

558 "id,description,size,filename,url,respondent_name," "private,date_uploaded" 

559 ).split(",") 

560 description: Mapped[Optional[str]] = mapped_column(mysql.VARCHAR(length=1024)) 

561 issue_id: Mapped[Optional[int]] = mapped_column( 

562 Integer, ForeignKey("issues.id", ondelete="SET NULL") 

563 ) 

564 date_uploaded: Mapped[datetime] = mapped_column( 

565 mysql.DATETIME, server_default=text("CURRENT_TIMESTAMP"), nullable=True 

566 ) 

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

568 mysql.VARCHAR(length=150), ForeignKey("users.id", ondelete="SET NULL") 

569 ) 

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

571 mysql.VARCHAR(length=150), 

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

573 ) 

574 private: Mapped[bool] = mapped_column( 

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

576 ) 

577 type: Mapped[str] = mapped_column( 

578 mysql.CHAR(1), server_default=text("P"), nullable=False 

579 ) 

580 

581 issue = relationship(Issue, back_populates="attachments") 

582 organisation = relationship(Organisation) 

583 

584 @property 

585 def url(self): 

586 return "/api/project/%i/issue/%i/attachment/%i/" % ( 

587 self.issue.project.id, 

588 self.issue.id, 

589 self.id, 

590 ) 

591 

592 @property 

593 def respondent_name(self): 

594 return self.organisation.name