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

272 statements  

« prev     ^ index     » next       coverage.py v7.0.1, created at 2022-12-31 16:00 +0000

1from datetime import datetime 

2from typing import List, Tuple 

3 

4from sqlalchemy import (Column, Integer, ForeignKey, DateTime, 

5 Boolean, and_, event, text, Index) 

6from sqlalchemy.dialects import mysql 

7from sqlalchemy.orm.session import object_session 

8from sqlalchemy.orm import relationship, backref 

9from sqlalchemy.ext.hybrid import hybrid_method 

10import sqlalchemy.types as types 

11 

12from rfpy.auth import perms as p 

13from rfpy.mail.schemas import User 

14from rfpy.model.exc import (IllegalStatusAction, 

15 DeadlineHasPassed, 

16 BusinessRuleViolation) 

17from rfpy.model.helpers import ensure 

18from .audit import AuditEvent, evt_types 

19from .notify import IssueWatchList 

20from .meta import Base, AttachmentMixin 

21from .humans import Organisation 

22 

23 

24class IssueStatusType(types.TypeDecorator): 

25 

26 impl = mysql.TINYINT(display_width=4) 

27 

28 cache_ok = True 

29 

30 def process_bind_param(self, value, dialect): 

31 try: 

32 return Issue.issue_statuses_int[value] 

33 except KeyError: 

34 vals = ','.join(Issue.issue_statuses.values()) 

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

36 

37 def process_result_value(self, value, dialect): 

38 return Issue.issue_statuses[value] 

39 

40 

41class Issue(Base): 

42 

43 '''@DynamicAttrs ''' 

44 

45 __tablename__ = 'issues' 

46 

47 __mapper_args__ = { 

48 'polymorphic_on': 'status', 

49 'polymorphic_identity': 'Base' 

50 } 

51 

52 class Status: 

53 NOT_SENT = 0 

54 OPPORTUNITY = 10 

55 ACCEPTED = 20 

56 UPDATEABLE = 25 

57 DECLINED = 30 

58 SUBMITTED = 40 

59 RETRACTED = 50 

60 

61 issue_statuses = { 

62 Status.NOT_SENT: 'Not Sent', 

63 Status.OPPORTUNITY: 'Opportunity', 

64 Status.ACCEPTED: 'Accepted', 

65 Status.UPDATEABLE: 'Updateable', 

66 Status.DECLINED: 'Declined', 

67 Status.SUBMITTED: 'Submitted', 

68 Status.RETRACTED: 'Retracted', 

69 } 

70 

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

72 status_names = set(issue_statuses.values()) 

73 

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

75 

76 project_id = Column( 

77 Integer, 

78 ForeignKey('projects.id'), 

79 nullable=False, 

80 index=True, 

81 server_default=text("'0'")) 

82 respondent_id = Column( 

83 mysql.VARCHAR(150), 

84 ForeignKey('organisations.id', onupdate='CASCADE'), 

85 nullable=True 

86 ) 

87 respondent_email = Column(mysql.VARCHAR(80)) 

88 issue_date = Column(DateTime) 

89 accepted_date = Column(DateTime) 

90 submitted_date = Column(DateTime) 

91 deadline = Column(DateTime) 

92 use_workflow = Column(Boolean, nullable=False, default=False, server_default=text("'0'")) 

93 status = Column(IssueStatusType, nullable=False, default='Not Sent') 

94 # selected = Column(Boolean, nullable=False, server_default=text("'0'")) 

95 feedback = Column(mysql.LONGTEXT) 

96 internal_comments = Column(mysql.LONGTEXT) 

97 award_status = Column( 

98 mysql.TINYINT(display_width=4), 

99 nullable=False, 

100 default=0, 

101 server_default=text("'0'") 

102 ) 

103 label = Column(mysql.VARCHAR(255)) 

104 selected = Column( 

105 mysql.TINYINT(display_width=4), 

106 default=False, 

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

108 nullable=False 

109 ) 

110 reminder_sent = Column(DateTime) 

111 # WINLOSS - exposing feedback reports to respondents 

112 winloss_exposed = Column(Boolean, nullable=False, 

113 default=0, server_default=text("'0'")) 

114 winloss_expiry = Column(DateTime, nullable=True) 

115 winloss_weightset_id = Column(Integer, 

116 ForeignKey('weighting_sets.id', ondelete="SET NULL")) 

117 

