def _patch(): from django.contrib.auth import get_user_model from ej.utils.functional import deprecate_lazy from sidekick import lazy, property, placeholder as _ # # Patch user model # user = get_user_model() # Enhanced querysets user.approved_comments = property(_.comments.approved()) user.rejected_comments = property(_.comments.rejected()) user.pending_comments = property(_.comments.pending()) # Statistics user.n_conversations = lazy(_.conversations.count()) user.n_total_comments = lazy(_.comments.count()) user.n_approved_comments = lazy(_.approved_comments.count()) user.n_pending_comments = lazy(_.pending_comments.count()) user.n_rejected_comments = lazy(_.rejected_comments.count()) user.n_votes = lazy(_.votes.count()) user.n_final_votes = lazy(_.votes.exclude(choice=Choice.SKIP).count()) # Deprecation warnings user.n_comments = deprecate_lazy( _.approved_comments.count(), 'The "n_comments" attribute was deprecated in favor of "n_approved_comments"\n.' "This property will be removed in future versions of EJ.", )
class DiseaseParams(WrappedParams): """ A wrapper for disease params. """ gamma: float = sk.property(1 / _.infectious_period) sigma: float = sk.property(1 / _.incubation_period) Qs: float = sk.alias("prob_symptoms") Qsv: float = sk.alias("prob_severe") Qcr: float = sk.alias("prob_critical") CFR: float = sk.alias("case_fatality_ratio") IFR: float = sk.alias("infection_fatality_ratio") HFR: float = sk.alias("hospital_fatality_ratio") ICUFR: float = sk.alias("icu_fatality_ratio")
def _patch(): from django.contrib.auth import get_user_model from sidekick import lazy, property, placeholder as _ # # Patch user model # user = get_user_model() # Enhanced querysets user.approved_comments = property(_.comments.approved()) user.n_rejected_comments = property(_.comments.rejected()) # Statistics user.n_conversations = lazy(_.conversations.count()) user.n_comments = lazy(_.approved_comments.count()) user.n_rejected_comments = lazy(_.rejected_comments.count()) user.n_votes = lazy(_.approved_comments.count())
class CrudeFR(ClinicalObserverModel): """ Model in which infected can become hospitalized and suffer a constant hospitalization fatality rate. Attributes: prob_severe (float, alias: Qsv): Probability that regular cases become severe (cases that require hospitalization). prob_critical (float, alias: Qcr): Probability that regular cases become critical (require ICU). hospitalization_period: Average duration of hospitalizations. icu_period: Average duration of ICU treatment. hospital_fatality_ratio (float, alias: HFR): Fraction of deaths for patients that go to hospitalization. icu_fatality_ratio (float, alias: ICUFR): Fraction of deaths for patients that go to ICU treatment. """ params = clinical.DEFAULT # Primary parameters prob_severe: float = param_property(default=0.0) prob_critical: float = param_property(default=0.0) severe_period: float = param_property(default=0.0) critical_period: float = param_property(default=0.0) # Aliases Qsv: float = param_alias("prob_severe") Qcr: float = param_alias("prob_critical") # Properties hospital_fatality_ratio = sk.property(_.CFR / _.Qsv) HFR = sk.alias("hospital_fatality_ratio") icu_fatality_ratio = sk.property(_.CFR / _.Qcr) ICUFR = sk.alias("icu_fatality_ratio") prob_aggravate_to_icu = sk.property(_.Qcr / _.Qsv) # Cumulative series def get_data_deaths(self, idx): return self["cases", idx] * self.CFR def get_data_severe(self, idx): data = self["severe_cases"] K = max(self.K, 0) data = delayed_with_discharge(data, 0, self.severe_period, K, positive=True) return sliced(data, idx) def get_data_severe_cases(self, idx): return self["cases", idx] * self.Qsv def get_data_critical_cases(self, idx): return self["cases", idx] * self.Qcr
class EpidemicParameters(Parameters): gamma = sk.property(1 / _.infectious_period) sigma = sk.property(1 / _.incubation_period)
class Model( WithDataModelMixin, WithInfoMixin, WithResultsMixin, WithParamsMixin, WithRegionDemography, metaclass=ModelMeta, ): """ Base class for all models. """ meta: Meta class Meta: model_name = "Model" data_aliases = {} # Initial values state: np.ndarray = None initial_cases: float = sk.lazy(lambda self: self._initial_cases()) initial_infected: float = sk.lazy(lambda self: self._initial_infected()) # Initial time date: datetime.date = None time: float = 0.0 iter: int = sk.property(lambda m: len(m.data)) dates: pd.DatetimeIndex = sk.property(lambda m: m.to_dates(m.times)) times: pd.Index = sk.property(lambda m: m.data.index) # Common epidemiological parameters R0: float = param_property("R0", default=2.0) K = sk.property(not_implemented) duplication_time = property(lambda self: np.log(2) / self.K) # Special accessors clinical: Clinical = property(lambda self: Clinical(self)) clinical_model: type = None clinical_params: Mapping = MappingProxyType({}) disease: Disease = None disease_params: DiseaseParams = None @property def ui(self) -> "UIProperty": try: from pydemic_ui.model import UIProperty except ImportError as ex: log.warn(f"Could not import pydemic_ui.model: {ex}") msg = ( "must have pydemic-ui installed to access the model.ui attribute.\n" "Please 'pip install pydemic-ui' before proceeding'") raise RuntimeError(msg) return UIProperty(self) def __init__(self, params=None, *, run=None, name=None, date=None, clinical=None, disease=None, **kwargs): self.name = name or f"{type(self).__name__} model" self.date = pd.to_datetime(date or today()) self.disease = get_disease(disease) self._initialized = False # Fix demography demography_opts = WithRegionDemography._init_from_dict(self, kwargs) self.disease_params = self.disease.params(**demography_opts) # Init other mixins WithParamsMixin.__init__(self, params, keywords=kwargs) WithInfoMixin.__init__(self) WithResultsMixin.__init__(self) WithDataModelMixin.__init__(self) if clinical: clinical = dict(clinical) self.clinical_model = clinical.pop("model", None) self.clinical_params = clinical for k, v in kwargs.items(): if hasattr(self, k): try: setattr(self, k, v) except AttributeError: name = type(self).__name__ msg = f"cannot set '{k}' attribute in '{name}' model" raise AttributeError(msg) else: raise TypeError(f"invalid arguments: {k}") if run is not None: self.run(run) def __str__(self): return self.name def _initial_cases(self): raise NotImplementedError("must be implemented in subclass") def _initial_infected(self): raise NotImplementedError("must be implemented in subclass") def epidemic_model_name(self): """ Return the epidemic model name. """ return self.meta.model_name # # Pickling and copying # # noinspection PyUnresolvedReferences def copy(self, **kwargs): """ Copy instance possibly setting new values for attributes. Keyword Args: All keyword arguments are used to reset attributes in the copy. Examples: >>> m.copy(R0=1.0, name="Stable") <SIR(name="Stable")> """ cls = type(self) data = self.__dict__.copy() params = data.pop("_params") data.pop("_results_cache") new = object.__new__(cls) for k in list(kwargs): if k in data: data[k] = kwargs.pop(k) new._params = copy(params) new._results_cache = {} new.__dict__.update(copy(data)) for k, v in kwargs.items(): setattr(new, k, v) return new def split(self, n=None, **kwargs) -> "ModelGroup": """ Create n copies of model, each one may override a different set of parameters and return a ModelGroup. Args: n: Number of copies in the resulting list. It can also be a sequence of dictionaries with arguments to pass to the .copy() constructor. Keyword Args: Keyword arguments are passed to the `.copy()` method of the model. If the keyword is a sequence, it applies the n-th component of the sequence to the corresponding n-th model. """ from ..model_group import ModelGroup if n is None: for k, v in kwargs.items(): if not isinstance(v, str) and isinstance(v, Sequence): n = len(v) break else: raise TypeError( "cannot determine the group size from arguments") if isinstance(n, int): options = [{} for _ in range(n)] else: options = [dict(d) for d in n] n: int = len(options) # Merge option dicts for k, v in kwargs.items(): if not isinstance(v, str) and isinstance(v, Sequence): xs = v m = len(xs) if m != n: raise ValueError(f"sizes do not match: " f"{k} should be a sequence of {n} " f"items, got {m}") for opt, x in zip(options, xs): opt.setdefault(k, x) else: for opt in options: opt.setdefault(k, v) # Fix name for opt in options: try: name = opt["name"] except KeyError: pass else: opt["name"] = name.format(n=n, **opt) return ModelGroup(self.copy(**opt) for opt in options) def split_children(self, options=MappingProxyType({}), **kwargs) -> "ModelGroup": """ Similar to split, but split into the children of the given class. Args: options: A mapping between region or region id """ from ..model_group import ModelGroup if self.region is None: raise ValueError("model is not bound to a region") for k in self._params: if k not in kwargs: kwargs[k] = self.get_param(k) for attr in ("disease", ): kwargs.setdefault(attr, getattr(self, attr)) return ModelGroup.from_children(self.region, type(self), options, **kwargs) def reset(self, date: Union[datetime.date, float] = None, **kwargs): """ Return a copy of the model setting the state to the final state. If a positional "date" argument is given, reset to the state to the one in the specified date. Args: date (float or date): An optional float or datetime selecting the desired date. Keyword Args: Additional keyword arguments are handled the same way as the :method:`copy` method. """ if date is None: date = self.date time = self.time elif isinstance(date, (float, int)): time = float(date) date = self.to_date(date) else: time: float = self.to_time(date) kwargs["data"] = self.data.loc[[time]] kwargs["date"] = date kwargs["state"] = kwargs["data"].iloc[0].values kwargs["time"] = 1 return self.copy(**kwargs) def trim_dates(self, start=0, end=None): """ Trim data in model to the given interval specified by start and end dates or times. Args: start (int or date): Starting date. If not given, start at zero. end (int or date): End date. If not given, select up to the final date. """ start = int(start or 0) end = int(end or self.time) new = self.copy( date=self.to_date(start), data=self.data.iloc[start:end].reset_index(drop=True), time=end - start, state=self.data.iloc[end].values, ) return new # # Initial conditions # def set_ic(self, state=None, **kwargs): """ Set initial conditions. """ if self.state is None: if state is None: state = self.initial_state(**kwargs) self.state = np.array(state, dtype=float) alias = self.meta.data_aliases for k, v in list(kwargs.items()): if k in alias: del kwargs[k] kwargs[alias[k]] = v components = extract_keys(self.meta.variables, kwargs) for k, v in components.items(): idx = self.meta.get_variable_index(k) self.state[idx] = v return self def set_data(self, data): """ Force a dataframe into simulation state. """ data = data.copy() data.columns = [self.meta.data_aliases.get(c, c) for c in data.columns] self.set_ic(state=data.iloc[0]) self.data = data.reset_index(drop=True) self.time = len(data) - 1 self.date = data.index[-1] self.state[:] = data.iloc[-1] self.info["observed.dates"] = data.index[[0, -1]] self._initialized = True return self def set_cases_from_region(self: T) -> T: """ Set the number of cases from region. """ self.set_cases() return self def set_cases(self: T, curves=None, adjust_R0=False, save_observed=False) -> T: """ Initialize model from a dataframe with the deaths and cases curve. This curve is usually the output of disease.epidemic_curve(region), and is automatically retrieved if not passed explicitly and the region of the model is set. Args: curves: Dataframe with cumulative ["cases", "deaths"] columns. If not given, or None, fetches from disease.epidemic_curves(info) adjust_R0: If true, adjust R0 from the observed cases. save_observed: If true, save the cases curves into the model.info["observed.cases"] key. """ if curves is None: warnings.warn("omitting curves from set_cases will be deprecated.") if self.region is None or self.disease is None: msg = 'must provide both "region" and "disease" or an explicit cases ' "curve." raise ValueError(msg) curves = self.region.pydemic.epidemic_curve(self.disease) if adjust_R0: warnings.warn("adjust_R0 argument is deprecated") method = "RollingOLS" if adjust_R0 is True else adjust_R0 Re, _ = value = fit.estimate_R0(self, curves, Re=True, method=method) assert np.isfinite(Re), f"invalid value for R0: {value}" self.R0 = Re # Save notification it in the info dictionary for reference if "cases_observed" in curves: tf = curves.index[-1] rate = curves.loc[tf, "cases_observed"] / curves.loc[tf, "cases"] else: rate = 1.0 self.info["observed.notification_rate"] = rate # Save simulation state from data model = self.epidemic_model_name() curve = fit.cases(curves) data = fit.epidemic_curve(model, curve, self) self.set_data(data) self.initial_cases = curve.iloc[0] if adjust_R0: self.R0 /= self["susceptible:final"] / self.population self.info["observed.R0"] = self.R0 # Optionally save cases curves into the info dictionary if save_observed: key = "observed.curves" if save_observed is True else save_observed df = curves.rename(columns={"cases": "cases_raw"}) df["cases"] = curve self.info[key] = df return self def adjust_R0(self, method="RollingOLS"): curves = self["cases"] self.R0, _ = fit.estimate_R0(self, curves, method=method) self.info["observed.R0"] = self.R0 def initial_state(self, cases=None, **kwargs): """ Create the default initial vector for model. """ if cases is not None: kwargs.setdefault("population", self.population) return formulas.initial_state(self.epidemic_model_name(), cases, self, **kwargs) return self._initial_state() def infect(self, n=1, column="infectious"): """ Convert 'n' susceptible individuals to infectious. """ last = self.data.index[-1] n = min(n, self.data.loc[last, "susceptible"]) self.data.loc[last, column] += n self.data.loc[last, "susceptible"] -= n return self def _initial_state(self): raise NotImplementedError def initialize(self): """ Force initialization. """ if not self._initialized: self.set_ic() self.data = make_dataframe(self) self._initialized = True # # Running simulation # def run(self: T, time) -> T: """ Runs the model for the given duration. """ steps = int(time) self.initialize() if time == 0: return _, *shape = self.data.shape ts = self.time + 1.0 + np.arange(steps) data = np.zeros((steps, *shape)) date = self.date if self.info.get("event.simulation_start") is None: self.info.save_event("simulation_start") self.run_to_fill(data, ts) extra = pd.DataFrame(data, columns=self.data.columns, index=ts) self.data = pd.concat([self.data, extra]) self.date = date + time * DAY self.time = ts[-1] self.state = data[-1] return self def run_to_fill(self: T, data, times) -> T: """ Run simulation to fill pre-allocated array of data. """ raise NotImplementedError def run_until(self, condition: Callable[["Model"], bool]): """ Run until stop condition is satisfied. Args: condition: A function that receives a model and return True if stop criteria is satisfied. """ raise NotImplementedError # # Utility methods # def to_dates(self, times: Sequence[int], start_date=None) -> pd.DatetimeIndex: """ Convert an array of numerical times to dates. Args: times: Sequence of times. start_date: Starting date. If not given, uses the starting date for simulation. """ dates: pd.DatetimeIndex if isinstance(times, pd.DatetimeIndex): return times if start_date is None: start_date = self.date - self.time * DAY # noinspection PyTypeChecker return pd.to_datetime(times, unit="D", origin=start_date) def to_date(self, time: Union[float, int]) -> datetime.date: """ Convert a single instant to the corresponding datetime """ return pd.to_datetime(time - self.time, unit="D", origin=self.date) def to_times(self, dates: Sequence, start_date=None) -> np.ndarray: """ Convert an array of numerical times to dates. Args: dates: Sequence of dates. start_date: Starting date. If not given, uses the starting date for simulation. """ if start_date is None: start_date = self.date - self.time * DAY data = [(date - start_date).days for date in dates] return np.array(data) if data else np.array([], dtype=int) def to_time(self, date, start_date=None) -> float: """ Convert date to time. """ if start_date is None: return self.to_time(date, self.date) - self.time return float((date - start_date).days) def get_times(self, idx=None): """ Get times possibly sliced by an index. """ if idx is None: return self.times else: return self.times[idx] def get_data_time(self, idx): times = self.get_times(idx) return pd.Series(times, index=times) def get_data_date(self, idx): times = self.get_times(idx) dates = self.to_dates(times) return pd.Series(dates, index=times) def get_data_cases(self, idx): raise NotImplementedError # # Plotting and showing information # def plot( self, components=None, *, ax=None, logy=False, show=False, dates=False, legend=True, grid=True, ): """ Plot the result of simulation. """ ax = ax or plt.gca() kwargs = {"logy": logy, "ax": ax, "grid": grid, "legend": legend} def get_column(col): if dates: col += ":dates" data = self[col] return data components = self.meta.variables if components is None else components for col in components: data = get_column(col) data.plot(**kwargs) if show: plt.show()
class EpidemicParams(Params): R0: float infectious_period: float incubation_period: float = 0.0 gamma = sk.property(1 / _.infectious_period) sigma = sk.property(1 / _.incubation_period)
def patch_user_model(model): def conversations_with_votes(user): return models.Conversation.objects.filter(comments__votes__author=user).distinct() model.conversations_with_votes = property(conversations_with_votes)
class Conversation(TimeStampedModel): """ A topic of conversation. """ title = models.CharField( _("Title"), max_length=255, help_text=_( "Short description used to create URL slugs (e.g. School system)." ), ) text = models.TextField(_("Question"), help_text=_("What do you want to ask?")) author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="conversations", help_text= _("Only the author and administrative staff can edit this conversation." ), ) moderators = models.ManyToManyField( settings.AUTH_USER_MODEL, blank=True, related_name="moderated_conversations", help_text=_("Moderators can accept and reject comments."), ) slug = AutoSlugField(unique=False, populate_from="title") is_promoted = models.BooleanField( _("Promote conversation?"), default=False, help_text=_( "Promoted conversations appears in the main /conversations/ " "endpoint."), ) is_hidden = models.BooleanField( _("Hide conversation?"), default=False, help_text=_( "Hidden conversations does not appears in boards or in the main /conversations/ " "endpoint."), ) objects = ConversationQuerySet.as_manager() tags = TaggableManager(through="ConversationTag", blank=True) votes = property( lambda self: Vote.objects.filter(comment__conversation=self)) @property def users(self): return (get_user_model().objects.filter( votes__comment__conversation=self).distinct()) @property def approved_comments(self): return self.comments.filter(status=Comment.STATUS.approved) class Meta: ordering = ["created"] permissions = ( ("can_publish_promoted", _("Can publish promoted conversations")), ("is_moderator", _("Can moderate comments in any conversation")), ) # # Statistics and annotated values # author_name = lazy(this.author.name) first_tag = lazy(this.tags.values_list("name", flat=True).first()) tag_names = lazy(this.tags.values_list("name", flat=True)) # Statistics n_comments = lazy( this.comments.filter(status=Comment.STATUS.approved).count()) n_pending_comments = lazy( this.comments.filter(status=Comment.STATUS.pending).count()) n_rejected_comments = lazy( this.comments.filter(status=Comment.STATUS.rejected).count()) n_favorites = lazy(this.favorites.count()) n_tags = lazy(this.tags.count()) n_votes = lazy(this.votes.count()) n_participants = lazy(this.users.count()) # Statistics for the request user user_comments = property(this.comments.filter(author=this.for_user)) user_votes = property(this.votes.filter(author=this.for_user)) n_user_comments = lazy( this.user_comments.filter(status=Comment.STATUS.approved).count()) n_user_rejected_comments = lazy( this.user_comments.filter(status=Comment.STATUS.rejected).count()) n_user_pending_comments = lazy( this.user_comments.filter(status=Comment.STATUS.pending).count()) n_user_votes = lazy(this.user_votes.count()) is_user_favorite = lazy(this.is_favorite(this.for_user)) @lazy def for_user(self): return self.request.user @lazy def request(self): msg = "Set the request object by calling the .set_request(request) method first" raise RuntimeError(msg) # TODO: move as patches from other apps @lazy def n_clusters(self): try: return self.clusterization.n_clusters except AttributeError: return 0 @lazy def n_stereotypes(self): try: return self.clusterization.n_clusters except AttributeError: return 0 n_endorsements = 0 def __str__(self): return self.title def set_request(self, request_or_user): """ Saves optional user and request attributes in model. Those attributes are used to compute and cache many other attributes and statistics in the conversation model instance. """ request = None user = request_or_user if not isinstance(request_or_user, get_user_model()): user = request_or_user.user request = request_or_user if (self.__dict__.get("for_user", user) != user or self.__dict__.get("request", request) != request): raise ValueError("user/request already set in conversation!") self.for_user = user self.request = request def save(self, *args, **kwargs): if self.id is None: pass super().save(*args, **kwargs) def clean(self): can_edit = "ej.can_edit_conversation" if (self.is_promoted and self.author_id is not None and not self.author.has_perm(can_edit, self)): raise ValidationError( _("User does not have permission to create a promoted " "conversation.")) def get_absolute_url(self, board=None): kwargs = {"conversation": self, "slug": self.slug} if board is None: board = getattr(self, "board", None) if board: kwargs["board"] = board return SafeUrl("boards:conversation-detail", **kwargs) else: return SafeUrl("conversation:detail", **kwargs) def url(self, which, board=None, **kwargs): """ Return a url pertaining to the current conversation. """ if board is None: board = getattr(self, "board", None) kwargs["conversation"] = self kwargs["slug"] = self.slug if board: kwargs["board"] = board which = "boards:" + which.replace(":", "-") return SafeUrl(which, **kwargs) return SafeUrl(which, **kwargs) def votes_for_user(self, user): """ Get all votes in conversation for the given user. """ if user.id is None: return Vote.objects.none() return self.votes.filter(author=user) def create_comment(self, author, content, commit=True, *, status=None, check_limits=True, **kwargs): """ Create a new comment object for the given user. If commit=True (default), comment is persisted on the database. By default, this method check if the user can post according to the limits imposed by the conversation. It also normalizes duplicate comments and reuse duplicates from the database. """ # Convert status, if necessary if status is None and (author.id == self.author.id or author.has_perm( "ej.can_edit_conversation", self)): kwargs["status"] = Comment.STATUS.approved else: kwargs["status"] = normalize_status(status) # Check limits if check_limits and not author.has_perm("ej.can_comment", self): log.info("failed attempt to create comment by %s" % author) raise PermissionError("user cannot comment on conversation.") # Check if comment is created with rejected status if status == Comment.STATUS.rejected: msg = _("automatically rejected") kwargs.setdefault("rejection_reason", msg) kwargs.update(author=author, content=content.strip()) comment = make_clean(Comment, commit, conversation=self, **kwargs) log.info("new comment: %s" % comment) return comment def vote_count(self, which=None): """ Return the number of votes of a given type. """ kwargs = {"comment__conversation_id": self.id} if which is not None: kwargs["choice"] = which return Vote.objects.filter(**kwargs).count() def statistics(self, cache=True): """ Return a dictionary with basic statistics about conversation. """ if cache: try: return self._cached_statistics except AttributeError: self._cached_statistics = self.statistics(False) return self._cached_statistics return { # Vote counts "votes": self.votes.aggregate( agree=Count("choice", filter=Q(choice=Choice.AGREE)), disagree=Count("choice", filter=Q(choice=Choice.DISAGREE)), skip=Count("choice", filter=Q(choice=Choice.SKIP)), total=Count("choice"), ), # Comment counts "comments": self.comments.aggregate( approved=Count("status", filter=Q(status=Comment.STATUS.approved)), rejected=Count("status", filter=Q(status=Comment.STATUS.rejected)), pending=Count("status", filter=Q(status=Comment.STATUS.pending)), total=Count("status"), ), # Participants count "participants": { "voters": (get_user_model().objects.filter( votes__comment__conversation_id=self.id).distinct().count( )), "commenters": (get_user_model().objects.filter( comments__conversation_id=self.id, comments__status=Comment.STATUS.approved, ).distinct().count()), }, } def statistics_for_user(self, user): """ Get information about user. """ max_votes = (self.comments.filter( status=Comment.STATUS.approved).exclude(author_id=user.id).count()) given_votes = (0 if user.id is None else (Vote.objects.filter(comment__conversation_id=self.id, author=user).count())) e = 1e-50 # for numerical stability return { "votes": given_votes, "missing_votes": max_votes - given_votes, "participation_ratio": given_votes / (max_votes + e), } def next_comment(self, user, default=NOT_GIVEN): """ Returns a random comment that user didn't vote yet. If default value is not given, raises a Comment.DoesNotExit exception if no comments are available for user. """ comment = rules.compute("ej.next_comment", self, user) if comment: return comment elif default is NOT_GIVEN: msg = _("No comments available for this user") raise Comment.DoesNotExist(msg) else: return default def is_favorite(self, user): """ Checks if conversation is favorite for the given user. """ return bool(self.favorites.filter(user=user).exists()) def make_favorite(self, user): """ Make conversation favorite for user. """ self.favorites.update_or_create(user=user) def remove_favorite(self, user): """ Remove favorite status for conversation """ if self.is_favorite(user): self.favorites.filter(user=user).delete() def toggle_favorite(self, user): """ Toggles favorite status of conversation. Return the final favorite status. """ try: self.favorites.get(user=user).delete() return False except ObjectDoesNotExist: self.make_favorite(user) return True
def _filter_comments(*args): *_, which = args status = getattr(Comment.STATUS, which) return property(lambda self: self.comments.filter(status=status))
class Conversation(HasFavoriteMixin, TimeStampedModel): """ A topic of conversation. """ title = models.CharField( _("Title"), max_length=255, help_text=_( "Short description used to create URL slugs (e.g. School system)." ), ) text = models.TextField(_("Question"), help_text=_("What do you want to ask?")) author = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="conversations", help_text= _("Only the author and administrative staff can edit this conversation." ), ) moderators = models.ManyToManyField( settings.AUTH_USER_MODEL, blank=True, related_name="moderated_conversations", help_text=_("Moderators can accept and reject comments."), ) slug = AutoSlugField(unique=False, populate_from="title") is_promoted = models.BooleanField( _("Promote conversation?"), default=False, help_text=_( "Promoted conversations appears in the main /conversations/ " "endpoint."), ) is_hidden = models.BooleanField( _("Hide conversation?"), default=False, help_text=_( "Hidden conversations does not appears in boards or in the main /conversations/ " "endpoint."), ) objects = ConversationQuerySet.as_manager() tags = TaggableManager(through="ConversationTag", blank=True) votes = property( lambda self: Vote.objects.filter(comment__conversation=self)) @property def users(self): return get_user_model().objects.filter( votes__comment__conversation=self).distinct() # Comment managers def _filter_comments(*args): *_, which = args status = getattr(Comment.STATUS, which) return property(lambda self: self.comments.filter(status=status)) approved_comments = _filter_comments("approved") rejected_comments = _filter_comments("rejected") pending_comments = _filter_comments("pending") del _filter_comments class Meta: ordering = ["created"] verbose_name = _("Conversation") verbose_name_plural = _("Conversations") permissions = ( ("can_publish_promoted", _("Can publish promoted conversations")), ("is_moderator", _("Can moderate comments in any conversation")), ) # # Statistics and annotated values # author_name = lazy(this.author.name) first_tag = lazy(this.tags.values_list("name", flat=True).first()) tag_names = lazy(this.tags.values_list("name", flat=True)) # Statistics n_comments = deprecate_lazy( this.n_approved_comments, "Conversation.n_comments was deprecated in favor of .n_approved_comments." ) n_approved_comments = lazy(this.approved_comments.count()) n_pending_comments = lazy(this.pending_comments.count()) n_rejected_comments = lazy(this.rejected_comments.count()) n_total_comments = lazy(this.comments.count().count()) n_favorites = lazy(this.favorites.count()) n_tags = lazy(this.tags.count()) n_votes = lazy(this.votes.count()) n_final_votes = lazy(this.votes.exclude(choice=Choice.SKIP).count()) n_participants = lazy(this.users.count()) # Statistics for the request user user_comments = property(this.comments.filter(author=this.for_user)) user_votes = property(this.votes.filter(author=this.for_user)) n_user_total_comments = lazy(this.user_comments.count()) n_user_comments = lazy( this.user_comments.filter(status=Comment.STATUS.approved).count()) n_user_rejected_comments = lazy( this.user_comments.filter(status=Comment.STATUS.rejected).count()) n_user_pending_comments = lazy( this.user_comments.filter(status=Comment.STATUS.pending).count()) n_user_votes = lazy(this.user_votes.count()) n_user_final_votes = lazy( this.user_votes.exclude(choice=Choice.SKIP).count()) is_user_favorite = lazy(this.is_favorite(this.for_user)) # Statistical methods vote_count = vote_count statistics = statistics statistics_for_user = statistics_for_user @lazy def for_user(self): return self.request.user @lazy def request(self): msg = "Set the request object by calling the .set_request(request) method first" raise RuntimeError(msg) # TODO: move as patches from other apps @lazy def n_clusters(self): try: return self.clusterization.n_clusters except AttributeError: return 0 @lazy def n_stereotypes(self): try: return self.clusterization.n_clusters except AttributeError: return 0 n_endorsements = 0 # FIXME: endorsements def __str__(self): return self.title def set_request(self, request_or_user): """ Saves optional user and request attributes in model. Those attributes are used to compute and cache many other attributes and statistics in the conversation model instance. """ request = None user = request_or_user if not isinstance(request_or_user, get_user_model()): user = request_or_user.user request = request_or_user if self.__dict__.get("for_user", user) != user or self.__dict__.get( "request", request) != request: raise ValueError("user/request already set in conversation!") self.for_user = user self.request = request def save(self, *args, **kwargs): if self.id is None: pass super().save(*args, **kwargs) def clean(self): can_edit = "ej.can_edit_conversation" if self.is_promoted and self.author_id is not None and not self.author.has_perm( can_edit, self): raise ValidationError( _("User does not have permission to create a promoted " "conversation.")) def get_absolute_url(self, board=None): kwargs = {"conversation": self, "slug": self.slug} if board is None: board = getattr(self, "board", None) if board: kwargs["board"] = board return SafeUrl("boards:conversation-detail", **kwargs) else: return SafeUrl("conversation:detail", **kwargs) def url(self, which="conversation:detail", board=None, **kwargs): """ Return a url pertaining to the current conversation. """ if board is None: board = getattr(self, "board", None) kwargs["conversation"] = self kwargs["slug"] = self.slug if board: kwargs["board"] = board which = "boards:" + which.replace(":", "-") return SafeUrl(which, **kwargs) return SafeUrl(which, **kwargs) def votes_for_user(self, user): """ Get all votes in conversation for the given user. """ if user.id is None: return Vote.objects.none() return self.votes.filter(author=user) def create_comment(self, author, content, commit=True, *, status=None, check_limits=True, **kwargs): """ Create a new comment object for the given user. If commit=True (default), comment is persisted on the database. By default, this method check if the user can post according to the limits imposed by the conversation. It also normalizes duplicate comments and reuse duplicates from the database. """ # Convert status, if necessary if status is None and (author.id == self.author.id or author.has_perm( "ej.can_edit_conversation", self)): kwargs["status"] = Comment.STATUS.approved else: kwargs["status"] = normalize_status(status) # Check limits if check_limits and not author.has_perm("ej.can_comment", self): log.info("failed attempt to create comment by %s" % author) raise PermissionError("user cannot comment on conversation.") # Check if comment is created with rejected status if status == Comment.STATUS.rejected: msg = _("automatically rejected") kwargs.setdefault("rejection_reason", msg) kwargs.update(author=author, content=content.strip()) comment = make_clean(Comment, commit, conversation=self, **kwargs) if comment.status == comment.STATUS.approved and author != self.author: comment_moderated.send( Comment, comment=comment, moderator=comment.moderator, is_approved=True, author=comment.author, ) log.info("new comment: %s" % comment) return comment def next_comment(self, user, default=NOT_GIVEN): """ Returns a random comment that user didn't vote yet. If default value is not given, raises a Comment.DoesNotExit exception if no comments are available for user. """ comment = rules.compute("ej.next_comment", self, user) if comment: return comment return None def next_comment_with_id(self, user, comment_id=None): """ Returns a comment with id if user didn't vote yet, otherwhise return a random comment. """ if comment_id: try: return self.approved_comments.exclude(votes__author=user).get( id=comment_id) except Exception as e: pass return self.next_comment(user)