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
« 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
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
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
28if TYPE_CHECKING:
29 from .questionnaire import QuestionResponseState, Answer
30 from .project import Project
33class IssueStatusType(types.TypeDecorator):
34 impl = mysql.TINYINT()
36 cache_ok = True
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}")
45 def process_result_value(self, value, dialect):
46 return Issue.issue_statuses[value]
49class Issue(Base):
50 """@DynamicAttrs"""
52 __tablename__ = "issues"
54 __mapper_args__ = {"polymorphic_on": "status", "polymorphic_identity": "Base"}
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
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 }
75 issue_statuses_int = {v: k for k, v in issue_statuses.items()}
76 status_names = set(issue_statuses.values())
78 scoreable_statuses = (Status.SUBMITTED, Status.UPDATEABLE)
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 )
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 )
136 response_states: DynamicMapped["QuestionResponseState"] = relationship(
137 "QuestionResponseState",
138 back_populates="issue",
139 lazy="dynamic",
140 passive_deletes=True,
141 )
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 )
153 winloss_weightset = relationship("WeightingSet")
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 )
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 )
170 watch_list: DynamicMapped["IssueWatchList"] = relationship(
171 "IssueWatchList",
172 lazy="dynamic",
173 back_populates="issue",
174 cascade="all,delete",
175 passive_deletes=True,
176 )
178 attachments: DynamicMapped["IssueAttachment"] = relationship(
179 "IssueAttachment",
180 lazy="dynamic",
181 back_populates="issue",
182 )
184 def get_attachment(self, attachment_id):
185 return self.attachments.filter(IssueAttachment.id == attachment_id).one()
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 )
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 )
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)
211 @property
212 def deadline_passed(self) -> bool:
213 if not self.deadline:
214 return False
215 return datetime.now() > self.deadline
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())
227 return expr
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.
234 This is a nasty workaround because sqlalchemy polymorphism
235 doesn't permit objects to change their type within a Session
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()
245 issue_table = self.__table__
247 exp = (
248 issue_table.update()
249 .where(issue_table.c.id == self.id)
250 .values(status=new_status)
251 )
253 sess.execute(exp)
254 sess.expunge(self)
256 def change_status(self, user, new_status):
257 """
258 Delegates to concrete subclass methods named to_[new_status] e.g. to_accepted()
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)
266 if new_status not in Issue.issue_statuses_int:
267 raise ValueError('"%s" is not a valid Issue Status' % new_status)
269 try:
270 meth_name = "to_%s" % new_status.lower()
271 transition_method = getattr(self, meth_name)
273 except AttributeError:
274 tmpl = "Cannot change status from %s to %s"
275 msg = tmpl % (self.status, new_status)
276 raise IllegalStatusAction(msg)
278 return transition_method(user)
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)
296 def response_state_for_q(self, question_id):
297 return self.response_states.filter_by(question_instance_id=question_id).one()
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
311class NotSentIssue(Issue):
312 __mapper_args__ = {"polymorphic_identity": "Not Sent"}
314 def _publish(self, new_status, user):
315 self.issue_date = datetime.now()
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)
326 @ensure(p.ISSUE_PUBLISH, is_participant=True)
327 def to_opportunity(self, user):
328 self._publish("Opportunity", user)
330 @ensure(p.ISSUE_PUBLISH, is_participant=True)
331 def to_updateable(self, user):
332 self._publish("Updateable", user)
334 @ensure(p.ISSUE_PUBLISH, is_participant=True)
335 def to_accepted(self, user):
336 self._publish("Accepted", user)
339class OpportunityIssue(Issue):
340 __mapper_args__ = {"polymorphic_identity": "Opportunity"}
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")
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")
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")
361class AcceptedIssue(Issue):
362 __mapper_args__ = {"polymorphic_identity": "Accepted"}
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")
370 @ensure(p.ISSUE_SUBMIT, is_vendor=True)
371 def to_submitted(self, user):
372 from rfpy.api import fetch
374 if self.deadline and self.deadline < datetime.now():
375 msg = "This Response cannot be submitted - Deadline has passed"
376 raise DeadlineHasPassed(msg)
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)
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)
396 self._change_status("Submitted")
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")
407class UpdateableIssue(Issue):
408 __mapper_args__ = {"polymorphic_identity": "Updateable"}
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")
418class DeclinedIssue(Issue):
419 __mapper_args__ = {"polymorphic_identity": "Declined"}
422class SubmittedIssue(Issue):
423 __mapper_args__ = {"polymorphic_identity": "Submitted"}
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")
432class RetractedIssue(Issue):
433 __mapper_args__ = {"polymorphic_identity": "Retracted"}
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")
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")
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)
459class Score(Base):
460 __tablename__ = "scores"
462 public_attrs = "id,question_id,issue_id,score,scoreset_id".split(",")
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 )
477 issue = relationship("Issue", uselist=False, back_populates="scores")
479 question = relationship("QuestionInstance", uselist=False)
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 )
492 def __repr__(self):
493 return "Score: %s" % self.score
495 @property
496 def question_id(self):
497 return self.question_instance_id
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)
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
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")
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)
524class ScoreComment(Base):
525 __tablename__ = "score_comments"
526 __table_args__ = (
527 Index("score_comment_fulltext", "comment_text", mysql_prefix="FULLTEXT"),
528 ) + Base.__table_args__
530 public_attrs = "comment_time,user_name,comment_text".split(",")
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 )
546 score = relationship("Score", back_populates="comments")
547 user = relationship("User")
549 @property
550 def user_name(self):
551 return self.user.fullname
554class IssueAttachment(AttachmentMixin, Base):
555 __tablename__ = "issue_attachments"
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 )
581 issue = relationship(Issue, back_populates="attachments")
582 organisation = relationship(Organisation)
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 )
592 @property
593 def respondent_name(self):
594 return self.organisation.name