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
« 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
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
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
24class IssueStatusType(types.TypeDecorator):
26 impl = mysql.TINYINT(display_width=4)
28 cache_ok = True
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}")
37 def process_result_value(self, value, dialect):
38 return Issue.issue_statuses[value]
41class Issue(Base):
43 '''@DynamicAttrs '''
45 __tablename__ = 'issues'
47 __mapper_args__ = {
48 'polymorphic_on': 'status',
49 'polymorphic_identity': 'Base'
50 }
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
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 }
71 issue_statuses_int = {v: k for k, v in issue_statuses.items()}
72 status_names = set(issue_statuses.values())
74 scoreable_statuses = (Status.SUBMITTED, Status.UPDATEABLE)
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"))
118 project = relationship("Project", uselist=False, back_populates='_issues')
119 respondent = relationship("Organisation", lazy='joined', uselist=False,
120 backref=backref('issues', passive_deletes=True))
122 winloss_weightset = relationship('WeightingSet')
124 watchers = relationship('User', secondary='issue_watch_list', lazy='dynamic', viewonly=True)
126 def get_attachment(self, attachment_id):
127 return self.attachments\
128 .filter(IssueAttachment.id == attachment_id)\
129 .one()
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)
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)
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)
151 @property
152 def deadline_passed(self) -> bool:
153 if not self.deadline:
154 return False
155 return datetime.now() > self.deadline
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())
167 return expr
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.
174 This is a nasty workaround because sqlalchemy polymorphism
175 doesn't permit objects to change their type within a Session
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()
185 issue_table = self.__table__
187 exp = issue_table\
188 .update()\
189 .where(issue_table.c.id == self.id)\
190 .values(status=new_status)
192 sess.execute(exp)
193 sess.expunge(self)
195 def change_status(self, user, new_status):
196 '''
197 Delegates to concrete subclass methods named to_[new_status] e.g. to_accepted()
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)
205 if new_status not in Issue.issue_statuses_int:
206 raise ValueError('"%s" is not a valid Issue Status' % new_status)
208 try:
209 meth_name = 'to_%s' % new_status.lower()
210 transition_method = getattr(self, meth_name)
212 except AttributeError:
213 tmpl = 'Cannot change status from %s to %s'
214 msg = tmpl % (self.status, new_status)
215 raise IllegalStatusAction(msg)
217 return transition_method(user)
219 def log_event(self, event_name, user, change_list: List[Tuple[str, str, str]]):
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)
230 def response_state_for_q(self, question_id):
231 return (self.response_states
232 .filter_by(question_instance_id=question_id)
233 .one())
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
247class NotSentIssue(Issue):
248 __mapper_args__ = {
249 'polymorphic_identity': 'Not Sent'
250 }
252 def _publish(self, new_status, user):
254 self.issue_date = datetime.now()
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)
265 @ensure(p.ISSUE_PUBLISH, is_participant=True)
266 def to_opportunity(self, user):
267 self._publish('Opportunity', user)
269 @ensure(p.ISSUE_PUBLISH, is_participant=True)
270 def to_updateable(self, user):
271 self._publish('Updateable', user)
273 @ensure(p.ISSUE_PUBLISH, is_participant=True)
274 def to_accepted(self, user):
275 self._publish('Accepted', user)
278class OpportunityIssue(Issue):
279 __mapper_args__ = {
280 'polymorphic_identity': 'Opportunity'
281 }
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')
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')
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')
308class AcceptedIssue(Issue):
309 __mapper_args__ = {
310 'polymorphic_identity': 'Accepted'
311 }
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')
321 @ensure(p.ISSUE_SUBMIT, is_vendor=True)
322 def to_submitted(self, user):
323 from rfpy.api import fetch
325 if self.deadline and self.deadline < datetime.now():
326 msg = 'This Response cannot be submitted - Deadline has passed'
327 raise DeadlineHasPassed(msg)
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)
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)
345 self._change_status('Submitted')
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')
356class UpdateableIssue(Issue):
357 __mapper_args__ = {
358 'polymorphic_identity': 'Updateable'
359 }
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')
369class DeclinedIssue(Issue):
370 __mapper_args__ = {
371 'polymorphic_identity': 'Declined'
372 }
375class SubmittedIssue(Issue):
376 __mapper_args__ = {
377 'polymorphic_identity': 'Submitted'
378 }
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')
387class RetractedIssue(Issue):
388 __mapper_args__ = {
389 'polymorphic_identity': 'Retracted'
390 }
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')
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')
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)
416class Score(Base):
417 __tablename__ = 'scores'
419 public_attrs = 'id,question_id,issue_id,score,scoreset_id'.split(',')
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))
442 def __repr__(self):
443 return "Score: %s" % self.score
445 @property
446 def question_id(self):
447 return self.question_instance_id
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)
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
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')
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)
474class ScoreComment(Base):
475 __tablename__ = 'score_comments'
476 __table_args__ = (
477 Index('score_comment_fulltext', 'comment_text', mysql_prefix='FULLTEXT'),
478 ) + Base.__table_args__
480 public_attrs = 'comment_time,user_name,comment_text'.split(',')
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'"))
492 score = relationship('Score', back_populates='comments')
493 user = relationship('User')
495 @property
496 def user_name(self):
497 return self.user.fullname
500class IssueAttachment(AttachmentMixin, Base):
501 __tablename__ = 'issue_attachments'
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)
516 issue = relationship(Issue, backref=backref('attachments', lazy='dynamic'))
517 organisation = relationship(Organisation)
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)
524 @property
525 def respondent_name(self):
526 return self.organisation.name