示例#1
0
class ServiceType(Model):
    title = models.CharField(_("title"), max_length=40)
    hourly_rate = MoneyField(_("hourly rate"))
    position = models.IntegerField(_("position"), default=0)

    class Meta:
        ordering = ("position", "id")
        verbose_name = _("service type")
        verbose_name_plural = _("service types")

    def __str__(self):
        return self.title
示例#2
0
class Accruals(models.Model):
    cutoff_date = models.DateField(_("cutoff date"), unique=True)
    accruals = MoneyField(_("accruals"))

    objects = AccrualsQuerySet.as_manager()

    class Meta:
        ordering = ["-cutoff_date"]
        verbose_name = _("accruals")
        verbose_name_plural = _("accruals")

    def __str__(self):
        return local_date_format(self.cutoff_date)
示例#3
0
class Value(models.Model):
    deal = models.ForeignKey(
        Deal, on_delete=models.CASCADE, related_name="values", verbose_name=_("deal")
    )
    type = models.ForeignKey(
        ValueType, on_delete=models.PROTECT, verbose_name=_("type")
    )
    value = MoneyField(_("value"))

    class Meta:
        ordering = ["type"]
        unique_together = [("deal", "type")]
        verbose_name = _("value")
        verbose_name_plural = _("values")

    def __str__(self):
        return currency(self.value)
示例#4
0
class ValueType(models.Model):
    title = models.CharField(_("title"), max_length=200)
    position = models.PositiveIntegerField(_("position"), default=0)
    is_archived = models.BooleanField(_("is archived"), default=False)
    weekly_target = MoneyField(_("weekly target"), blank=True, null=True)

    class Meta:
        ordering = ("position", "id")
        verbose_name = _("value type")
        verbose_name_plural = _("value types")

    def __str__(self):
        return self.title

    def __lt__(self, other):
        return ((self.position, -self.pk) < (other.position, -other.pk)
                if isinstance(other, self.__class__) else 1)
示例#5
0
class ProjectedInvoice(models.Model):
    project = models.ForeignKey(
        Project,
        on_delete=models.CASCADE,
        related_name="projected_invoices",
        verbose_name=_("project"),
    )
    invoiced_on = models.DateField(_("invoiced on"))
    gross_margin = MoneyField(_("gross margin"))
    description = models.CharField(_("description"),
                                   max_length=200,
                                   blank=True)

    class Meta:
        ordering = ["invoiced_on"]
        verbose_name = _("projected invoice")
        verbose_name_plural = _("projected invoices")

    def __str__(self):
        return f"{local_date_format(self.invoiced_on)}: {currency(self.gross_margin)}"
示例#6
0
class CreditEntry(Model):
    ledger = models.ForeignKey(
        Ledger,
        on_delete=models.PROTECT,
        related_name="transactions",
        verbose_name=_("ledger"),
    )
    reference_number = models.CharField(
        _("reference number"), max_length=40, unique=True
    )
    value_date = models.DateField(_("value date"))
    total = MoneyField(_("total"))
    payment_notice = models.CharField(_("payment notice"), max_length=1000, blank=True)

    invoice = models.OneToOneField(
        Invoice,
        on_delete=models.PROTECT,
        blank=True,
        null=True,
        verbose_name=_("invoice"),
    )
    notes = models.TextField(_("notes"), blank=True)
    _fts = models.TextField(editable=False, blank=True)

    objects = CreditEntryQuerySet.as_manager()

    class Meta:
        ordering = ["-value_date", "-pk"]
        verbose_name = _("credit entry")
        verbose_name_plural = _("credit entries")

    def __str__(self):
        return self.reference_number

    def save(self, *args, **kwargs):
        self._fts = " ".join(str(part) for part in [self.invoice or "", self.total])
        super().save(*args, **kwargs)

    save.alters_data = True
示例#7
0
class ServiceBase(Model):
    created_at = models.DateTimeField(_("created at"), default=timezone.now)

    title = models.CharField(_("title"), max_length=200)
    description = models.TextField(_("description"), blank=True)
    position = models.IntegerField(_("position"), default=0)

    service_hours = HoursFieldAllowNegatives(_("service hours"), default=0)
    service_cost = MoneyField(_("service cost"), default=0)

    effort_type = models.CharField(_("effort type"), max_length=50, blank=True)
    effort_hours = HoursFieldAllowNegatives(_("hours"), blank=True, null=True)
    effort_rate = MoneyField(_("hourly rate"), blank=True, null=True)

    cost = MoneyField(_("cost"), blank=True, null=True)
    third_party_costs = MoneyField(
        _("third party costs"),
        default=None,
        blank=True,
        null=True,
        help_text=_("Total incl. tax for third-party services."),
    )

    class Meta:
        abstract = True
        ordering = ["position", "created_at"]
        verbose_name = _("service")
        verbose_name_plural = _("services")

    def __str__(self):
        return Truncator(": ".join(filter(
            None, (self.title, self.description)))).chars(100)

    def project_service_title(self):
        title = "{}: {}{}{}".format(
            self.project,
            self.title,
            ": " if self.description else "",
            self.description,
        )
        return Truncator(title).chars(100)

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._related_model = self._meta.get_field(self.RELATED_MODEL_FIELD)
        self._orig_related_id = getattr(self, self._related_model.attname)

    def save(self, *args, **kwargs):
        skip_related_model = kwargs.pop("skip_related_model", False)

        if not self.position:
            max_pos = self.__class__._default_manager.aggregate(
                m=Max("position"))["m"]
            self.position = 10 + (max_pos or 0)
        self.service_hours = self.effort_hours or Z1
        self.service_cost = self.cost or Z2
        if all((self.effort_hours, self.effort_rate)):
            self.service_cost += self.effort_hours * self.effort_rate

        super().save(*args, **kwargs)

        if not skip_related_model:
            ids = filter(
                None,
                [
                    self._orig_related_id,
                    getattr(self, self._related_model.attname)
                ],
            )
            for (
                    instance
            ) in self._related_model.remote_field.model._default_manager.filter(
                    id__in=ids):
                instance.save()

    save.alters_data = True

    def delete(self, *args, **kwargs):
        super().delete(*args, **kwargs)
        if self._orig_related_id:
            ids = filter(
                None,
                [
                    self._orig_related_id,
                    getattr(self, self._related_model.attname)
                ],
            )
            for (
                    instance
            ) in self._related_model.remote_field.model._default_manager.filter(
                    id__in=ids):
                instance.save()

    delete.alters_data = True

    def clean_fields(self, exclude):
        super().clean_fields(exclude)
        errors = {}
        effort = (self.effort_type != "", self.effort_rate is not None)
        if any(effort) and not all(effort):
            if self.effort_type == "":
                errors["effort_type"] = _("Either fill in all fields or none.")
            if self.effort_rate is None:
                errors["effort_rate"] = _("Either fill in all fields or none.")
        if self.third_party_costs is not None and self.cost is None:
            errors["cost"] = _("Cannot be empty if third party costs is set.")
        raise_if_errors(errors, exclude)
