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
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)
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)
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)
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)}"
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
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)
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)
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)
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, )
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
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)
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)
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, )