118 project = relationship("Project", uselist=False, back_populates='_issues') 

119 respondent = relationship("Organisation", lazy='joined', uselist=False, 

120 backref=backref('issues', passive_deletes=True)) 

121 

122 winloss_weightset = relationship('WeightingSet') 

123 

124 watchers = relationship('User', secondary='issue_watch_list', lazy='dynamic', viewonly=True) 

125 

126 def get_attachment(self, attachment_id): 

127 return self.attachments\ 

128 .filter(IssueAttachment.id == attachment_id)\ 

129 .one() 

130 

131 @hybrid_method 

132 def can_view_winloss(self, user): 

133 return self.winloss_exposed \ 

134 and (self.winloss_expiry is not None) \ 

135 and (self.winloss_expiry > datetime.now()) \ 

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

137 

138 @can_view_winloss.expression 

139 def can_view_sql(self, user): 

140 return self.winloss_exposed \ 

141 & (self.winloss_expiry is not None) \ 

142 & (self.winloss_expiry > datetime.now()) \ 

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

144 

145 def __repr__(self): 

146 if self.respondent_id is not None: 

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

148 else: 

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

150 

151 @property 

152 def deadline_passed(self) -> bool: 

153 if not self.deadline: 

154 return False 

155 return datetime.now() > self.deadline 

156 

157 @classmethod 

158 def scoreable_filter(cls, project): 

159 """ 

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

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

162 """ 

163 expr = Issue.status.in_(('Submitted', 'Updateable')) 

164 if project.hide_responses: 

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

166 

167 return expr 

168 

169 def _change_status(self, new_status): 

170 ''' 

171 Change the status value directly in the DB and remove 

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

173 

174 This is a nasty workaround because sqlalchemy polymorphism 

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

176 

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

178 since it messes up the session 

179 ''' 

180 sess = object_session(self) 

181 # Begin by flushing any pending changes since this function 

182 # will remove this Issue instance from the Session 

183 sess.flush() 

184 

185 issue_table = self.__table__ 

186 

187 exp = issue_table\ 

188 .update()\ 

189 .where(issue_table.c.id == self.id)\ 

190 .values(status=new_status) 

191 

192 sess.execute(exp) 

193 sess.expunge(self) 

194 

195 def change_status(self, user, new_status): 

196 ''' 

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

198 

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

200 ''' 

201 if not self.project.status == 'Live': 

202 msg = 'Project status must be Live to change status of an Issue' 

203 raise IllegalStatusAction(msg) 

204 

205 if new_status not in Issue.issue_statuses_int: 

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

207 

208 try: 

209 meth_name = 'to_%s' % new_status.lower() 

210 transition_method = getattr(self, meth_name) 

211 

212 except AttributeError: 

213 tmpl = 'Cannot change status from %s to %s' 

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

215 raise IllegalStatusAction(msg) 

216 

217 return transition_method(user) 

218 

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

220 

221 evt = AuditEvent.create(event_name, 

222 object_id=self.id, 

223 user=user, 

224 issue=self, 

225 project=self.project, 

226 change_list=change_list) 

227 sess = object_session(self) 

228 sess.add(evt) 

229 

230 def response_state_for_q(self, question_id): 

231 return (self.response_states 

232 .filter_by(question_instance_id=question_id) 

233 .one()) 

234 

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

236 ''' 

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

238 False if the user is already watching 

239 ''' 

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

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

242 return True 

243 else: 

244 return False 

245 

246 

247class NotSentIssue(Issue): 

248 __mapper_args__ = { 

249 'polymorphic_identity': 'Not Sent' 

250 } 

251 

252 def _publish(self, new_status, user): 

253 

254 self.issue_date = datetime.now() 

255 

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

257 # SQLA session afterwards 

258 changes = [ 

259 ('status', self.status, new_status), 

260 ('issue_date', None, self.issue_date) 

261 ] 

262 self.log_event('ISSUE_RELEASED', user, changes) 

263 self._change_status(new_status) 

264 

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

266 def to_opportunity(self, user): 

267 self._publish('Opportunity', user) 

268 

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

270 def to_updateable(self, user): 

271 self._publish('Updateable', user) 

272 

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

274 def to_accepted(self, user): 

275 self._publish('Accepted', user) 

276 

277 

278class OpportunityIssue(Issue): 

279 __mapper_args__ = { 

280 'polymorphic_identity': 'Opportunity' 

281 } 

282 

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