示例#8
0
class LoggedCost(Model):
    service = models.ForeignKey(
        Service,
        on_delete=models.PROTECT,
        related_name="loggedcosts",
        verbose_name=_("service"),
    )

    created_at = models.DateTimeField(_("created at"), default=timezone.now)
    created_by = models.ForeignKey(User,
                                   on_delete=models.PROTECT,
                                   verbose_name=_("created by"))
    rendered_on = models.DateField(_("rendered on"),
                                   default=dt.date.today,
                                   db_index=True)
    rendered_by = models.ForeignKey(
        User,
        on_delete=models.PROTECT,
        related_name="loggedcosts",
        verbose_name=_("rendered by"),
    )
    cost = MoneyField(_("cost"))
    third_party_costs = MoneyField(
        _("third party costs"),
        blank=True,
        null=True,
        help_text=_("Total incl. tax for third-party services."),
    )
    description = models.TextField(_("description"))

    invoice_service = models.ForeignKey(
        "invoices.Service",
        on_delete=models.PROTECT,
        blank=True,
        null=True,
        verbose_name=_("invoice service"),
    )
    archived_at = models.DateTimeField(_("archived at"), blank=True, null=True)

    are_expenses = models.BooleanField(_("paid from my own pocket"),
                                       default=False)
    expense_report = models.ForeignKey(
        "expenses.ExpenseReport",
        on_delete=models.PROTECT,
        blank=True,
        null=True,
        verbose_name=_("expense report"),
        related_name="expenses",
    )

    expense_currency = models.CharField(_("original currency"),
                                        max_length=3,
                                        blank=True)
    expense_cost = MoneyField(_("original cost"), blank=True, null=True)

    objects = LoggedCostQuerySet.as_manager()

    class Meta:
        ordering = ("-rendered_on", "-created_at")
        verbose_name = _("logged cost")
        verbose_name_plural = _("logged costs")

    def __str__(self):
        return self.description

    @classmethod
    def allow_delete(cls, instance, request):
        if instance.invoice_service_id or instance.archived_at:
            messages.error(request,
                           _("Cannot delete archived logged cost entries."))
            return False
        if instance.expense_report:
            messages.error(
                request,
                _("Expenses are part of an expense report, cannot delete entry."
                  ),
            )
        return super().allow_delete(instance, request)

    @classmethod
    def get_redirect_url(cls, instance, request):
        if not request.is_ajax():
            return cls.urls["list"] + "?project={}".format(
                instance.service.project_id if instance else "")

    def clean_fields(self, exclude):
        super().clean_fields(exclude)
        errors = {}
        expense = (self.expense_currency != "", self.expense_cost is not None)
        if any(expense) and not all(expense):
            if self.expense_currency == "":
                errors["expense_currency"] = _(
                    "Either fill in all fields or none.")
            if self.expense_cost is None:
                errors["expense_cost"] = _(
                    "Either fill in all fields or none.")
        raise_if_errors(errors, exclude)
