Ejemplo n.º 1
0
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.",
    )
Ejemplo n.º 2
0
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")
Ejemplo n.º 3
0
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())
Ejemplo n.º 4
0
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
Ejemplo n.º 5
0
class EpidemicParameters(Parameters):
    gamma = sk.property(1 / _.infectious_period)
    sigma = sk.property(1 / _.incubation_period)
Ejemplo n.º 6
0
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()
Ejemplo n.º 7
0
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)
Ejemplo n.º 8
0
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)
Ejemplo n.º 9
0
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
Ejemplo n.º 10
0
 def _filter_comments(*args):
     *_, which = args
     status = getattr(Comment.STATUS, which)
     return property(lambda self: self.comments.filter(status=status))
Ejemplo n.º 11
0
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)