284 def to_accepted(self, user): 

285 changes = [ 

286 ('status', self.status, 'Accepted') 

287 ] 

288 self.log_event('ISSUE_ACCEPTED', user, changes) 

289 self._change_status('Accepted') 

290 

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

292 def to_declined(self, user): 

293 changes = [ 

294 ('status', self.status, 'Declined') 

295 ] 

296 self.log_event('ISSUE_DECLINED', user, changes) 

297 self._change_status('Declined') 

298 

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

300 def to_retracted(self, user): 

301 changes = [ 

302 ('status', self.status, 'Retracted') 

303 ] 

304 self.log_event('ISSUE_RETRACTED', user, changes) 

305 self._change_status('Retracted') 

306 

307 

308class AcceptedIssue(Issue): 

309 __mapper_args__ = { 

310 'polymorphic_identity': 'Accepted' 

311 } 

312 

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

314 def to_declined(self, user): 

315 changes = [ 

316 ('status', self.status, 'Declined') 

317 ] 

318 self.log_event('ISSUE_DECLINED', user, changes) 

319 self._change_status('Declined') 

320 

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

322 def to_submitted(self, user): 

323 from rfpy.api import fetch 

324 

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

326 msg = 'This Response cannot be submitted - Deadline has passed' 

327 raise DeadlineHasPassed(msg) 

328 

329 uc = fetch.unanswered_mandatory(self).count() 

330 if uc > 0: 

331 v = ('is', '') if uc == 1 else ('are', 's') 

332 m = f"Cannot submit: there {v[0]} {uc} unanswered mandatory question{v[1]} remaining" 

333 raise IllegalStatusAction(m) 

334 

335 previous_submitted_date = self.submitted_date 

336 self.submitted_date = datetime.now() 

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

338 # SQLA session afterwards 

339 changes = [ 

340 ('status', self.status, 'Submitted'), 

341 ('submitted_date', previous_submitted_date, self.submitted_date) 

342 ] 

343 self.log_event('ISSUE_SUBMITTED', user, changes) 

344 

345 self._change_status('Submitted') 

346 

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

348 def to_retracted(self, user): 

349 changes = [ 

350 ('status', self.status, 'Retracted'), 

351 ] 

352 self.log_event('ISSUE_RETRACTED', user, changes) 

353 self._change_status('Retracted') 

354 

355 

356class UpdateableIssue(Issue): 

357 __mapper_args__ = { 

358 'polymorphic_identity': 'Updateable' 

359 } 

360 

361 def to_retracted(self, user): 

362 changes = [ 

363 ('status', self.status, 'Retracted'), 

364 ] 

365 self.log_event('ISSUE_RETRACTED', user, changes) 

366 self._change_status('Retracted') 

367 

368 

369class DeclinedIssue(Issue): 

370 __mapper_args__ = { 

371 'polymorphic_identity': 'Declined' 

372 } 

373 

374 

375class SubmittedIssue(Issue): 

376 __mapper_args__ = { 

377 'polymorphic_identity': 'Submitted' 

378 } 

379 

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

381 def to_accepted(self, user): 

382 changes = [('status', self.status, 'Accepted')] 

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

384 self._change_status('Accepted') 

385 

386 

387class RetractedIssue(Issue): 

388 __mapper_args__ = { 

389 'polymorphic_identity': 'Retracted' 

390 } 

391 

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

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

394 changes = [ 

395 ('status', self.status, 'Accepted'), 

396 ] 

397 self.log_event('ISSUE_RELEASED', user, changes) 

398 self._change_status('Accepted') 

399 

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

401 def to_opportunity(self, user): 

402 changes = [ 

403 ('status', self.status, 'Opportunity'), 

404 ] 

405 self.log_event('ISSUE_RELEASED', user, changes) 

406 self._change_status('Opportunity') 

407 

408 

409@event.listens_for(Issue, 'before_insert', propagate=True) 

410def check_org_or_email(mapper, connection, issue): 

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

412 m = 'Issue has neither Organisation or an email address set' 

413 raise BusinessRuleViolation(m) 

414 

415 

416class Score(Base): 

417 __tablename__ = 'scores' 

418 

419 public_attrs = 'id,question_id,issue_id,score,scoreset_id'.split(',') 

420 

421 question_instance_id = Column( 

422 mysql.INTEGER(10), 

423 ForeignKey('question_instances.id'), 

424 nullable=False 

425 ) 