示例#9
0
class Project(Model):
    ORDER = "order"
    MAINTENANCE = "maintenance"
    INTERNAL = "internal"

    TYPE_CHOICES = [
        (ORDER, _("Order")),
        (MAINTENANCE, _("Maintenance")),
        (INTERNAL, _("Internal")),
    ]

    customer = models.ForeignKey(Organization,
                                 on_delete=models.PROTECT,
                                 verbose_name=_("customer"))
    contact = models.ForeignKey(
        Person,
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
        verbose_name=_("contact"),
    )

    title = models.CharField(_("title"), max_length=200)
    description = models.TextField(
        _("project description"),
        blank=True,
        help_text=_("Do not use this for the offer description."
                    " You can add the offer description later."),
    )
    owned_by = models.ForeignKey(User,
                                 on_delete=models.PROTECT,
                                 verbose_name=_("responsible"))

    type = models.CharField(_("type"), choices=TYPE_CHOICES, max_length=20)
    flat_rate = MoneyField(
        _("flat rate"),
        blank=True,
        null=True,
        help_text=_(
            "Set this if you want all services to have the same hourly rate."),
    )
    created_at = models.DateTimeField(_("created at"), default=timezone.now)
    closed_on = models.DateField(_("closed on"), blank=True, null=True)

    _code = models.IntegerField(_("code"))
    _fts = models.TextField(editable=False, blank=True)

    cost_center = models.ForeignKey(
        "reporting.CostCenter",
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
        verbose_name=_("cost center"),
        related_name="projects",
    )
    campaign = models.ForeignKey(
        Campaign,
        on_delete=models.PROTECT,
        blank=True,
        null=True,
        verbose_name=_("campaign"),
        related_name="projects",
    )

    objects = ProjectQuerySet.as_manager()

    class Meta:
        ordering = ("-id", )
        verbose_name = _("project")
        verbose_name_plural = _("projects")

    def __str__(self):
        return "%s %s - %s" % (self.code, self.title,
                               self.owned_by.get_short_name())

    def __html__(self):
        return format_html(
            "<small>{}</small> {} - {}",
            self.code,
            self.title,
            self.owned_by.get_short_name(),
        )

    def __lt__(self, other):
        return self.id < other.id if isinstance(other, Project) else 1

    @property
    def code(self):
        return "%s-%04d" % (self.created_at.year, self._code)

    def save(self, *args, **kwargs):
        new = not self.pk
        if new:
            self._code = RawSQL(
                "SELECT COALESCE(MAX(_code), 0) + 1 FROM projects_project"
                " WHERE EXTRACT(year FROM created_at) = %s",
                (timezone.now().year, ),
            )
            super().save(*args, **kwargs)
            self.refresh_from_db()

        self._fts = " ".join(
            str(part) for part in [
                self.code,
                self.customer.name,
                self.contact.full_name if self.contact else "",
            ])
        if new:
            super().save()
        else:
            super().save(*args, **kwargs)

    save.alters_data = True

    def clean_fields(self, exclude=None):
        super().clean_fields(exclude=exclude)
        errors = {}
        if self.closed_on and self.closed_on > dt.date.today():
            errors["closed_on"] = _(
                "Leave this empty if you do not want to close the project yet."
            )
        raise_if_errors(errors)

    @property
    def status_badge(self):
        css = {
            self.MAINTENANCE: "secondary",
            self.ORDER: "success",
            self.INTERNAL: "info",
        }[self.type]

        if self.closed_on:
            css = "light"
        return format_html('<span class="badge badge-{}">{}</span>', css,
                           self.pretty_status)

    @property
    def pretty_status(self):
        parts = [str(self.get_type_display())]
        if self.closed_on:
            parts.append(
                gettext("closed on %s") % local_date_format(self.closed_on))
        return ", ".join(parts)

    @cached_property
    def grouped_services(self):
        # Avoid circular imports
        from workbench.deals.models import Deal
        from workbench.logbook.models import LoggedCost, LoggedHours

        # Logged vs. service hours
        service_hours = defaultdict(lambda: Z1)
        logged_hours = defaultdict(lambda: Z1)
        # Logged vs. service cost
        service_cost = defaultdict(lambda: Z2)
        logged_cost = defaultdict(lambda: Z2)
        # Project logbook vs. project service cost (hours and cost)
        total_service_cost = Z2
        total_logged_cost = Z2
        total_service_hours_rate_undefined = Z1
        total_logged_hours_rate_undefined = Z1

        offers = self.offers.select_related("owned_by").prefetch_related(
            Prefetch("deals", Deal.objects.select_related("owned_by")))
        offers_map = {offer.id: offer for offer in offers}
        services_by_offer = defaultdict(lambda: {"services": []}, ((offer, {
            "services": []
        }) for offer in offers))

        logged_hours_per_service_and_user = defaultdict(dict)
        logged_hours_per_user = defaultdict(lambda: Z1)
        logged_hours_per_effort_rate = defaultdict(lambda: Z1)

        for row in (LoggedHours.objects.order_by().filter(
                service__project=self).values("service",
                                              "rendered_by").annotate(
                                                  Sum("hours"))):
            logged_hours_per_user[row["rendered_by"]] += row["hours__sum"]
            logged_hours_per_service_and_user[row["service"]][
                row["rendered_by"]] = row["hours__sum"]

        logged_cost_per_service = {
            row["service"]: row["cost__sum"]
            for row in LoggedCost.objects.order_by().filter(
                service__project=self).values("service").annotate(Sum("cost"))
        }

        not_archived_logged_hours_per_service = {
            row["service"]: row["hours__sum"]
            for row in LoggedHours.objects.order_by().filter(
                service__project=self,
                archived_at__isnull=True,
                service__effort_rate__isnull=False,
            ).values("service").annotate(Sum("hours"))
        }
        not_archived_logged_cost_per_service = {
            row["service"]: row["cost__sum"]
            for row in LoggedCost.objects.order_by().filter(
                service__project=self, archived_at__isnull=True).values(
                    "service").annotate(Sum("cost"))
        }

        users = {
            user.id: user
            for user in User.objects.filter(
                id__in=logged_hours_per_user.keys())
        }

        for service in self.services.all():
            service.offer = offers_map.get(service.offer_id)  # Reuse
            logged = logged_hours_per_service_and_user.get(service.id, {})
            row = {
                "service":
                service,
                "logged_hours":
                sum(logged.values(), Z1),
                "logged_hours_per_user":
                sorted(
                    ((users[user], hours) for user, hours in logged.items()),
                    key=lambda row: row[1],
                    reverse=True,
                ),
                "logged_cost":
                logged_cost_per_service.get(service.id, Z2),
                "not_archived_logged_hours":
                not_archived_logged_hours_per_service.get(service.id, Z1),
                "not_archived_logged_cost":
                not_archived_logged_cost_per_service.get(service.id, Z1),
            }
            row["not_archived"] = (service.effort_rate or Z2) * row[
                "not_archived_logged_hours"] + row["not_archived_logged_cost"]

            logged_hours[service.offer] += row["logged_hours"]
            logged_cost[service.offer] += row["logged_cost"]
            logged_hours[service.project] += row["logged_hours"]
            logged_cost[service.project] += row["logged_cost"]
            total_logged_cost += row["logged_cost"]
            logged_hours_per_effort_rate[
                service.effort_rate] += row["logged_hours"]

            if not service.is_declined:
                service_hours[service.offer] += service.service_hours
                service_cost[service.offer] += service.cost or Z2
                service_hours[service.project] += service.service_hours
                service_cost[service.project] += service.cost or Z2
                total_service_cost += service.service_cost

            if service.effort_rate is not None:
                total_logged_cost += service.effort_rate * row["logged_hours"]
            else:
                total_logged_hours_rate_undefined += row["logged_hours"]
                if not service.is_declined:
                    total_service_hours_rate_undefined += service.service_hours

            services_by_offer[offers_map.get(
                service.offer_id)]["services"].append(row)

        for offer, offer_data in services_by_offer.items():
            offer_data["service_hours"] = service_hours[offer]
            offer_data["logged_hours"] = logged_hours[offer]
            offer_data["service_cost"] = service_cost[offer]
            offer_data["logged_cost"] = logged_cost[offer]

        return {
            "offers":
            sorted(item for item in services_by_offer.items()
                   if item[1]["services"] or item[0] is not None),
            "logged_hours":
            logged_hours[self],
            "logged_hours_per_user":
            sorted(
                ((users[user], hours)
                 for user, hours in logged_hours_per_user.items()),
                key=lambda row: row[1],
                reverse=True,
            ),
            "logged_hours_per_effort_rate":
            sorted(
                ((rate, hours)
                 for rate, hours in logged_hours_per_effort_rate.items()
                 if hours),
                key=lambda row: row[0] or Decimal("9999999"),
                reverse=True,
            ),
            "logged_cost":
            logged_cost[self],
            "service_hours":
            service_hours[self],
            "service_cost":
            service_cost[self],
            "total_service_cost":
            total_service_cost,
            "total_logged_cost":
            total_logged_cost,
            "total_service_hours_rate_undefined":
            total_service_hours_rate_undefined,
            "total_logged_hours_rate_undefined":
            total_logged_hours_rate_undefined,
            "total_discount":
            sum((offer.discount for offer in offers if not offer.is_declined),
                Z2),
        }

    @cached_property
    def project_invoices(self):
        return self.invoices.select_related("contact__organization").reverse()

    @cached_property
    def project_invoices_total_excl_tax(self):
        return sum(
            (invoice.total_excl_tax
             for invoice in self.project_invoices if invoice.is_invoiced),
            Z2,
        )

    @cached_property
    def not_archived_total(self):
        # Avoid circular imports
        from workbench.logbook.models import LoggedCost, LoggedHours

        total = Z2
        hours_rate_undefined = Z1

        for row in (LoggedHours.objects.order_by().filter(
                service__project=self, archived_at__isnull=True).values(
                    "service__effort_rate").annotate(Sum("hours"))):
            if row["service__effort_rate"] is None:
                hours_rate_undefined += row["hours__sum"]
            else:
                total += row["hours__sum"] * row["service__effort_rate"]

        total += (LoggedCost.objects.order_by().filter(
            service__project=self, archived_at__isnull=True).aggregate(
                Sum("cost"))["cost__sum"] or Z2)
        return {"total": total, "hours_rate_undefined": hours_rate_undefined}

    def solely_declined_offers_warning(self, *, request):
        from workbench.offers.models import Offer

        if self.closed_on:
            return

        status = set(self.offers.order_by().values_list("status", flat=True))
        if status == {Offer.DECLINED}:
            messages.warning(
                request,
                _("All offers of project %(project)s are declined."
                  " You might want to close the project now?") %
                {"project": self},
            )

    @property
    def is_logbook_locked(self):
        return self.closed_on and self.closed_on < in_days(-14)
示例#10
0
class Deal(Model):
    OPEN = 10
    ACCEPTED = 20
    DECLINED = 30

    STATUS_CHOICES = (
        (OPEN, _("Open")),
        (ACCEPTED, _("Accepted")),
        (DECLINED, _("Declined")),
    )

    UNKNOWN = 10
    NORMAL = 20
    HIGH = 30

    PROBABILITY_CHOICES = [
        (UNKNOWN, _("unknown")),
        (NORMAL, _("normal")),
        (HIGH, _("high")),
    ]

    customer = models.ForeignKey(
        Organization, on_delete=models.PROTECT, verbose_name=_("customer")
    )
    contact = models.ForeignKey(
        Person,
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
        verbose_name=_("contact"),
    )

    title = models.CharField(_("title"), max_length=200)
    description = models.TextField(_("description"), blank=True)
    owned_by = models.ForeignKey(
        User, on_delete=models.PROTECT, verbose_name=_("contact person")
    )
    value = MoneyField(_("value"))

    status = models.PositiveIntegerField(
        _("status"), choices=STATUS_CHOICES, default=OPEN
    )
    probability = models.IntegerField(
        _("probability"), choices=PROBABILITY_CHOICES, default=UNKNOWN
    )
    decision_expected_on = models.DateField(
        _("decision expected on"), blank=True, null=True
    )

    created_at = models.DateTimeField(_("created at"), default=timezone.now)

    attributes = models.ManyToManyField(
        Attribute,
        verbose_name=_("attributes"),
        through="DealAttribute",
    )

    closed_on = models.DateField(_("closed on"), blank=True, null=True)
    closing_type = models.ForeignKey(
        ClosingType,
        on_delete=models.PROTECT,
        blank=True,
        null=True,
        verbose_name=_("closing type"),
    )
    closing_notice = models.TextField(_("closing notice"), blank=True)

    _fts = models.TextField(editable=False, blank=True)

    related_offers = models.ManyToManyField(
        "offers.Offer",
        blank=True,
        related_name="deals",
        verbose_name=_("related offers"),
    )

    contributors = models.ManyToManyField(
        User,
        verbose_name=_("contributors"),
        related_name="+",
        through="Contribution",
        help_text=_(
            "The value of the deal will be distributed among all"
            " contributors in the accepted deals report."
        ),
    )

    objects = DealQuerySet.as_manager()

    class Meta:
        ordering = ["-pk"]
        verbose_name = _("deal")
        verbose_name_plural = _("deals")

    def __str__(self):
        return f"{self.code} {self.title} - {self.owned_by.get_short_name()}"

    def __html__(self):
        return format_html(
            "<small>{}</small> {} - {}",
            self.code,
            self.title,
            self.owned_by.get_short_name(),
        )

    def get_related_offers(self):
        return self.related_offers.select_related("owned_by", "project")

    def save(self, *args, **kwargs):
        skip_value_calculation = kwargs.pop("skip_value_calculation", False)

        if not skip_value_calculation:
            self.value = sum((v.value for v in self.values.all()), Z2)

        self._fts = " ".join(
            str(part)
            for part in [
                self.code,
                self.customer.name,
                self.contact.full_name if self.contact else "",
            ]
        )
        super().save(*args, **kwargs)

    save.alters_data = True

    @property
    def pretty_status(self):
        d = {
            "created_at": local_date_format(self.created_at.date()),
            "closed_on": self.closed_on and local_date_format(self.closed_on),
            "decision_expected_on": self.decision_expected_on
            and local_date_format(self.decision_expected_on),
            "status": self.get_status_display(),
        }

        if self.status != self.OPEN:
            return _("%(status)s on %(closed_on)s") % d
        if self.decision_expected_on:
            return _("Decision expected on %(decision_expected_on)s") % d
        return _("Open since %(created_at)s") % d

    @property
    def status_badge(self):
        if self.status != self.OPEN:
            css = {self.ACCEPTED: "success", self.DECLINED: "danger"}[self.status]
        elif self.decision_expected_on:
            css = "warning" if self.decision_expected_on < dt.date.today() else "info"
        else:
            open_since = (dt.date.today() - self.created_at.date()).days
            if (
                open_since
                > {self.UNKNOWN: 90, self.NORMAL: 45, self.HIGH: 20}[self.probability]
            ):
                css = "caveat"
            else:
                css = "info"

        return format_html(
            '<span class="badge badge-{}">{}</span>', css, self.pretty_status
        )

    @property
    def pretty_closing_type(self):
        return self.closing_type or _("<closing type missing>")

    @property
    def all_contributions(self):
        contributors = {}
        for contribution in self.contributions.all():
            contributors[contribution.user] = contribution.weight
        total = sum(contributors.values(), 0)
        return sorted(
            (
                {"user": user, "value": self.value * weight / total}
                for user, weight in contributors.items()
            ),
            key=lambda row: row["value"],
            reverse=True,
        )