426 issue_id = Column(mysql.INTEGER(10), ForeignKey('issues.id'), nullable=False) 

427 score = Column(mysql.DOUBLE(asdecimal=True), nullable=True, default=None) 

428 scoreset_id = Column(mysql.VARCHAR(150), default='') 

429 issue = relationship( 

430 "Issue", uselist=False, backref=backref('scores', lazy='dynamic')) 

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

432 comments = relationship( 

433 'ScoreComment', order_by='ScoreComment.comment_time', back_populates="score" 

434 ) 

435 project = relationship( 

436 'Project', 

437 viewonly=True, 

438 secondary='issues', 

439 secondaryjoin='issues.c.project_id==projects.c.id', 

440 backref=backref('scores_q', lazy='dynamic', viewonly=True)) 

441 

442 def __repr__(self): 

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

444 

445 @property 

446 def question_id(self): 

447 return self.question_instance_id 

448 

449 @classmethod 

450 def check_view_scores(cls, user, scoreset_id): 

451 perm = Score.get_permission_for_scoreset(user, scoreset_id) 

452 user.check_permission(perm) 

453 

454 @classmethod 

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

456 if scoreset_id in (None, '') or scoreset_id != user.id: 

457 return p.ISSUE_SAVE_AGREED_SCORES if to_save else p.ISSUE_VIEW_AGREED_SCORES 

458 else: 

459 return p.ISSUE_SAVE_SCORES if to_save else p.ISSUE_VIEW_SCORES 

460 

461 @classmethod 

462 def validate_scoreset(cls, scoreset_id, project): 

463 if not project.multiscored and scoreset_id not in (None, ''): 

464 raise ValueError('Cannot assign scoreset project that is not multiscored') 

465 

466 @classmethod 

467 def check_score_value(cls, score_value, project): 

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

469 max_score = project.maximum_score 

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

471 raise ValueError('Score must be between zero and %s' % max_score) 

472 

473 

474class ScoreComment(Base): 

475 __tablename__ = 'score_comments' 

476 __table_args__ = ( 

477 Index('score_comment_fulltext', 'comment_text', mysql_prefix='FULLTEXT'), 

478 ) + Base.__table_args__ 

479 

480 public_attrs = 'comment_time,user_name,comment_text'.split(',') 

481 

482 score_id = Column(mysql.INTEGER(11), ForeignKey('scores.id'), nullable=False) 

483 comment_time = Column( 

484 mysql.DATETIME(), 

485 server_default=text('CURRENT_TIMESTAMP'), 

486 nullable=False 

487 ) 

488 user_id = Column(mysql.VARCHAR(150), ForeignKey('users.id'), nullable=True) 

489 comment_text = Column(mysql.LONGTEXT(), nullable=False) 

490 type = Column(mysql.INTEGER(1), nullable=False, server_default=text("'0'")) 

491 

492 score = relationship('Score', back_populates='comments') 

493 user = relationship('User') 

494 

495 @property 

496 def user_name(self): 

497 return self.user.fullname 

498 

499 

500class IssueAttachment(AttachmentMixin, Base): 

501 __tablename__ = 'issue_attachments' 

502 

503 public_attrs = ('id,description,size,filename,url,respondent_name,' 

504 'private,date_uploaded').split(',') 

505 description = Column(mysql.VARCHAR(1024)) 

506 issue_id = Column(Integer, ForeignKey('issues.id', ondelete='SET NULL')) 

507 date_uploaded = Column(mysql.DATETIME, server_default=text("CURRENT_TIMESTAMP")) 

508 author_id = Column(mysql.VARCHAR(150), ForeignKey('users.id', ondelete='SET NULL')) 

509 org_id = Column( 

510 mysql.VARCHAR(150), 

511 ForeignKey('organisations.id', onupdate='CASCADE', ondelete='SET NULL') 

512 ) 

513 private = Column(Boolean, default=False, server_default=text("'0'")) 

514 type = Column(mysql.CHAR(1), server_default=text('P'), nullable=False) 

515 

516 issue = relationship(Issue, backref=backref('attachments', lazy='dynamic')) 

517 organisation = relationship(Organisation) 

518 

519 @property 

520 def url(self): 

521 return '/api/project/%i/issue/%i/attachment/%i/' %\ 

522 (self.issue.project.id, self.issue.id, self.id) 

523 

524 @property 

525 def respondent_name(self): 

526 return self.organisation.name