示例#11
0
class RecurringInvoice(ModelWithTotal):
    PERIODICITY_CHOICES = [
        ("yearly", _("yearly")),
        ("quarterly", _("quarterly")),
        ("monthly", _("monthly")),
        ("weekly", _("weekly")),
    ]

    customer = models.ForeignKey(Organization,
                                 on_delete=models.PROTECT,
                                 verbose_name=_("customer"))
    contact = models.ForeignKey(
        Person,
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
        verbose_name=_("contact"),
    )

    title = models.CharField(_("title"), max_length=200)
    description = models.TextField(_("description"), blank=True)
    owned_by = models.ForeignKey(User,
                                 on_delete=models.PROTECT,
                                 verbose_name=_("responsible"))

    created_at = models.DateTimeField(_("created at"), default=timezone.now)

    third_party_costs = MoneyField(
        _("third party costs"),
        default=Z2,
        help_text=_("Only used for statistical purposes."),
    )
    postal_address = models.TextField(_("postal address"))

    starts_on = models.DateField(_("starts on"), default=dt.date.today)
    ends_on = models.DateField(_("ends on"), blank=True, null=True)
    periodicity = models.CharField(_("periodicity"),
                                   max_length=20,
                                   choices=PERIODICITY_CHOICES)
    create_invoice_on_day = models.IntegerField(
        _("create invoice on day"),
        default=-20,
        help_text=
        _("Invoices are created 20 days before their period begins by default."
          ),
    )
    next_period_starts_on = models.DateField(_("next period starts on"),
                                             blank=True,
                                             null=True)

    create_project = models.BooleanField(
        _("Create project?"),
        help_text=_("Invoices are created without projects by default."),
        default=False,
    )

    objects = RecurringInvoiceQuerySet.as_manager()

    class Meta:
        ordering = ["customer__name", "title"]
        verbose_name = _("recurring invoice")
        verbose_name_plural = _("recurring invoices")

    def __str__(self):
        return self.title

    def __html__(self):
        return "%s - %s" % (self.title, self.owned_by.get_short_name())

    @property
    def pretty_status(self):
        if self.ends_on:
            return _("%(periodicity)s from %(from)s until %(until)s") % {
                "periodicity": self.get_periodicity_display(),
                "from": local_date_format(self.starts_on),
                "until": local_date_format(self.ends_on),
            }
        return _("%(periodicity)s from %(from)s") % {
            "periodicity": self.get_periodicity_display(),
            "from": local_date_format(self.starts_on),
        }

    @property
    def pretty_next_period(self):
        start = self.next_period_starts_on or self.starts_on
        if self.ends_on and self.ends_on < start:
            return ""
        create = start + dt.timedelta(days=self.create_invoice_on_day)

        return _(
            "Next period starts on %(start)s, invoice will be created on %(create)s"
        ) % {
            "start": local_date_format(start),
            "create": local_date_format(create)
        }

    @property
    def status_badge(self):
        return format_html(
            '<span class="badge badge-{}">{}</span>',
            "light" if self.ends_on else "secondary",
            self.pretty_status,
        )

    def create_single_invoice(self, *, period_starts_on, period_ends_on):
        project = None
        if self.create_project:
            project = Project.objects.create(
                customer=self.customer,
                contact=self.contact,
                title=self.title,
                description=self.description,
                owned_by=self.owned_by,
                type=Project.MAINTENANCE,
            )

        return Invoice.objects.create(
            customer=self.customer,
            contact=self.contact,
            project=project,
            invoiced_on=period_starts_on,
            due_on=period_starts_on + dt.timedelta(days=15),
            title=self.title,
            description=self.description,
            service_period_from=period_starts_on,
            service_period_until=period_ends_on,
            owned_by=self.owned_by,
            status=Invoice.IN_PREPARATION,
            type=Invoice.DOWN_PAYMENT
            if self.create_project else Invoice.FIXED,
            postal_address=self.postal_address,
            subtotal=self.subtotal,
            discount=self.discount,
            liable_to_vat=self.liable_to_vat,
            # tax_rate=self.tax_rate,
            # total=self.total,
            third_party_costs=self.third_party_costs,
        )

    def create_invoices(self):
        invoices = []
        days = recurring(
            max(filter(None, (self.next_period_starts_on, self.starts_on))),
            self.periodicity,
        )
        generate_until = min(
            filter(None, (in_days(-self.create_invoice_on_day), self.ends_on)))
        this_period = next(days)
        while True:
            if this_period > generate_until:
                break
            next_period = next(days)
            invoices.append(
                self.create_single_invoice(
                    period_starts_on=this_period,
                    period_ends_on=next_period - dt.timedelta(days=1),
                ))
            self.next_period_starts_on = next_period
            this_period = next_period
        self.save()
        return invoices
示例#12
0
class Invoice(ModelWithTotal):
    IN_PREPARATION = 10
    SENT = 20
    PAID = 40
    CANCELED = 50

    STATUS_CHOICES = (
        (IN_PREPARATION, _("In preparation")),
        (SENT, _("Sent")),
        (PAID, _("Paid")),
        (CANCELED, _("Canceled")),
    )
    INVOICED_STATUSES = {SENT, PAID}

    FIXED = "fixed"
    DOWN_PAYMENT = "down-payment"
    SERVICES = "services"

    TYPE_CHOICES = (
        (FIXED, _("Fixed amount")),
        (DOWN_PAYMENT, _("Down payment")),
        (SERVICES, _("Services")),
    )

    customer = models.ForeignKey(Organization,
                                 on_delete=models.PROTECT,
                                 verbose_name=_("customer"))
    contact = models.ForeignKey(
        Person,
        on_delete=models.SET_NULL,
        blank=True,
        null=True,
        verbose_name=_("contact"),
    )
    project = models.ForeignKey(
        Project,
        on_delete=models.PROTECT,
        blank=True,
        null=True,
        verbose_name=_("project"),
        related_name="invoices",
    )

    invoiced_on = models.DateField(_("invoiced on"), blank=True, null=True)
    due_on = models.DateField(_("due on"), blank=True, null=True)
    closed_on = models.DateField(
        _("closed on"),
        blank=True,
        null=True,
        help_text=_("Payment date for paid invoices, date of"
                    " replacement or cancellation otherwise."),
    )
    last_reminded_on = models.DateField(_("last reminded on"),
                                        blank=True,
                                        null=True)

    title = models.CharField(_("title"), max_length=200)
    description = models.TextField(_("description"), blank=True)
    service_period_from = models.DateField(_("service period from"),
                                           blank=True,
                                           null=True)
    service_period_until = models.DateField(_("service period until"),
                                            blank=True,
                                            null=True)
    owned_by = models.ForeignKey(User,
                                 on_delete=models.PROTECT,
                                 verbose_name=_("responsible"))

    created_at = models.DateTimeField(_("created at"), default=timezone.now)
    status = models.PositiveIntegerField(_("status"),
                                         choices=STATUS_CHOICES,
                                         default=IN_PREPARATION)
    type = models.CharField(_("type"), max_length=20, choices=TYPE_CHOICES)
    down_payment_applied_to = models.ForeignKey(
        "self",
        on_delete=models.PROTECT,
        blank=True,
        null=True,
        verbose_name=_("down payment applied to"),
        related_name="down_payment_invoices",
    )
    down_payment_total = MoneyField(_("down payment total"), default=Z2)
    third_party_costs = MoneyField(
        _("third party costs"),
        default=Z2,
        help_text=_("Only used for statistical purposes."),
    )

    postal_address = models.TextField(_("postal address"))
    _code = models.IntegerField(_("code"))
    _fts = models.TextField(editable=False, blank=True)

    payment_notice = models.TextField(
        _("payment notice"),
        blank=True,
        help_text=_(
            "This fields' value is overridden when processing credit entries."
        ),
    )

    objects = InvoiceQuerySet.as_manager()

    class Meta:
        ordering = ("-id", )
        verbose_name = _("invoice")
        verbose_name_plural = _("invoices")

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._orig_status = self.status

    def __str__(self):
        return "%s %s - %s" % (self.code, self.title,
                               self.owned_by.get_short_name())

    def __html__(self):
        return format_html(
            "<small>{}</small> {} - {}",
            self.code,
            self.title,
            self.owned_by.get_short_name(),
        )

    def save(self, *args, **kwargs):
        new = not self.pk
        if new:
            if self.project_id:
                self._code = RawSQL(
                    "SELECT COALESCE(MAX(_code), 0) + 1 FROM invoices_invoice"
                    " WHERE project_id = %s",
                    (self.project_id, ),
                )
            else:
                self._code = RawSQL(
                    "SELECT COALESCE(MAX(_code), 0) + 1 FROM invoices_invoice"
                    " WHERE project_id IS NULL",
                    (),
                )
            super().save(*args, **kwargs)
            self.refresh_from_db()

        self._fts = " ".join(
            str(part) for part in [
                self.code,
                self.customer.name,
                self.contact.full_name if self.contact else "",
                self.project.title if self.project else "",
            ])
        if (self.invoiced_on and self.last_reminded_on
                and self.last_reminded_on < self.invoiced_on):
            # Reset last_reminded_on if it is before invoiced_on
            self.last_reminded_on = None

        if new:
            super().save()
        else:
            super().save(*args, **kwargs)

    save.alters_data = True

    def delete(self, *args, **kwargs):
        assert (self.status <= self.IN_PREPARATION
                ), "Trying to delete an invoice not in preparation"
        super().delete(*args, **kwargs)

    delete.alters_data = True

    def _calculate_total(self):
        if self.type == self.SERVICES:
            services = self.services.all()
            self.subtotal = sum((service.service_cost for service in services),
                                Z2)
            self.third_party_costs = sum(
                (service.third_party_costs
                 for service in services if service.third_party_costs),
                Z2,
            )
        super()._calculate_total()

    @property
    def code(self):
        return ("%s-%04d" %
                (self.project.code, self._code) if self.project else "%05d" %
                self._code)

    def clean_fields(self, exclude=None):
        super().clean_fields(exclude=exclude)
        errors = {}

        if self.status >= self.SENT:
            if not self.invoiced_on or not self.due_on:
                errors["status"] = _(
                    "Invoice and/or due date missing for selected state.")

        if self.status <= self.SENT and self.closed_on:
            errors["status"] = _(
                "Invalid status when closed on is already set.")

        if self.invoiced_on and self.due_on:
            if self.invoiced_on > self.due_on:
                errors["due_on"] = _("Due date has to be after invoice date.")

        if self.type in (self.SERVICES,
                         self.DOWN_PAYMENT) and not self.project:
            errors["__all__"] = _(
                "Invoices of type %(type)s require a project.") % {
                    "type": self.get_type_display()
                }

        if self.status == Invoice.CANCELED and not self.payment_notice:
            errors["payment_notice"] = _(
                "Please provide a short reason for the invoice cancellation.")

        if bool(self.service_period_from) != bool(self.service_period_until):
            errors["service_period_from"] = errors["service_period_until"] = _(
                "Either fill in both fields or none.")
        if (self.service_period_from and self.service_period_until
                and self.service_period_from > self.service_period_until):
            errors["service_period_until"] = _(
                "Until date has to be after from date.")

        raise_if_errors(errors, exclude)

    @property
    def is_invoiced(self):
        return self.status in self.INVOICED_STATUSES

    @property
    def pretty_status(self):
        d = {
            "invoiced_on": (local_date_format(self.invoiced_on)
                            if self.invoiced_on else None),
            "reminded_on": (local_date_format(self.last_reminded_on)
                            if self.last_reminded_on else None),
            "created_at":
            local_date_format(self.created_at.date()),
            "closed_on":
            (local_date_format(self.closed_on) if self.closed_on else None),
        }

        if self.status == self.IN_PREPARATION:
            return _("In preparation since %(created_at)s") % d
        elif self.status == self.SENT:
            if self.last_reminded_on:
                return _(
                    "Sent on %(invoiced_on)s, reminded on %(reminded_on)s") % d

            if self.due_on and dt.date.today() > self.due_on:
                return _("Sent on %(invoiced_on)s but overdue") % d

            return _("Sent on %(invoiced_on)s") % d
        elif self.status == self.PAID:
            return _("Paid on %(closed_on)s") % d
        else:
            return self.get_status_display()

    def payment_reminders_sent_at(self):
        from workbench.tools.history import changes  # Avoid a circular import

        actions = LoggedAction.objects.for_model(self).with_data(id=self.id)
        return [
            day for day in [
                change.values["last_reminded_on"]
                for change in changes(self, {"last_reminded_on"}, actions)
            ] if day
        ]

    @property
    def status_badge(self):
        css = {
            self.IN_PREPARATION: "info",
            self.SENT: "success",
            self.PAID: "default",
            self.CANCELED: "danger",
        }[self.status]

        if self.status == self.SENT:
            if self.last_reminded_on or (self.due_on
                                         and dt.date.today() > self.due_on):
                css = "warning"

        return format_html('<span class="badge badge-{}">{}</span>', css,
                           self.pretty_status)

    @property
    def total_title(self):
        if self.type == self.DOWN_PAYMENT:
            return (_("down payment total CHF incl. tax")
                    if self.liable_to_vat else _("down payment total CHF"))
        else:
            return _("total CHF incl. tax") if self.liable_to_vat else _(
                "total CHF")

    def create_services_from_logbook(self, project_services):
        assert self.project, "cannot call create_services_from_logbook without project"

        for ps in project_services:
            not_archived_effort = ps.loggedhours.filter(
                archived_at__isnull=True).order_by()
            not_archived_costs = ps.loggedcosts.filter(
                archived_at__isnull=True).order_by()

            hours = not_archived_effort.aggregate(
                Sum("hours"))["hours__sum"] or Z1
            cost = not_archived_costs.aggregate(Sum("cost"))["cost__sum"] or Z2

            if hours or cost:
                service = Service(
                    invoice=self,
                    project_service=ps,
                    title=ps.title,
                    description=ps.description,
                    position=ps.position,
                    effort_rate=ps.effort_rate,
                    effort_type=ps.effort_type,
                    effort_hours=hours,
                    cost=cost,
                    third_party_costs=not_archived_costs.
                    filter(third_party_costs__isnull=False).aggregate(
                        Sum("third_party_costs"))["third_party_costs__sum"],
                )
                service.save(skip_related_model=True)
                not_archived_effort.update(invoice_service=service,
                                           archived_at=timezone.now())
                not_archived_costs.update(invoice_service=service,
                                          archived_at=timezone.now())

        (
            self.service_period_from,
            self.service_period_until,
        ) = self.service_period_from_logbook()
        self.save()

    def create_services_from_offer(self, project_services):
        assert self.project, "cannot call create_services_from_offer without project"

        for ps in project_services:
            service = Service(
                invoice=self,
                project_service=ps,
                title=ps.title,
                description=ps.description,
                position=ps.position,
                effort_rate=ps.effort_rate,
                effort_type=ps.effort_type,
                effort_hours=ps.effort_hours,
                cost=ps.cost,
                third_party_costs=ps.third_party_costs,
            )
            service.save(skip_related_model=True)
            ps.loggedhours.filter(archived_at__isnull=True).update(
                invoice_service=service, archived_at=timezone.now())
            ps.loggedcosts.filter(archived_at__isnull=True).update(
                invoice_service=service, archived_at=timezone.now())

        (
            self.service_period_from,
            self.service_period_until,
        ) = self.service_period_from_logbook()
        self.save()

    @classmethod
    def allow_delete(cls, instance, request):
        if instance.status > instance.IN_PREPARATION:
            messages.error(
                request,
                _("Invoices in preparation may be deleted, others not."))
            return False
        return None

    def service_period_from_logbook(self):
        with connections["default"].cursor() as cursor:
            cursor.execute(
                """
with sq as (
    select min(rendered_on) as min_date, max(rendered_on) as max_date
    from logbook_loggedhours log
    left join invoices_service i_s on log.invoice_service_id=i_s.id
    where i_s.invoice_id=%s

    union all

    select min(rendered_on) as min_date, max(rendered_on) as max_date
    from logbook_loggedcost log
    left join invoices_service i_s on log.invoice_service_id=i_s.id
    where i_s.invoice_id=%s
)
select min(min_date), max(max_date) from sq
                """,
                [self.id, self.id],
            )
            return list(cursor)[0]

    @property
    def service_period(self):
        period = [self.service_period_from, self.service_period_until]
        return ("%s - %s" % tuple(local_date_format(day)
                                  for day in period) if all(period) else None)
示例#13
0
class Employment(Model):
    user = models.ForeignKey(
        User,
        on_delete=models.CASCADE,
        verbose_name=_("user"),
        related_name="employments",
    )
    date_from = models.DateField(_("date from"), default=dt.date.today)
    date_until = models.DateField(_("date until"), default=dt.date.max)
    percentage = models.IntegerField(_("percentage"))
    vacation_weeks = models.DecimalField(
        _("vacation weeks"),
        max_digits=4,
        decimal_places=2,
        help_text=_("Vacation weeks for a full year."),
    )
    notes = models.CharField(_("notes"), blank=True, max_length=500)
    hourly_labor_costs = MoneyField(_("hourly labor costs"),
                                    blank=True,
                                    null=True)
    green_hours_target = models.SmallIntegerField(_("green hours target"),
                                                  blank=True,
                                                  null=True)

    class Meta:
        ordering = ["date_from"]
        unique_together = ["user", "date_from"]
        verbose_name = _("employment")
        verbose_name_plural = _("employments")

    def __str__(self):
        if self.date_until.year > 3000:
            return _("since %s") % local_date_format(self.date_from)
        return "%s - %s" % (
            local_date_format(self.date_from),
            local_date_format(self.date_until),
        )

    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)
        next = None
        for employment in self.user.employments.reverse():
            if next is None:
                next = employment
            else:
                if employment.date_until >= next.date_from:
                    employment.date_until = next.date_from - dt.timedelta(
                        days=1)
                    super(Employment, employment).save()
                next = employment

    save.alters_data = True

    def clean_fields(self, exclude):
        super().clean_fields(exclude)
        errors = {}
        if (self.hourly_labor_costs is None) != (self.green_hours_target is
                                                 None):
            errors["__all__"] = _("Either provide both hourly labor costs"
                                  " and green hours target or none.")
        if self.date_from and self.date_until and self.date_from > self.date_until:
            errors["date_until"] = _(
                "Employments cannot end before they began.")
        raise_if_errors(errors, exclude)
示例#14
0
class ExpenseReport(Model):
    created_at = models.DateTimeField(_("created at"), default=timezone.now)
    created_by = models.ForeignKey(User,
                                   on_delete=models.PROTECT,
                                   verbose_name=_("created by"))
    closed_on = models.DateField(_("closed on"), blank=True, null=True)
    owned_by = models.ForeignKey(
        User,
        on_delete=models.PROTECT,
        related_name="expensereports",
        verbose_name=_("responsible"),
    )
    total = MoneyField(
        _("total"),
        default=Z2,
        blank=True,
        null=True,
        help_text=_("Total incl. tax for third-party services."),
    )

    class Meta:
        ordering = ("-created_at", )
        verbose_name = _("expense report")
        verbose_name_plural = _("expense reports")

    def __str__(self):
        return "%s, %s, %s" % (
            self.owned_by.get_full_name(),
            local_date_format(self.created_at.date()),
            currency(self.total),
        )

    def save(self, *args, **kwargs):
        if self.pk:
            self.total = self.expenses.aggregate(
                t=Sum("third_party_costs"))["t"] or Z2
        super().save(*args, **kwargs)

    save.alters_data = True

    def delete(self, *args, **kwargs):
        self.expenses.update(expense_report=None)
        super().delete(*args, **kwargs)

    delete.alters_data = True

    @classmethod
    def allow_create(cls, request):
        if (LoggedCost.objects.expenses(user=request.user).filter(
                expense_report__isnull=True).exists()):
            return True
        messages.error(request, _("Could not find any expenses to reimburse."))
        return False

    @classmethod
    def allow_update(cls, instance, request):
        if instance.closed_on:
            messages.error(request,
                           _("Cannot update a closed expense report."))
        return not instance.closed_on

    @classmethod
    def allow_delete(cls, instance, request):
        if instance.closed_on:
            messages.error(request,
                           _("Cannot delete a closed expense report."))
        return not instance.closed_on

    @property
    def pretty_status(self):
        return ((_("closed on %s") % local_date_format(self.closed_on))
                if self.closed_on else _("In preparation"))

    @property
    def status_badge(self):
        return format_html(
            '<span class="badge badge-{}">{}</span>',
            "light" if self.closed_on else "info",
            self.pretty_status,
        )